From 19cd0462754f2769275bfb72f70312f0154d5c22 Mon Sep 17 00:00:00 2001 From: qwreey Date: Mon, 24 Aug 2026 18:54:14 +0900 Subject: [PATCH] =?UTF-8?q?design:=20=EC=86=90=20=ED=8A=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=8B=B1=206=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=EB=9F=89=20=EC=B2=98=EB=A6=AC=C2=B7=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=E2=80=94=20H-1~H-54=20=ED=99=95=EC=A0=95,=20M2/M3=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로 발견 보고 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한 순서로** 물어 전량 결정하고 `base/` 24개 문서에 반영했다. 결정과 근거는 `qa-request/pre-implementation-handtrace-round6-followup.md`가 소스이고, 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`. **M2/M3를 막던 것이 전부 닫혔다** — 말단 핸들러 4종의 `setLength`/ `setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가 타입에러인 것(`H-25`), `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`), `:List`의 좌표계 결함 둘(`H-1`/`H-2`). ## 구조가 바뀐 것 넷 - **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설로 `indexOfRaw`가 O(1) 기본 경로가 되고, `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로 강등. 사용자 역제안 — *"raw* 가 층위를 알아야할 이유를 모르겠는 상태 … realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 같이 업데이트해주는 편이"*. 맵을 `_elements`와 같은 층에 두니 층 분리가 오히려 깨끗해졌다 - **`_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget` 신설** — 상태가 셋이 됐다(미실체화/실체화/마운트). `raw*`는 부기를 실체화 시점부터 항상 하고 `native*`만 가른다. 그래야 최초 population 중 `getOffsetAt`이 성립해 `updateFn`의 `index`를 거기서 뽑을 수 있다 - **`Ref.Callbacks`가 해시맵 셋 + `:Uncallback`**(사용자 발견) — 해제가 O(1)이 되고 `ref-plan.md`의 `#t` border 실측 항목이 폐기됐다 - **`blocker:Policy(emit)` 노출** — `Debounce`/`Throttle`이 emit을 안 쥐고 자기 Blocker를 On/Off만 하는 정책이 된다. `Gate`엔 `Flush`/`Cancel`을 안 둔다 요소 타입 검증은 블랙리스트에서 **주입 술어 `isInst` 기반 화이트리스트**로 뒤집혔고(`H-40`), 주입 op이 둘 늘었다(`isInst`/`onDestroying` — 조합 폴백이 불가능해 미주입이면 에러). ## 사용자가 에이전트 갈래를 뒤집은 자리가 여럿 `H-1`(세 갈래가 전부 차선), `H-2`(*"부기 확정에서 length 를 확정해도 되는거 아님?"* — 부기와 물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이 2026-08-21 인스턴스 브랜드 재작성 이후 성립 불가임을 지적), `H-33`(제 중첩 합성안이 unblock 시 디바운스 창을 새로 시작시켜 창이 안 끝난다는 지적). `Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 사용자가 먼저 발견. ## 반영 후 재검토 — `/code-review high` 7건 + 감사 9라운드 34건 **`/code-review high` 7건 중 셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이 됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 `Slot { frameA }` 생성자가 크래시하던 것, `native*`를 `_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐 영구 누수를 만든 것, "`:List`와 CRUD는 상호배타"라며 승인받은 분기가 **재마운트 경로를 안 봐서** 포탈을 깬 것. 셋 다 코퍼스 정합성 각도로는 구조적으로 안 보이는 종류라 *"`/code-review`는 감사자를 대체하지 않는다"*가 실측으로 재확인됐다. 같은 리뷰가 `H-11`의 두 결정이 서로 모순임을 잡아 재결정했다 — *"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가 실재하지 않았고**, 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 `isEffect`를 보고 직접 처리하는 것으로 바뀌었다. **`quad-doc-auditor` 감사 루프는 9라운드에서 새 발견 0건으로 수렴**(라운드별 5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절). 한 턴에 하나씩 돌리고 라운드마다 각도를 바꿨다. 가장 많이 잡은 6라운드(9건)는 *"이 체크박스로 코드를 짜면 무엇이 나오는가"*를 물은 라운드였고, 그때 `ROADMAP.md`의 미완료 항목이 대거 stale인 게 드러났다(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화 계약이 통째로 부재, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순). **반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**: 배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고 `.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중 6행만(7·8라운드). 셋 다 핸드오버 체크리스트가 명시적으로 경고하는 항목이라, 규율이 없어서가 아니라 지켰는지 스스로 확인하지 않아서 생긴 실패다. ## 백로그 하나 `Fallback`/`Traceback` 중 생성된 부분 트리의 회수(`H-26`) — 그 둘이 슈가라 구현 시점에 같이 다룬다. 같이 확인된 것: `dispatch-core-plan.md`가 잔여 부기를 인스턴스 GC가 정리한다고 적은 문장은 **gcconn 불멸성과 양립하지 않는 틀린 안전망 주장**이라 삭제했다. **M2 착수를 막는 설계 항목은 이제 없다** — 남은 건 `question.md` 2번(M2↔M3 양방향 의존, 마일스톤 순서)뿐이다. `doc-check.py` ERROR 0. Co-authored-by: qwreey Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm --- .claude/README.md | 36 +- .claude/base/architecture.md | 7 +- .claude/base/attribute-plan.md | 60 ++ .claude/base/blocker-plan.md | 27 +- .claude/base/component-composition-plan.md | 4 +- .claude/base/debounce-throttle-plan.md | 90 ++- .claude/base/dispatch-core-plan.md | 172 ++++- .claude/base/effect-plan.md | 208 ++++- .claude/base/fallback-plan.md | 55 ++ .claude/base/gate-plan.md | 57 +- .claude/base/lifecycle-pattern.md | 33 + .claude/base/modifier-plan.md | 24 + .claude/base/module-lifecycle-plan.md | 8 +- .claude/base/onchange-plan.md | 20 +- .claude/base/quad-types-plan.md | 35 + .claude/base/ref-plan.md | 139 +++- .claude/base/slot-plan.md | 731 +++++++++++++++--- .claude/base/source-state-plan.md | 62 +- .claude/base/store-plan.md | 16 +- .claude/base/tag-plan.md | 16 +- .claude/base/tween-plan.md | 8 + .claude/base/typing-limits.md | 27 +- .claude/luau-test/STATUS.md | 9 +- ...mplementation-handtrace-round6-followup.md | 375 +++++++++ .../pre-implementation-handtrace-round6.md | 10 +- .claude/research/documentation-content-map.md | 26 +- .claude/session-summary.md | 50 ++ ...26-08-24-01-handtrace-round6-resolution.md | 152 ++++ .claude/todos.md | 78 +- ROADMAP.md | 215 +++++- 30 files changed, 2440 insertions(+), 310 deletions(-) create mode 100644 .claude/qa-request/pre-implementation-handtrace-round6-followup.md create mode 100644 .claude/session/2026-08-24-01-handtrace-round6-resolution.md diff --git a/.claude/README.md b/.claude/README.md index a902b63..e46c475 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,7 +24,7 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음. **[2026-08-21 확장] 확정된 결정의 "왜 그렇게 정했나" 근거 기록도 여기 둔다** — `research/`(아직 상의 필요)도 `archive/`(뒤집혔거나 기각됨)도 아니고, `base/`가 근거로 인용하는 온디맨드 자료라는 이 폴더의 기준에 정확히 맞기 때문(`slot-attach-decomposition.md`/`epoch-brand-composition.md`가 그렇게 들어옴) | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`, **사용자 회신 대기**이고 `base/`는 아직 한 줄도 안 고쳤다. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M3~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M3~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것. **⭐ [2026-08-24] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round6-followup.md`(**[2026-08-24 신설] 6라운드의 결정과 근거가 여기 소스다** — `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정한 기록이고, 반영 후 `/code-review high`가 잡은 7건(그중 셋이 이번 반영이 만든 회귀)도 D절에 있다. 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-19 기준] 폴더 자체가 아직 없음**(M0/M1 스캐폴딩만으론 안 생기고 실제로 렌더링해보고 쓰는 단계부터, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | @@ -45,33 +45,33 @@ |---|---| | `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` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류. **[2026-08-19 정정]** 패키징 매니저를 wally에서 pesde로 전환 — 세부는 `project-setup-plan.md` | | `project-setup-plan.md` | **[2026-08-19 신설, 같은 날 두 차례 후속 갱신]** M0/M1 스캐폴딩을 실제로 pesde/`luau`/Rojo/`selene` CLI로 굴려보고 검증한 결과 — pesde 워크스페이스 구조(`workspace_members`, 패키지 이름은 하이픈 금지), `mise.toml` 툴체인 핀(`rokit.toml`에서 전환, 실제 설치·attestation 검증까지 확인), `init.luau`에서 `@self`가 필수인 이유(Luau RFC `abstract-module-paths-and-init-dot-luau`), 워크스페이스 의존성이 심볼릭 링크로 연결되고 `luau` CLI의 require-by-string은 이를 못 따라가지만 Rojo/Studio 배포 경로는 무관함을 실측 확인, `selene`의 CWD 상대 config 탐색 함정, `.luaurc` alias 런타임 미지원 재확인, `pesde.lock` 커밋 권고(잠정). "확인 완료/아직 확인 안 된 것" 절이 다음에 뭘 검증해야 하는지의 소스 | -| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), §8에 **새 타입·API 설계 시 체크리스트**. **[2026-08-19 신설]** §6 — `type function`을 거친 값(패스스루라도)은 이후 제네릭 self 메소드 체이닝(`AddPlugin`류)이 조용히 깨짐, `quad-types-plan.md`의 `CheckedQuad` 배선 중 실측 발견·회피(원본은 type function을 절대 안 거치게 하고 검사 결과는 별도 필드로 격리). 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` + `luau-test/23` | -| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 | +| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), §8에 **새 타입·API 설계 시 체크리스트**. **[2026-08-19 신설]** §6 — `type function`을 거친 값(패스스루라도)은 이후 제네릭 self 메소드 체이닝(`AddPlugin`류)이 조용히 깨짐, `quad-types-plan.md`의 `CheckedQuad` 배선 중 실측 발견·회피(원본은 type function을 절대 안 거치게 하고 검사 결과는 별도 필드로 격리). 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` + `luau-test/23` **[2026-08-24 6라운드 `H-24` — 실측]** 영향 범위 표에 **`tween:Mapped`** 추가 — `tween:Mapped(fn: (T) -> U): Tween`가 1번이 지목한 모양(`Foo` 안에서 `-> Foo`)과 **글자 그대로 같은데** 이 문서도 `tween-plan.md`도 그걸 모르고 있었다. 인라인 제네릭 메소드로 선언하면 `luau-analyze`가 **진단 없이 조용히 통과**시키고, ③(`typeof(named function)`)로 바꾸면 정상적으로 잡힌다 — **기존 완화책이 그대로 통하는데 아무도 적용을 지시하지 않고 있었다** | +| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 **[2026-08-24 6라운드 `H-11`]** `bindLifetime`/`unbindLifetime`이 **`isEffect`를 보고 `Destroying`을 걸고/끊는다** — `LP-2`가 *"`Effect`가 그 훅을 쓰는 유일한 소비자"*라 확정해뒀는데 **실제로 거는 코드가 어디에도 없었다.** `unbind`는 cleanup을 부르지 않는다(대칭이라 포탈이 자연히 성립) | | `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | -| `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M3 착수 전 결론 필요) | -| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`nativeDispose`에도 재사용 — **[2026-08-22]** 그 op 이름은 `native*` 계층으로 확정, 옛 가칭 `disposeInst`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) | +| `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M3 착수 전 결론 필요) **[2026-08-24 6라운드]** 전파 루프가 구독자 집합을 **스냅샷으로 복사한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 **실행마다 결과가 달라지고 한 Observer가 통째로 누락**됐다(`H-23`, `Epoch` dedup으로는 이중 발화만 접히고 누락은 안 접힌다). `Effect(fn, ...deps)` 역전이 이 문서에 미반영이던 것도 정정 — **`Observer`만 기각 유지**이고 그 근거를 새로 씀("Observer는 리시버 State 하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다", `H-13`) 그리고 **`ObserverEffectLeafHandler`도 자기 배열 자리의 `setOffsetSource`/`setLength`를 안 등록하던 것**을 정정(`H-39` — 말단 핸들러 넷이 같은 결함이었고 이게 그중 하나다, `Frame { someObserver, Frame{} }`가 첫 `recompute`에서 죽었다) | +| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`nativeDispose`에도 재사용 — **[2026-08-22]** 그 op 이름은 `native*` 계층으로 확정, 옛 가칭 `disposeInst`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) **[2026-08-24 6라운드]** (1) **말단 핸들러 4종**(Tag/AttributeGroup/RefLeaf/ObserverEffectLeaf)이 `setOffsetSource`/`setLength`를 **아예 등록 안 하던 것**이 발견돼 계약대로 등록하게 됨 — 안 그러면 `Frame { Tag("x"), Child{} }` 같은 흔한 배치가 첫 `recompute`에서 명시적 error로 죽는다(`H-39`). (2) 접두합 캐시 무효화 3규칙이 **산문으로만 있고 코드 경로가 없던 것**을 `setLength`/`spliceArrays*`/`_baseObserver`에 실제 배치(`H-3`), `bk` 스펙에 `offsetCache`/`invalidAfter` 추가(`H-4`). (3) **`recompute` 호출은 `setLength`의 단독 책임**(`H-19`). (4) Blocker 범위를 **`drive` 전체**로 정정하고 `PostRef` 콜백이 게이트 안에서 돈다는 걸 계약화(`H-17`). (5) *"잔여 부기는 인스턴스 GC로 정리된다"*는 **틀린 안전망 주장 삭제**(gcconn 불멸성과 양립 불가, `H-26`) | | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-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`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설, 같은 날 후속 정정]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `module:RunInit(initFn)`(함수 자체를 릴레이션 키로 쓰는 공유 멱등 가드, 파일마다 따로 두던 센티널 폐기). 실제로 `quad-base/src/init.luau`에 구현·검증됨. `RunInit`은 backend 설치엔 재사용 안 함 — `_initializedBy` 문자열 마커(같은 팩토리=no-op, 다른 팩토리=에러)를 별도로 유지하는 걸로 확정(2026-08-19 해소) | -| `quad-types-plan.md` | **[2026-08-19 신설, 같은 날 후속으로 확장]** 워크스페이스 세 번째 멤버 `quad-types` — 구현 없는 `Quad` 타입 계약 + `AddPlugin`(실측 검증된 제네릭 self 플러그인 체이닝) + `CheckedQuad`(런타임 주입 때문에 pesde semver 보호가 안 걸리는 자리를 메꾸는 컴파일 타임 버전 체크, 글롭/캐럿 패턴 지원). 버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 `type-version-check`(워크스페이스 네 번째 멤버, 사용자가 나중에 독립 저장소로 분리 예정 — `HUMAN_TODO.md` 9번)로 분리됐고 `CheckedQuad`가 그 위에 얹힘. `type function`이 `T`를 패스스루만 해도 이후 제네릭 self 메소드 체이닝이 조용히 깨진다는 새 Luau 함정을 발견·회피(별도 가상 필드로 격리), `export type function`/이중 꺾쇠 제네릭 인스턴스화 등 cross-package 사용 함정도 정리. quad-roblox가 quad-base 대신 이 가벼운 패키지만 의존 — dev-dependency로 두면 게시 후 소비자 환경에서 타입-전용 require가 런타임 크래시하는 문제를 원천 회피 | -| `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/dispatch-core-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/dispatch-core-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이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `nativeDispose(element)`로 위임(**[2026-08-22]** 옛 가칭 `disposeInst`)(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정. **[2026-08-20 구현 전 QA 4라운드 회신 1차]** `SetAndDispose`를 `source:SetAndDispose(value)` 콜론 메서드로 확정, `unmountSlotTree`가 `slot.Offset`은 안 건드림(`SL-75`) 등 회신 반영 다수. **[2026-08-21 구현 전 QA 4라운드 회신 4차 — 이 문서 최대 규모 변경]** (a) **`Detach`의 보존 주체가 `userdata`에서 새 `slot._detached` 필드로 전면 뒤집힘** — gcconn 트릭 때문에 detach된 quad-제작 Instance는 GC 폴백이 아예 없는데 `userdata`는 `:List`에게 opaque라 최종 처분이 불가능했음. 재-`Detach`는 nop, `prev`를 그대로 반환하면 재마운트. (b) 이에 맞춰 **raw 3형제로 분화** — `rawRemove`(소유권 해제+파괴)/`rawUnmount`(해제+파괴 안 함)/**`rawDetach`(소유권 유지+파괴 안 함)**, 그리고 정상 사이클과 소멸 루프가 공유하는 처분 함수 `settle()` 신설. (c) **`KeyGone` 센티널 신설** — 키가 데이터에서 사라진 자리를 조용히 처분하지 않고 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물음(오래 ⚠️ 미결이던 항목 해소), owner 사망 시 최종 정리는 `activateList`가 거는 `Effect`(**[같은 날 이관]** 원래 `mountSlotTree`였으나 `_detached`를 채우는 건 `:List`뿐이라 옮김). (d) **`Owned` 설치 플래그 신설** — `Detach`(사이클 단위)와 직교하는 축으로, `false`면 어떤 경로로도 파괴 안 함(`state` 의미론 충돌 해소). 이로써 값 교체는 `Owned = true`면 **파괴가 맞다**로 재정정(2026-08-18의 "값 교체는 비파괴"를 뒤집음). (e) **`attachSlot`이 `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해** — "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 단일 함수로는 동시 만족 불가라는 진단이 근거, 공개 `attachSlot`은 두 줄짜리 래퍼로 남아 호출부 무변경. (f) `SL-38`의 `userdata` 생명주기 제약에 "quad-제작 Instance는 GC로 안 죽는다"를 예시로 추가. **같은 날 반영 후 감사 6라운드로 실제 크래시 3건이 더 나와 닫혔다**(detach 재마운트가 `claimOwner`에서 죽던 것 등) — **개별 항목을 여기 쌓지 않는다, 처리 전량의 소스는 `qa-request/pre-implementation-qa-round4-followup.md`의 H절·I절** | -| `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 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 | +| `quad-types-plan.md` | **[2026-08-19 신설, 같은 날 후속으로 확장]** 워크스페이스 세 번째 멤버 `quad-types` — 구현 없는 `Quad` 타입 계약 + `AddPlugin`(실측 검증된 제네릭 self 플러그인 체이닝) + `CheckedQuad`(런타임 주입 때문에 pesde semver 보호가 안 걸리는 자리를 메꾸는 컴파일 타임 버전 체크, 글롭/캐럿 패턴 지원). 버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 `type-version-check`(워크스페이스 네 번째 멤버, 사용자가 나중에 독립 저장소로 분리 예정 — `HUMAN_TODO.md` 9번)로 분리됐고 `CheckedQuad`가 그 위에 얹힘. `type function`이 `T`를 패스스루만 해도 이후 제네릭 self 메소드 체이닝이 조용히 깨진다는 새 Luau 함정을 발견·회피(별도 가상 필드로 격리), `export type function`/이중 꺾쇠 제네릭 인스턴스화 등 cross-package 사용 함정도 정리. quad-roblox가 quad-base 대신 이 가벼운 패키지만 의존 — dev-dependency로 두면 게시 후 소비자 환경에서 타입-전용 require가 런타임 크래시하는 문제를 원천 회피 **[2026-08-24 6라운드 `H-25` — 실측]** `Quad`가 **5필드 닫힌 레코드**라 `RunInit`으로 붙인 서브시스템이 타입에 안 보인다(`quad.Dispatch`가 `luau-analyze`에서 타입에러 — `architecture.md` 결정 13번이 그 접근을 표준 사용법으로 확정해둔 것과 정면 충돌). **확정: `quad-types`의 `Quad`를 마일스톤마다 갱신한다** — M2(`Dispatch`)를 시작으로 M3/M6/M7/M8/M10에 체크박스가 뿌려졌다. 타입만 재수출하므로 "가벼운 타입 계약"이라는 존재 이유와 안 부딪힌다 | +| `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/dispatch-core-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/dispatch-core-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이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `nativeDispose(element)`로 위임(**[2026-08-22]** 옛 가칭 `disposeInst`)(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정. **[2026-08-20 구현 전 QA 4라운드 회신 1차]** `SetAndDispose`를 `source:SetAndDispose(value)` 콜론 메서드로 확정, `unmountSlotTree`가 `slot.Offset`은 안 건드림(`SL-75`) 등 회신 반영 다수. **[2026-08-21 구현 전 QA 4라운드 회신 4차 — 이 문서 최대 규모 변경]** (a) **`Detach`의 보존 주체가 `userdata`에서 새 `slot._detached` 필드로 전면 뒤집힘** — gcconn 트릭 때문에 detach된 quad-제작 Instance는 GC 폴백이 아예 없는데 `userdata`는 `:List`에게 opaque라 최종 처분이 불가능했음. 재-`Detach`는 nop, `prev`를 그대로 반환하면 재마운트. (b) 이에 맞춰 **raw 3형제로 분화** — `rawRemove`(소유권 해제+파괴)/`rawUnmount`(해제+파괴 안 함)/**`rawDetach`(소유권 유지+파괴 안 함)**, 그리고 정상 사이클과 소멸 루프가 공유하는 처분 함수 `settle()` 신설. (c) **`KeyGone` 센티널 신설** — 키가 데이터에서 사라진 자리를 조용히 처분하지 않고 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물음(오래 ⚠️ 미결이던 항목 해소), owner 사망 시 최종 정리는 `activateList`가 거는 `Effect`(**[같은 날 이관]** 원래 `mountSlotTree`였으나 `_detached`를 채우는 건 `:List`뿐이라 옮김). (d) **`Owned` 설치 플래그 신설** — `Detach`(사이클 단위)와 직교하는 축으로, `false`면 어떤 경로로도 파괴 안 함(`state` 의미론 충돌 해소). 이로써 값 교체는 `Owned = true`면 **파괴가 맞다**로 재정정(2026-08-18의 "값 교체는 비파괴"를 뒤집음). (e) **`attachSlot`이 `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해** — "부모에게 미는 길이는 최종값"(C6)과 "부기가 물리보다 먼저"(C7)가 단일 함수로는 동시 만족 불가라는 진단이 근거, 공개 `attachSlot`은 두 줄짜리 래퍼로 남아 호출부 무변경. (f) `SL-38`의 `userdata` 생명주기 제약에 "quad-제작 Instance는 GC로 안 죽는다"를 예시로 추가. **같은 날 반영 후 감사 6라운드로 실제 크래시 3건이 더 나와 닫혔다**(detach 재마운트가 `claimOwner`에서 죽던 것 등) — **개별 항목을 여기 쌓지 않는다, 처리 전량의 소스는 `qa-request/pre-implementation-qa-round4-followup.md`의 H절·I절** **[2026-08-24 6라운드 손 트레이싱 — 이 문서가 가장 크게 바뀌었다]** (1) **상태가 셋이 됐다**(미실체화/실체화/마운트) — `_mounted`는 이제 **물리 인스턴스 유무**만 뜻하고 `slot._physicalTarget`이 신설됐다. `raw*`는 **부기를 실체화 시점부터 항상** 하고 `native*`만 `_mounted`로 가른다(`H-2`/`H-12`). (2) **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설 — `indexOfRaw`가 O(1) 기본 경로가 되고 `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로 강등(`H-1`). (3) `updateFn`의 `index`를 **`Dispatch.getOffsetAt`에서** 구한다 — 옛 `result.Length:Get()` 가산은 마운트 전 Slot의 Length가 항상 0이라 아무것도 반영 못 했다(`H-2`). (4) 요소 타입 검증이 **블랙리스트→화이트리스트**(`isSlot`→`isState`→**주입 술어 `isInst`**), 관문은 `wrapElement` 하나(`H-40`). (5) `unwrapElement`에 `isSlot` 가드(Instance에서 항상 죽던 것, `H-21`), `identityUpdateFn`이 `KeyGone` 흡수(`H-22`), `dispose` 가드를 분기 밖으로(`H-28`/`H-43`), `collectLeaves` 신설 + 미작성 `raw*` 규약(`H-29`), 선행 검증 패스(`H-30`/`H-31`), `unmountSlotTree` 역순 순회(`H-6`), top-level `Slot.luau` 신설(`H-46`) | +| `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 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 **[2026-08-24 6라운드 `H-35`]** `flatten`이 만드는 `ProcessedModifier` 센티널을 받는 **`ProcessedModifierHandler`의 의사코드가 없고 색인 두 곳에서도 빠져 있던 것**을 보강 — `Modifier`가 하나라도 든 리터럴은 **전부** 이 핸들러를 거치므로, 이 문서를 안 읽고 색인만 보고 구현하면 존재 자체를 놓친다. 소속 파일은 `quad-base/Dispatch/Modifier.luau`(`architecture.md` 소스 트리와 `ROADMAP.md` M7에도 등재) | | `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` 포인터로 압축 | -| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게. **[2026-08-22 정정]** 구현은 M3가 아니라 **M2**이고, 바닥부터 짜는 게 아니라 공용 `GateNode`(`gate-plan.md`) 위의 **정책**이다. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) | -| `debounce-throttle-plan.md` | **[2026-08-14 신설, 2026-08-19 전부 해소돼 `research/`에서 승격]** 시간 기반 전파 게이트 `Debounce`/`Throttle` — 사용자 요청("`Blocker`와 유사하게")으로 신설. 요지: (1) `Blocker`가 이미 쓰는 게이트 노드의 **릴리스 트리거만 타이머로 바꾼 것**이라 새 전파 메커니즘이 아님, (2) 무효화 채널만 만지므로 laziness 안 깨짐, (3) **Debounce/Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐** — 공개 생성자는 둘, 구현은 하나, (4) 알고리즘은 quad-base + 주입 op 2개 `setTimeout(func, delay) -> Timeout`/`clearTimeout`(Roblox `task.delay`/`task.cancel`로 배선 — **인자 순서 반대라 주의**), `Timeout`은 `{ __type_timeout: true, _native: any }`. **[2026-08-19 마지막 라운드]** 의미론은 **(A) emit-gate**(`Blocker`와 동일, `:Get()`은 항상 최신값)로 확정 — 검토했던 값-지연 안은 laziness와 상충해 철회. 제어 핸들은 개별은 `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak 레지스트리)로 확정, `Time`/`MaxTime`은 `number \| State`(스케줄 시점에만 폴링) 허용. 이름은 `Debounce`/`Throttle` 유지 + Roblox 관용 "debounce"와 다르다는 문서 경고. **결과적으로 quad-base에 새 코어 메커니즘을 안 더하는 순수 슈가로 귀결**(`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힘) — 우선순위는 `Operator.*`와 같은 급으로 재평가됨. **부수 성과**: 이 설계 중 `source-state-plan.md`의 무효화 dedup 서술이 `Observer` 계약과 모순되는 게 발견돼 base 전면 정정(`archive/invalidate-dedup-propagation-reversed.md`) | -| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, ...deps)` — deps 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음. **[2026-08-20 `E-10` → 2026-08-21 `EF-3`에서 반영]** 그 dedup 경로의 process/retract 대칭은 **성립함이 확인됨**(핸들러가 `old`를 `Relate`로 직접 들고 양쪽이 같은 비교식을 씀 — 남은 건 구현 시 회귀 확인뿐, 설계상 열린 항목 아님). **[2026-08-21 5라운드 `C-6`]** 시그니처가 `Effect(fn, ...deps)`로 확장돼 의존성을 여러 개 직접 받고(`Ref`도 가능) 각각에 구독을 건다. **[2026-08-21]** 다중 의존성이 공통 상류를 공유할 때 한 파동에 `fn`이 여러 번 돌던 미해결 갭은 **`EffectHandle`이 자기 `EpochMap`을 들어** 닫힘(`base/state-epoch-plan.md`) | +| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게. **[2026-08-22 정정]** 구현은 M3가 아니라 **M2**이고, 바닥부터 짜는 게 아니라 공용 `GateNode`(`gate-plan.md`) 위의 **정책**이다. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) **[2026-08-24 6라운드 `H-33`/`H-49`]** **`blocker:Policy(emit) -> onUpstreamEmit`** 신설 — 자기 게이트 정책을 값으로 내주고 `state:Block(b)`가 그 위의 얇은 래퍼가 된다. `Debounce`/`Throttle`이 이걸로 자기 Blocker를 조종한다 | +| `debounce-throttle-plan.md` | **[2026-08-14 신설, 2026-08-19 전부 해소돼 `research/`에서 승격]** 시간 기반 전파 게이트 `Debounce`/`Throttle` — 사용자 요청("`Blocker`와 유사하게")으로 신설. 요지: (1) `Blocker`가 이미 쓰는 게이트 노드의 **릴리스 트리거만 타이머로 바꾼 것**이라 새 전파 메커니즘이 아님, (2) 무효화 채널만 만지므로 laziness 안 깨짐, (3) **Debounce/Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐** — 공개 생성자는 둘, 구현은 하나, (4) 알고리즘은 quad-base + 주입 op 2개 `setTimeout(func, delay) -> Timeout`/`clearTimeout`(Roblox `task.delay`/`task.cancel`로 배선 — **인자 순서 반대라 주의**), `Timeout`은 `{ __type_timeout: true, _native: any }`. **[2026-08-19 마지막 라운드]** 의미론은 **(A) emit-gate**(`Blocker`와 동일, `:Get()`은 항상 최신값)로 확정 — 검토했던 값-지연 안은 laziness와 상충해 철회. 제어 핸들은 개별은 `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak 레지스트리)로 확정, `Time`/`MaxTime`은 `number \| State`(스케줄 시점에만 폴링) 허용. **⚠️ [2026-08-24 6라운드 `H-32`/`H-33`] 7절 의사코드에 무효화 배너가 붙었다 — 그 골격은 확정된 `state:Gate(setup)` API로는 성립하지 않아 재작성 대상이다.** 새 모델은 `Debounce`/`Throttle`이 **emit을 아예 안 쥐고** 자기 `Blocker`를 사적으로 하나 갖고(적용 핸들당 하나) `On()`/`Off()` 시점만 정하는 것 — `pending`도 Blocker의 `HasBlockedEmit`으로 흡수되어 `Trailing=false`+`MaxTime`에서 `Flush`가 영구 no-op이던 결함(`H-32`)이 구조적으로 사라진다. 창/타이머 정책 자체(`openWindow`/`onWindowEnd`/`MaxTime` 분기)는 그대로 유효. 이름은 `Debounce`/`Throttle` 유지 + Roblox 관용 "debounce"와 다르다는 문서 경고. **결과적으로 quad-base에 새 코어 메커니즘을 안 더하는 순수 슈가로 귀결**(`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힘) — 우선순위는 `Operator.*`와 같은 급으로 재평가됨. **부수 성과**: 이 설계 중 `source-state-plan.md`의 무효화 dedup 서술이 `Observer` 계약과 모순되는 게 발견돼 base 전면 정정(`archive/invalidate-dedup-propagation-reversed.md`) | +| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, ...deps)` — deps 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 **각 dep에 구독을 따로 걸어**(State/Source면 `Observer`, `Ref`면 `:Callback`) 재실행+cleanup 체이닝(React `useEffect` 동형). **[2026-08-24 표기 정정]** 여기 원래 *"내부적으로 `state:Observer(...)`를 조합"*이라 적혀 있었는데 그건 단수 시절 모델이고, 같은 행 뒤쪽의 `C-6` 서술과 어긋났다. Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음. **[2026-08-20 `E-10` → 2026-08-21 `EF-3`에서 반영]** 그 dedup 경로의 process/retract 대칭은 **성립함이 확인됨**(핸들러가 `old`를 `Relate`로 직접 들고 양쪽이 같은 비교식을 씀 — 남은 건 구현 시 회귀 확인뿐, 설계상 열린 항목 아님). **[2026-08-21 5라운드 `C-6`]** 시그니처가 `Effect(fn, ...deps)`로 확장돼 의존성을 여러 개 직접 받고(`Ref`도 가능) 각각에 구독을 건다. **[2026-08-21]** 다중 의존성이 공통 상류를 공유할 때 한 파동에 `fn`이 여러 번 돌던 미해결 갭은 **`EffectHandle`이 자기 `EpochMap`을 들어** 닫힘(`base/state-epoch-plan.md`). **[2026-08-24 6라운드]** `fn` 시그니처가 **`fn(self: EffectHandle) -> (() -> ())?`**로 확정(**`...deps`는 의존성 선언일 뿐 `fn`에 안 넘어간다**, `H-14`), `_observer`(단수)→`_observers`(배열)(`H-8`), 그리고 **leaf 사망 cleanup을 실제로 발화시키는 배선이 없던 것**이 `bindLifetime`/`unbindLifetime`이 `isEffect`를 보고 `Destroying`을 걸고/끊는 것으로 닫힘(`H-11` — 이게 없어서 `slot._detachCleanup`과 `OnDestroyed`가 통째로 무동작이었다). `Ref` dep 콜백은 해제 경로(`:Uncallback`)와 **발화 시 `canExecute` 확인**을 둘 다 갖는다(`H-7`) | | `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` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | -| `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/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) | -| `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-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드). **[2026-08-18 구현 전 QA 반영]** **`Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가 이김)을 둘 다 제공**으로 열린 항목 해소, 그리고 **⚠️ 같은 그룹 객체를 두 위치에 놓는 경우를 잡을 위치별 claim이 필요**하다는 미해결 항목 신설(`Ref`처럼 `bindLifetime` 재사용은 불가) | -| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 특수 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | +| `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/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) **[2026-08-24 6라운드]** `TagHandler`가 **자기 배열 자리의 `setOffsetSource`/`setLength`를 아예 등록하지 않던 것**을 정정(`H-39` — `Frame { Tag("card"), TextLabel{} }`처럼 Tag를 자식보다 앞에 두는 흔한 배치가 첫 `recompute`에서 error로 죽었다). `isHandlable`에 **`type(k) == "number"` 가드**도 추가(`H-52` — `RefLeafHandler`가 2026-08-18에 받은 수정을 이쪽은 못 받고 있었다) | +| `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-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드). **[2026-08-18 구현 전 QA 반영]** **`Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가 이김)을 둘 다 제공**으로 열린 항목 해소, 그리고 **⚠️ 같은 그룹 객체를 두 위치에 놓는 경우를 잡을 위치별 claim이 필요**하다는 미해결 항목 신설(`Ref`처럼 `bindLifetime` 재사용은 불가) **[2026-08-24 6라운드]** `AttributeGroupHandler`에 (1) 배열 자리 부기 등록(`H-39`, Tag와 같은 결함 — 층위 예외를 두지 않고 다른 말단 핸들러와 똑같이 등록한다), (2) `type(k) == "number"` 가드(`H-52`), (3) **`groupClaimKeys` 위치 claim 배선**(`H-41` — 5라운드 `AT-1`에서 키를 확정해놓고 의사코드에 안 들어가 있었다, `nameClaims`보다 **먼저** 해야 절반만 기록되는 중간 상태가 안 생긴다). 그리고 **attribute 이름을 서로 다른 두 자리 사이에서 옮기는 것은 UB로 확정**(`H-18`/`H-45` — 두 체인이 별개라 emit 순서에 따라 성공하거나 크래시하는데, 사용자 판단: 막으려면 process/retract 계약 전체에 예외가 생겨 오버엔지니어링) | +| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 특수 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 **[2026-08-24 6라운드 `H-27`]** `process`에 **`v == nil` 얼리리턴**을 추가 — 이 문서가 스스로 *"`event-plan.md`와 같은 결"*이라 결론냈는데 그 "같은 결"의 핵심(`v=nil`이면 해제만 하고 새로 Connect하지 않는다)이 의사코드에 없었다. 없으면 `State`를 `None`으로 꺼서 콜백을 끄는 게 실제로는 **나중에 터질 Connection을 새로 심는** 동작이 된다 | | `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`가 실제 사례이자 수정 사례 | -| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`). **[2026-08-18 구현 전 QA 반영]** 내부 구조가 **별도 `.Callbacks` 테이블 + 평범한 `.Value` 필드**로 단순화(`__index` 우회 폐기), `RefLeafHandler.isHandlable`에 빠져 있던 `type(k)=="number"` 추가(leaf 바인딩은 **배열 전용**), "배열 파트의 `None`은 process를 안 탄다"는 옛 명확화 전면 정정 | +| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`). **[2026-08-18 구현 전 QA 반영]** 내부 구조가 **별도 `.Callbacks` 테이블 + 평범한 `.Value` 필드**로 단순화(`__index` 우회 폐기), `RefLeafHandler.isHandlable`에 빠져 있던 `type(k)=="number"` 추가(leaf 바인딩은 **배열 전용**), "배열 파트의 `None`은 process를 안 탄다"는 옛 명확화 전면 정정. **[2026-08-24 6라운드]** `.Callbacks`가 배열에서 **`{[callback|thread] = true}` 해시맵 셋**으로 재설계되고 **해제 경로 `:Uncallback(fn)`**이 신설됨(`H-7` — `Effect`의 `Ref` dep을 뗄 방법이 아예 없던 갭). 중복 등록은 dedup이 계약이고, 발화는 **순회 전 스냅샷**을 뜬다(`H-23`과 같은 처방). 그 부수로 **"구멍 있는 테이블에서 `#t` border" 실측 항목이 폐기**됐다(해시맵엔 border 개념이 없음). `RefLeafHandler`가 자기 배열 자리의 `setOffsetSource`/`setLength`를 등록하게 된 것(`H-39`)과 `PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부라는 것(`H-42`, Deferred에선 그 레이스가 없음 — 구조는 유지)도 같이 반영 | | `event-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 **[2026-08-18 정정] `None`/`nil`을 넣으면 disconnect**(옛 `false` 센티널은 `None` 도입 전의 선택이라 폐기 — `EventHandler.isHandlable`이 `v == nil`에도 매치돼야 함). 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | | `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 branded 타입 전부로 일반화(`isPostRef` 포함, 2026-08-14 아홉 번째 세션). **[2026-08-21 전면 재작성]** 공유 레지스트리 + `Brand.get(x) -> tag`(객체당 태그 하나)에서 **인스턴스 브랜드**(`Brand()` + `:register`/`:is`, **다중 태깅 허용**)로 바뀜 — 발단은 `Source`가 `SourceBrand`이면서 동시에 `EpochBrand`여야 하는데 옛 모양으로는 표현이 안 되던 것. 역조회는 없어졌고(멤버십 질문만 씀), weak-key·테이블 아이덴티티·duck-typing 기각 근거·포함 관계 predicate 합성은 전부 유지. 역전 원문은 `archive/brand-shared-registry-reversed.md`, 근거 기록은 `reference/epoch-brand-composition.md`. 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **[2026-08-18 구현 전 QA 반영]** **`Brand`는 아무 의존성도 갖지 않는다** — `None` 특수 분기 안은 기각(`isNone`은 그냥 `v == None`) | -| `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`는 사용자가 직접 처리(에이전트 범위 제외) | -| `fallback-plan.md` | **[2026-08-14 세션, `research/`에서 승격]** `Fallback`/`Traceback` — 컴포넌트 함수를 감싸 에러 시 플레이스홀더를 그려주는 순수 슈가(`additional-primitives-plan.md`의 "Error Boundary" 절이 내린 "빈 자리 아님" 결론 위에 얹힘). `Fallback`은 `pcall` 기반(trace 없음), `Traceback`은 `xpcall`+`debug.traceback` 기반(trace 항상 있음) — 플래그 대신 별도 함수로 분리(`Ref`/`PreRef`와 같은 패턴). `err: any`(Lua `error()`가 임의 값을 던질 수 있음, `error(msg)` 기본 호출의 위치 접두 캐비엇 포함) 확정. 패키지는 `quad-base`, 이름 확정. 메커니즘 실측은 `audit/fallback-xpcall-verification.md`. 구현 우선순위는 형제 백로그(`quad-mock`/`quad-debug`/`Operator`)와 동급, 맨 뒤 | +| `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`는 사용자가 직접 처리(에이전트 범위 제외) **[2026-08-24 6라운드 `H-24`]** `:Mapped` 절에 타입 단서 추가 — 그 시그니처는 재귀 제네릭 누수 패턴이라 **인라인이 아니라 `typeof(named function)`으로 선언해야** 체커가 지켜준다(`base/typing-limits.md`). "타입이 안전하게 성립한다"는 *의미론상* 맞지만 *체커가 지켜준다*는 뜻이 아니었다 | +| `fallback-plan.md` | **[2026-08-14 세션, `research/`에서 승격]** `Fallback`/`Traceback` — 컴포넌트 함수를 감싸 에러 시 플레이스홀더를 그려주는 순수 슈가(`additional-primitives-plan.md`의 "Error Boundary" 절이 내린 "빈 자리 아님" 결론 위에 얹힘). `Fallback`은 `pcall` 기반(trace 없음), `Traceback`은 `xpcall`+`debug.traceback` 기반(trace 항상 있음) — 플래그 대신 별도 함수로 분리(`Ref`/`PreRef`와 같은 패턴). `err: any`(Lua `error()`가 임의 값을 던질 수 있음, `error(msg)` 기본 호출의 위치 접두 캐비엇 포함) 확정. 패키지는 `quad-base`, 이름 확정. 메커니즘 실측은 `audit/fallback-xpcall-verification.md`. 구현 우선순위는 형제 백로그(`quad-mock`/`quad-debug`/`Operator`)와 동급, 맨 뒤 **⚠️ [2026-08-24 6라운드 `H-26`] 미해결 항목이 하나 신설됐다** — **실패 이전에 생성된 부분 트리는 회수되지 않는다.** quad Instance는 gcconn 때문에 `Destroy`로만 회수되는데, 컴포넌트가 리터럴을 만들다 던지면 그때까지 완성된 형제/자손이 트리에 붙지도 파괴되지도 않은 채 사라지고 `Fallback`은 그 존재를 알 방법이 없다. **`Fallback`/`Traceback`이 그 경로를 계속 살려두는 걸 존재 이유로 삼는 대표 사용처**라 층위가 다르다 — **백로그**(그 둘이 슈가라 구현 시점에 같이 다룬다, 그 문서의 ⚠️ 절이 소스) | | `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 | -| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Block(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **남은 것은 생명주기·재진입 계약뿐**(**[2026-08-22]** 미결이던 "M2 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 M2**로 해소). 구현은 M2 | +| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Block(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "M2 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 M2**로 해소.) 구현은 M2 | | `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만 돌며 값만 앞당기고 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** **[2026-08-22 정정]** 구현 마일스톤은 둘로 갈린다 — 부기 객체 `EpochMap.luau`와 `Epoch` 인터페이스·리비전 갱신은 **M2**(`GateNode`가 씀), State 본체 통합은 **M3** | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 320d226..2da358e 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -271,6 +271,8 @@ quad/ │ ├── AttributeKey.luau # 단일 키 `AttributeKey<>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치) │ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계) │ ├── Effect.luau # `Effect(fn, ...deps)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) +│ ├── Slot.luau # [2026-08-24 `H-46`] 값 타입 본체 — 생성자, 공개 CRUD, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`, `attachSlot` 3형제, `elementOwner`/`claimOwner`/`releaseOwner`, `dispose`, `Detach`/`KeyGone`(`base/slot-plan.md`). 다른 값 타입과 같은 대칭 — 아래 `Dispatch/Slot.luau`는 핸들러/부기만 +│ ├── Debug/init.luau # [2026-08-24 `H-47`] M1에서 **이미 커밋됨** — `InitDebug(module)`, `module.debug = false`(`base/project-setup-plan.md`) │ ├── Dispatch/ │ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환) @@ -280,7 +282,8 @@ quad/ │ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]** 백엔드 팩토리가 아님)(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) │ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]**) │ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절). `AttributeGroupFallbackHandler`가 같은 방식으로 감쌈 -│ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) +│ │ ├── Slot.luau # SlotHandler — 마운트/언마운트 + 그 자리 Length/Offset 부기(값 타입 본체는 위 top-level `Slot.luau`) +│ │ └── Modifier.luau # [2026-08-24 `H-35`] ProcessedModifierHandler — flatten이 소진한 자리를 캐치해 `setOffsetSource(None)`/`setLength(0)`만 등록하는 nop 핸들러(`base/modifier-plan.md`) │ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 │ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) │ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 @@ -292,7 +295,7 @@ quad/ ├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존 └── src/ ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상 목록은 아래 EngineOps.luau 줄이 소스 — 여기서 다시 나열하지 않는다(**[2026-08-22]** 예전엔 addTag/removeTag/setAttribute까지만 적혀 있어 native*/setTimeout이 빠져 있었음). bindLifetime/canBound/canExecute도 같은 경로로 주입됨 - ├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드 신설, 이름 확정] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백**. **[2026-08-22 추가] 시간 op 둘** — setTimeout(func, delay) -> Timeout / clearTimeout(t), Roblox는 task.delay/task.cancel로 배선(**인자 순서가 반대라 주의**); `Debounce`/`Throttle`이 얹힐 때 필요하고 그 전엔 미주입이어도 무방(`base/debounce-throttle-plan.md`). **이 줄이 주입 op 전체 목록의 단일 소스다** — 다른 문서는 개수를 세지 말고 여기를 가리킬 것 (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) + ├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드 신설, 이름 확정] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백**. **[2026-08-22 추가] 시간 op 둘** — setTimeout(func, delay) -> Timeout / clearTimeout(t), Roblox는 task.delay/task.cancel로 배선(**인자 순서가 반대라 주의**); `Debounce`/`Throttle`이 얹힐 때 필요하고 그 전엔 미주입이어도 무방(`base/debounce-throttle-plan.md`). **이 줄이 주입 op 전체 목록의 단일 소스다** — 다른 문서는 개수를 세지 말고 여기를 가리킬 것 (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절). **[2026-08-24 6라운드 신설] `isInst(value): boolean`**(`H-40` — 요소 타입 검증을 화이트리스트로 뒤집으면서 생긴 판정 술어, quad-roblox는 `typeof(value) == "Instance"`)**와 `onDestroying(inst, fn): Connection`**(`H-11` — `Effect`의 leaf 사망 cleanup을 발화시키는 훅, `bindLifetime`이 `isEffect`일 때 부른다, quad-roblox는 `inst.Destroying:Connect(fn)`). **⚠️ 이 둘은 `native*`의 "미주입이면 조합 폴백" 규칙의 예외다 — 조작이 아니라 판정/훅이라 조합으로 만들 수 없어 미주입이면 명확한 에러**(`addTag`/`setAttribute`와 같은 취급) ├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) ├── Handlers/ │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 59b183d..8ec0a4a 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -265,6 +265,37 @@ end **정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`) — 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹 `Attribute`는 자원이 **값 하나**라 겹침이 곧 충돌이다. +- **⚠️⚠️ [2026-08-24 신설, 6라운드 손 트레이싱 `H-18`/`H-45`] 이름을 **서로 다른 + 두 자리 사이에서** 옮기는 것은 **UB다.** 아래 "해제 → 재클레임 순서" 보장은 + **한 `(inst,k)` 체인 안에서만** 성립한다("자기 자신과 충돌하는 일이 없음"의 + 마지막 다섯 글자가 정확히 그 뜻이다). + + ```lua + Frame { + groupA, -- k=1, State. 지금 {"Hp"}를 잡고 있음 + groupB, -- k=2, State. 지금 {} + } + -- 런타임에: A는 "Hp"를 놓고, B가 "Hp"를 가져간다 + ``` + + 두 자리는 **완전히 별개의 체인**이고 각자 자기 `StoreBind`의 Observer가 + 독립적으로 깨어난다. `groupB` 쪽 emit이 먼저 처리되면 `nameClaims`가 아직 + `groupA`의 키를 가리키므로 **`attribute "Hp" is already bound by another + owner` error**가 난다. `groupA` 쪽이 먼저면 정상 동작한다 — **같은 코드가 + emit 순서에 따라 성공하거나 크래시한다.** 단건 `AttributeKey` ↔ 그룹 + 사이의 이전(`H-45`)도 정확히 같은 모양이다. + + **확정: 막지 않는다.** **사용자 판단**(2026-08-24): *"state 로 그 + 경로가 열리는데 어떤 방식으로 process 를 걸어도 외부에 바꿀지 말 지 알 방법이 + 없음. 외부에서 먼저 retract 가 나도록 유도하는게 아닌 한 알 방법이 없어보임. + 애초에 싱크이기도 하고, 또 이걸 허용하면 process/retract 계약의 전체에 대한 + 예외들이 생기고 오버 엔지니어링으로 보임."* + - 검토했다 기각된 대안 둘: **claim 반납을 2단계로**(재프로세스 사이클 동안 + "예약 해제" 상태를 두고 사이클 끝에 sweep — `Dispatch`에 사이클 개념을 + 들여야 해서 비쌈), **이전 소유자 생존 확인 후 인수**(판정 수단이 없고, + 그걸 만들면 위 인용의 "예외들"이 정확히 그것이다). + - **저작 지침**: 이름을 다른 자리로 옮기려면 **놓는 쪽을 먼저 커밋**할 것. + 한 프레임 안에서 동시에 하지 말 것. - **해제 → 재클레임 순서는 `Dispatch`가 보장함.** 같은 핸들러 재프로세스는 `slot.retractor(v)` → `h.process(...)` 순서이고, 핸들러가 바뀌는 경우는 `retractFrom` → `process` 순서(`base/dispatch-core-plan.md` "Dispatch @@ -390,7 +421,31 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구 클로저가 "이 호출이 등록한 키 목록"을 직접 캡처: ```lua +-- [명시화, 2026-08-24 6라운드 `H-52`] `isHandlable`이 산문으로만 "배열 파트"라 +-- 서술돼 있고 코드가 없었다 — `TagHandler`와 같은 모양으로 명시한다. +AttributeGroupHandler.isHandlable(inst, k, v) = (type(k) == "number" and isAttribute(v)) + function AttributeGroupHandler.process(inst, k, v, index) + -- [2026-08-24 6라운드 `H-39`] **말단 핸들러의 배열 자리 부기** — 빠져 있었다. + -- 그룹 핸들러는 "다른 키로 위임"하는 성격이라 층위 질문이 있었지만, + -- Tag/Attribute를 "Length/Offset 비참여 카테고리"로 재정의하면 `bk.N`의 + -- 의미가 바뀌어 파급이 커서 **다른 말단 핸들러와 똑같이 등록**하는 것으로 + -- 확정(사용자, 2026-08-24). 없으면 `Frame { Attribute(store), Slot() }`이 + -- 첫 `recompute`에서 `sourceList[k]가 nil`로 죽는다. + Dispatch.setOffsetSource(inst, k, None) + Dispatch.setLength(inst, k, 0, inst) + + -- [2026-08-24 6라운드 `H-41`] **`groupClaimKeys` 위치 claim** — 5라운드 + -- `AT-1`에서 `(inst, groupValue) → k`로 확정해놓고 의사코드에 배선되지 + -- 않았다. `nameClaims`보다 **먼저**여야 한다(같은 그룹의 두 번째 위치는 + -- 이름 claim까지 갈 것 없이 여기서 거부되어야 `nameClaims`에 절반만 + -- 기록되는 중간 상태가 안 생긴다 — 위 그 항목이 소스). + local claimed = groupClaimKeys:GetStrong(inst, v) + if claimed ~= nil and claimed ~= k then + error("Attribute: 같은 그룹 값이 이 인스턴스의 두 위치에 놓임") + end + groupClaimKeys:SetStrong(inst, v, k) + local keys = {} for name, source in pairs(v:NameMap()) do -- isHandlable이 이미 isAttribute(v)를 보장 -- 그룹 전용 키(비공개) — 공개 AttributeKey(name)이 아님. @@ -407,6 +462,11 @@ function AttributeGroupHandler.process(inst, k, v, index) for key in pairs(keys) do Dispatch.retractFrom(inst, key, 1) end + -- [2026-08-24 `H-41`] 위치 claim도 반납 — **자기 `k`일 때만** + -- (위 그 항목의 확정: "retract에서 자기 `k`일 때만 지운다"). + if groupClaimKeys:GetStrong(inst, v) == k then + groupClaimKeys:SetStrong(inst, v, nil) + end end end ``` diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index 60a3694..9635bf2 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -57,6 +57,15 @@ blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를 state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에 -- 처음 블록될 때가 아니라) onunblock 핸들을 -- blocker의 weak 배열에 등록. +blocker:Policy(emit) -> onUpstreamEmit + -- [2026-08-24 신설] 이 blocker의 게이트 정책을 + -- **값으로** 돌려준다. `state:Block(b)`가 + -- 내부에서 쓰는 바로 그것: + -- state:Block(b) + -- == state:Gate(function(emit) return b:Policy(emit) end) + -- **[2026-08-24 정정]** 여기 `state:Gate(b.Policy)`라 + -- 적었었는데 그건 언바운드 메소드라 `emit`이 `self` + -- 자리에 들어가 게이트가 영원히 안 열린다. ``` gated state의 동작: @@ -85,8 +94,22 @@ gated state의 동작: 정책 하나다 — `Block`이 내부에서 `self:Gate(policy)`를 부르고, 그 `policy`가 `blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지 정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다. -**공개 표면(`Blocker()` 생성자와 `state:Block`)은 안 바뀐다** — 배선만 -공용 노드를 쓰는 것. + +**⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로 +꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고, +`Blocker()` 생성자와 `state:Block`은 그대로다. + +- **왜 필요한가**: `Debounce`/`Throttle`이 "언제 통과시킬지"를 정하면서 + 실제 emit/보류 배선은 Blocker에 위임할 수 있어야 한다. 정책을 값으로 낼 수 + 있으면 그게 그냥 함수 합성이 된다 — `setup`이 곧 + `(emit) -> onUpstreamEmit`이라 타입이 이미 맞는다. +- **정책이 하는 일은 안 바뀐다** — `Policy(emit)`을 부르는 시점에 onunblock + 핸들이 등록되므로(지금 `state:Block`이 하던 것과 같은 자리), `Off()`가 + 풀 때 그 `emit`이 정확히 1회 불린다. +- **`Debounce`/`Throttle`은 Blocker를 사적으로 하나 갖는다** — 적용 핸들당 + 하나(커링 결과가 여러 곳에 적용될 수 있으므로 `Apply` 시점 생성). + `pending` 같은 상태는 별도로 안 들고 `HasBlockedEmit`으로 흡수한다. + 상세와 의사코드는 `base/gate-plan.md`의 5번 항목이 소스. **`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도 누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index d114b27..59ae6d2 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -227,7 +227,9 @@ Modifier/Ref/자식을 구분"이 이미 v1의 유일한 해법이었던 패턴 있음(`base/ref-plan.md`의 PreRef pre-pass 절이 다루는 것과 같은 부류의 문제 — Luau REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라 테이블 전체에 영향. **[주의]** 순서가 안 중요한 Ref 자신의 콜백/대기자 -배열은 반대로 `nil` 소진이 맞다는 정정이 따로 있음 — 여기서 다루는 건 +집합은 반대로 `nil` 소진이 맞다는 정정이 따로 있음(**[2026-08-24 어휘 정정]** +한때 "배열"이라 적었으나 6라운드 `H-7`로 `.Callbacks`는 **해시맵 셋**이 됐다 — +`t[k] = nil` 해제라 결론은 같고 이름만 바뀌었다) — 여기서 다루는 건 컴포넌트가 넘기는 **리터럴 children 배열**(순서가 중요한 배열)이라 그 정정과 무관하고 `None` 관용구가 계속 맞음). 그래서 **컴포넌트 저작자는 항상 `or None`으로 감싸서 넘겨야 함**: diff --git a/.claude/base/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md index 0ac34be..ad41231 100644 --- a/.claude/base/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -684,7 +684,7 @@ export type Timeout = { 호출 지점마다 캐스트를 흩뿌리는 방식은 나중에 누가 다른 필드를 더 끼워넣어도 아무도 모르는 게 문제였음 — quad가 타입 검사를 조용히 끄는 걸 싫어해온 것(`base/typing-limits.md`)과 결이 맞는 쪽으로 정리됨. -- `_` 접두사는 코퍼스의 기존 private 필드 관례(`handle._observer`, +- `_` 접두사는 코퍼스의 기존 private 필드 관례(`handle._observers`, `slot._mountedInst`, `_fired`)와 일치. - **`_native`에 뭘 담을지는 전적으로 백엔드 자유** — coroutine 하나일 수도, 취소 플래그를 담은 테이블일 수도 있음(아래 "취소를 제공하지 않는 @@ -797,11 +797,67 @@ function clearTimeout(timeout: Timeout) timeout._native() end ## 7. 의사코드 -> **주의**: `Gate` 노드의 내부 훅(`onUpstreamSignal`, `commit`)은 아직 -> 이름도 계약도 확정되지 않은 가칭 — `Blocker`의 게이티드 노드가 이미 -> 필요로 하는 것과 같은 훅이라(1절), 실제 구현 시엔 그쪽과 같이 정의할 것. -> 아래 코드의 `registry`/`Handle` 배선도 마찬가지로 스케치 수준 — 실제 -> 구현 시 이름은 자유. +> **⚠️ [2026-08-24 무효화 배너, 6라운드 손 트레이싱 `H-33`] 아래 의사코드의 +> 골격은 확정된 API로는 성립하지 않는다 — 다시 쓸 것.** 옛 주의문은 +> *"내부 훅 이름이 아직 가칭"*이라고만 말했는데, 문제는 이름이 아니라 **모양** +> 이다. 아래는 탑레벨 `Gate(self)` 생성자를 부르고 반환 객체에 +> `.onUpstreamSignal`/`._flush`/`._cancel`을 **사후 대입**하는데, 확정된 형태는 +> 정반대다: +> +> - `base/gate-plan.md`가 **`state:Gate(setup)`**(`setup: (emit) -> onUpstreamEmit`)로 +> 못박으면서 *"탑레벨 `Gate(...)` 생성자는 안 만든다"*고 명시했다 → +> `Gate`라는 호출 가능한 값이 없어 `attempt to call a nil value`. +> - 이름을 `self:Gate(...)`로 고쳐도 `setup`이 핸들러를 **반환**해야 하는 +> 프로토콜과 안 맞는다. +> - 호출자는 State 하나만 받고 **노드 객체에 접근할 수 없으므로** +> `Handle:Set({ Flush = gate._flush, ... })`는 표현 자체가 불가능하다. +> +> **다시 쓸 방향은 정해져 있다**(사용자 확정 2026-08-24, +> `base/gate-plan.md`의 5번 항목이 소스): `Debounce`/`Throttle`은 **`emit`을 +> 아예 안 쥔다.** 자기 `Blocker`를 사적으로 하나 갖고(적용 핸들당 하나) +> **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류는 +> `blocker:Policy(emit)`이 돌려준 핸들에 위임한다: +> +> ```lua +> state:Gate(function(emit) +> local b = Blocker() +> local pass = b:Policy(emit) +> return function() -- 상류 emit 도착 (동기) +> -- 타이머/창 판단 후 b:On() / b:Off() / b:OffWithoutEmit() +> pass() +> end +> end) +> ``` +> +> - `gate:passThrough()` → **`b:Off()`**(보류분 1회 방출), 그 뒤 다시 `b:On()`. +> - `Trailing = false` 경로 → **`b:OffWithoutEmit()`**. +> - **`pending`은 없앤다** — "보류된 게 있는가"는 Blocker의 `HasBlockedEmit`이 +> 이미 들고 있다(중복 상태를 안 만든다). 이게 `H-32`를 구조적으로 없앤다. +> - `Flush`/`Cancel` 핸들은 `setup` 클로저 안에서 `b`를 캡처해 만든다 — +> 노드 객체 참조가 필요 없다(`Blocker`가 onunblock 핸들을 등록하는 방식과 +> 같은 우회). +> +> 아래 코드는 **창/타이머 정책 자체의 참고용**으로만 읽을 것 — +> `openWindow`/`onWindowEnd`/`MaxTime`의 분기 구조는 그대로 유효하다. + +**⭐ [2026-08-24 `H-32`] 같이 고쳐야 할 논리 결함 하나 — `Trailing = false`에서 +`pending`이 영구히 참으로 남는다.** 아래 코드는 창이 열려 있는 동안 오는 신호를 +`trailing`과 **무관하게** `pending = true`로 세우는데, `pending`을 `false`로 +되돌리는 자리는 **전부** `if pending and trailing then` 안에 있다 +(`onWindowEnd` / `MaxTime` 콜백 / `_flush` 셋 다). 그래서 +`Debounce{Leading=true, Trailing=false, MaxTime=…}`(문서가 *"버스트 시작에 한 +번만"*이라며 직접 드는 정상 사용례)에서 한 버스트에 신호가 둘 이상 오면: + +- `MaxTime`이 매번 재무장되며 **영원히 아무 효과가 없고**, +- `opts.Handle`로 받은 `:Flush()`가 그 가드에 막혀 **영구 no-op**이 된다 + (사용자가 명시적으로 커밋을 요청해도 반응이 없다). `:Cancel()`만이 + `pending = false`를 무조건 하므로 유일한 탈출구다. + +**해소는 위 재작성에 흡수된다** — `pending`을 없애고 Blocker의 +`HasBlockedEmit`을 쓰면 "보류분이 있는가"와 "그걸 어떻게 풀 것인가"가 분리되어 +이 결함이 구조적으로 성립하지 않는다(`Trailing = false`는 `OffWithoutEmit()` +으로 표현되고, 그건 보류분을 **버리면서** 상태도 같이 비운다). 아래 코드를 +참고할 때 이 결함을 그대로 옮기지 말 것. ```lua -- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절). @@ -936,15 +992,23 @@ function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end 3.5 입력 → window == nil → leading 즉시 통과 ● ``` -**(A) emit-gate가 붙는 자리(확정, 4절)**: `gate`는 다른 평범한 State와 -똑같이 **매 `onUpstreamSignal` 진입 시 자기 `invalid`를 즉시 세운다** — +**(A) emit-gate가 붙는 자리(확정, 4절)**: `GateNode`는 다른 평범한 State와 +똑같이 **매 `onUpstreamEmit` 진입 시 자기 `invalid`를 즉시 세운다** — `source-state-plan.md`의 "전파 모델 확정" 절이 정한 전파 규칙(**[2026-08-21]** "항상"이 아니라 "같은 에포크의 두 번째만 접는다" — `base/state-epoch-plan.md`)이 -게이트 자신에게도 그대로 적용됨. 위 코드의 `gate:passThrough()`가 -실제로 미루는 건 -**다운스트림 통지(전파)뿐**이지 invalid 세팅이 아님 — 그래서 창이 열려 -있는 동안 `gate:Get()`을 불러도 항상 최신값이 계산됨(캐시가 stale한 -채로 안 남음). +게이트 자신에게도 그대로 적용됨. 정책이 미루는 건 **다운스트림 통지(전파)뿐** +이지 invalid 세팅이 아님 — 그래서 창이 열려 있는 동안 `:Get()`을 불러도 항상 +최신값이 계산됨(캐시가 stale한 채로 안 남음). + +**⚠️ [용어 정정, 2026-08-24 6라운드 `H-33`] 이 문단도 위 무효화 배너의 적용 +대상이다.** 원래 `gate.onUpstreamSignal` / `gate:passThrough()`라는 **가칭 +스캐폴딩 이름**으로 쓰여 있었는데 둘 다 확정 API가 아니다 — 확정된 훅 이름은 +`setup(emit) -> onUpstreamEmit`이고(`base/gate-plan.md`), 재작성 후 +`Debounce`/`Throttle`은 `emit`을 직접 쥐지 않으므로 **`gate`라는 단일 객체를 +호출부가 손에 쥐는 모양 자체가 없다**(대신 자기 `Blocker`를 `On()`/`Off()` +한다). **이 문단이 말하는 의미론적 결론(즉시 invalidate, 전파만 지연 — +`Blocker`의 gated state와 동일)은 그대로 유효하고**, 바뀐 건 그걸 표현하는 +이름뿐이다. --- diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 46098fb..e860562 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -579,13 +579,31 @@ end `PostRef`를 fire하는 짧은 루프. 둘 다 배열 재순회가 아니라 pre-pass 하나 + 실제 `PostRef` 개수만큼의 목록 순회라 비용이 작음 — 상세는 `base/ref-plan.md`의 "`PostRef`" 절. - **[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결] 배열 파트 순회 - 전체를 `inst` 전용 `Blocker`로 감싼다** — 순회 시작 전에 - `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, 배열 파트 순회가 - (pre-pass/post-pass 포함) 전부 끝나면 `:OffWithoutEmit()` 한 뒤 - `recompute(inst, bk)`를 명시적으로 1회 호출 — 상세 근거·`setLength`/ - `setOffsetSource`가 이 Blocker를 어떻게 쓰는지는 아래 "배치 등록을 - 안전하게 만드는 Blocker 게이팅" 절이 소스. + **[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결 / 범위 정정 + 2026-08-24 `H-17`] `drive` 전체를 `inst` 전용 `Blocker`로 감싼다** — + 진입 직후 `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, + **`drive`가 할 일을 전부 마치면**(단일 일반화 순회 + post-pass 포함) + `:OffWithoutEmit()` 한 뒤 `recompute(inst, bk)`를 명시적으로 1회 호출 — + 상세 근거·`setLength`/`setOffsetSource`가 이 Blocker를 어떻게 쓰는지는 + 아래 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. + - **왜 "배열 파트"가 아니라 "`drive` 전체"인가**: 옛 문장은 *"배열 파트 + 순회 전체를 … (pre-pass/post-pass 포함)"*이었는데 두 군데가 안 맞았다. + (1) `F-4-1`이 확정한 **단일 일반화 `for` + `type(k) == "number"` 분기** + 에선 "배열 파트가 끝나는 시점"이 루프 밖에서 관측되지 않는다 — 첫 + 비숫자 키를 만나는 순간이거나 루프 종료이고, 어느 쪽인지는 그 인스턴스의 + props에 해시 키가 있느냐에 달렸다. (2) `postRefList` 소비는 애초에 + **해시 파트보다 뒤**다(`base/ref-plan.md`의 `PostRef` 절). 그래서 실제로 + 감쌀 수 있는 범위는 `drive` 전체다. 넓어지는 것 자체는 무해하다 — + 해시 파트는 `setLength`를 안 부른다. + - **⚠️ 따라서 `PostRef` 콜백은 게이트가 켜진 채로 실행된다.** 그 콜백은 + 사용자 코드이고, `base/ref-plan.md`가 드는 대표 용례부터가 "이 시점 + 이후의 동적 변경을 구분하는 플래그를 세우는 것"이라 **그 자리에서 + `slot:Add(...)`류를 부르는 게 자연스럽다.** 그러면 그 Slot 자신의 + blocker는 이미 꺼져 있어 자기 `recompute`는 정상적으로 돌지만, 바뀐 + `slot.Length`가 emit되어 올라온 부모 `inst`의 `gatedRecompute`는 + **여기서 조용히 스킵된다.** 정합성은 직후의 명시적 `recompute(inst, bk)` + 한 번에 전적으로 의존한다 — **[2026-08-24] 이걸 계약으로 못박는다** + (지금까지는 우연히 맞고 있었을 뿐 어디에도 적혀 있지 않았다). **진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입 후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을 처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와 @@ -874,10 +892,20 @@ Fallback Handler들도 존재하지 않아**, 위 "매치 실패는 즉시 `erro **선택적 업그레이드**일 뿐 기본 요구사항은 아님 — base 기본 스텁 하나로도 이미 충분히 안전하게 실패함(`AttributeGroupHandler`의 "부분 실패 경로" 절이 이미 정리한 - "에러=패닉 상태, 그 이후 정합성은 관리 대상 아님" 원칙 + `nameClaims`/ - `tagNameMap`이 `inst`에 대해 weak라 그 인스턴스가 GC되면 잔여 부기도 - 같이 사라지는 것으로 충분히 커버됨), 더 깔끔한 실패를 원하는 백엔드만 - 추가로 얹으면 됨. + "에러=패닉 상태, 그 이후 정합성은 관리 대상 아님" 원칙), 더 깔끔한 실패를 + 원하는 백엔드만 추가로 얹으면 됨. + - **⚠️ [정정, 2026-08-24 6라운드 손 트레이싱 `H-26`] 여기 원래 근거로 적혀 + 있던 *"`nameClaims`/`tagNameMap`이 `inst`에 대해 weak라 그 인스턴스가 + GC되면 잔여 부기도 같이 사라진다"*는 **틀린 안전망 주장이라 삭제했다.*** + quad는 자기가 만든 Instance마다 생성 즉시 gcconn을 걸고 그 클로저가 + `inst`를 캡처하므로(`base/lifecycle-pattern.md`의 "(0)" 절), + **참조를 놓는 것만으로는 회수되지 않고 반드시 `Destroy`로만 회수된다.** + 부분 실패한 `inst`에 대해 `Destroy`를 부를 주체가 없으면 그 인스턴스는 + 안 죽고, 따라서 weak 테이블의 엔트리도 안 사라진다. 남는 근거는 위의 + "에러=패닉" 원칙 하나뿐이고, 그건 그대로 유효하다. + (부분 생성 후 예외로 생긴 Instance 자체의 회수 문제는 **백로그** — + `Fallback`/`Traceback`이 그 경로를 계속 살려두는 걸 존재 이유로 삼는 + 대표 사용처라 그 둘을 구현할 때 같이 다룬다.) - **타입 패밀리는 백엔드 몫**: `AttributeKey<>` 제네릭 생성자와 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) 까지가 base이고, `Color3Attribute`류처럼 **엔진 고유 타입**에 묶인 @@ -1024,13 +1052,24 @@ end | `NoneHandler` | 중간 | 없음(재위임만) | | `NilHandler` | 말단 | 없음(`setLength`/`setOffsetSource` 부기만 — 2026-08-18 신설) | | `PropertyHandler` | 말단 | 프로퍼티 세팅 | - | `TagHandler` | 말단 | `addTag`/`removeTag` | + | `TagHandler` | 말단 | `addTag`/`removeTag` (+ 부기 — `H-39`) | | `AttributeKeyHandler` | 말단 | `setAttribute` | - | `AttributeGroupHandler` | 자기 체인에선 말단 | 없음(다른 키로 위임) | + | `AttributeGroupHandler` | 자기 체인에선 말단 | 다른 키로 위임 (+ 부기 — `H-39`) | | `SlotHandler` | 말단 | 마운트/언마운트 | - | `RefLeafHandler` | 말단 | `Ref:Set` | + | `RefLeafHandler` | 말단 | `Ref:Set` (+ 부기 — `H-39`) | + | `ObserverEffectLeafHandler` | 말단 | `bindLifetime` (+ 부기 — `H-39`) | + | `ProcessedPreRefHandler` / `ProcessedPostRefHandler` | 말단 | 없음(부기만) | + | `ProcessedModifierHandler` | 말단 | 없음(부기만 — `H-35`) | | `UICornerHandler` | 말단 | 자식 Instance 생성/제거 | + **[2026-08-24 `H-39`/`H-35`] 이 표 자체가 비대칭을 드러내고 있었다** — + `NilHandler` 행엔 부기가 적혀 있는데 바로 아래 `RefLeafHandler` 행엔 + `Ref:Set` 하나만 적혀 있었다. 말단 핸들러는 **예외 없이** 자기 배열 위치의 + `setOffsetSource`/`setLength`를 등록한다(위 "짝을 맞춰 `0`" 문단의 `H-39` + 블록). `ProcessedModifierHandler`는 이 표와 아래 등록 책임 열거 양쪽에서 + 통째로 빠져 있었다(`base/modifier-plan.md`의 flatten이 만드는 센티널의 + 전담 nop 핸들러 — `Modifier`가 하나라도 든 리터럴은 전부 이걸 거친다). + 이 계약이 필요한 이유: (A) 분기는 **아래를 안 건드린 채** 중간 노드만 갈아치우므로, 중간 노드가 `inst`에 직접 손을 댔다면 그 흔적을 지울 주체가 없어짐. @@ -1378,6 +1417,11 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 **[2026-08-14 아홉 번째 세션] `PostRef` 소진 자리도 동일** — `ProcessedPostRefHandler`(`base/ref-plan.md`의 "`PostRef`" 절)가 같은 두 등록을 하는 거울상 Handler라, 새 규칙 없이 그대로 맞물림. + **[2026-08-24 `H-35`] `Modifier` 소진 자리도 동일** — + `flatten`이 배열 자리를 `ProcessedModifier` 센티널로 소진하고 + `ProcessedModifierHandler`(`base/modifier-plan.md`)가 같은 두 등록을 한다. + 이 열거에서 통째로 빠져 있었다 — `Modifier`가 하나라도 든 리터럴은 **전부** + 이 핸들러를 거치므로, 이 문서만 보고 구현하면 그 존재 자체를 놓친다. **[정정, 2026-08-18 구현 전 QA] 값 자체가 `None`/`nil`인 자리도 이제 같은 원칙으로 덮인다** — `Dispatch.drive`가 `None`을 건너뛰지 않으므로 그 자리는 `NoneHandler`(재귀만) → `NilHandler`(말단)를 거치고, @@ -1385,6 +1429,33 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 `State`처럼 반응형 값이 뒤늦게 `None`을 내놓는 경로도 같은 자리로 수렴한다. + **⭐⭐ [2026-08-24 6라운드 손 트레이싱 `H-39`] 말단 핸들러 넷이 이 계약을 + 지키지 않고 있었다 — 전부 등록을 추가한다.** 위 원칙이 *"둘 다 array part의 + 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB"*이고 일반 `Ref`까지 + 명시적으로 지목해뒀는데, 전수 grep 결과 아래 넷은 `setLength`/`setOffsetSource`를 + **한 번도 부르지 않았다**: + - `TagHandler`(`base/tag-plan.md`) — 0건 + - `AttributeGroupHandler`(`base/attribute-plan.md`) — 0건 + - `RefLeafHandler`(`base/ref-plan.md`) — `Processed*` 둘에만 있고 이쪽엔 없음 + - `ObserverEffectLeafHandler`(`base/source-state-plan.md`) — 0건 + + `bk.N`은 **다른 위치가 등록될 때** `math.max`로 커지므로, 등록을 건너뛴 + 위치가 그 범위 안에 끼면 `recompute`가 그 자리에서 `sourceList[i] == nil`을 + 만나 **명시적 error로 죽는다**(2026-08-20 `C-6`으로 관대한 skip에서 error로 + 승격된 그 자리). `Frame { Tag("card"), TextLabel { … } }`처럼 말단이 앞에 + 오는 아주 흔한 배치가 첫 마운트에 죽고, 같은 게 + `Frame { Ref(myRef), Frame{} }` / `Frame { someObserver, Frame{} }` / + `Frame { Attribute(store), Slot() }`에서 그대로 재현된다. **해당 항목이 배열 + 맨 끝이면 `bk.N`이 거기까지 안 커져서 안 터지므로 "가끔 되고 가끔 터지는" + 형태로 드러난다.** + + 확정: **넷 다 `process` 맨 앞에서 `setOffsetSource(inst, k, None)` → + `setLength(inst, k, 0)`을 등록한다**(순서는 계약대로 offsetSource 먼저). + `AttributeGroupHandler`도 예외로 두지 않는다 — "그룹 핸들러는 다른 키로 + 위임하는 성격이라 층위가 다르지 않나"라는 갈래가 있었지만, Tag/Attribute를 + "Length/Offset에 참여하지 않는 별도 카테고리"로 재정의하면 `bk.N`의 의미가 + 바뀌어 파급이 크다(사용자 확정, 2026-08-24). + **해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` → `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** 별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가 @@ -1408,6 +1479,19 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 귀속) + 그 owner가 지금 등록해둔 position 개수 `N`(`bk.N`으로 같이 저장) — `Relate(parentInst)`에 lazy 생성. +**⭐ [2026-08-24 보강, 6라운드 손 트레이싱 `H-4`] 접두합 캐시 두 필드도 여기 +소속이고, 초기값을 명시한다.** `offsetCache`/`invalidAfter`가 이 열거에 +빠져 있어서 스펙상 존재하지 않는 필드였고, 초기값도 안 적혀 있었다. +`bk.N`이 `nil`로 시작하는 lazy 규칙(그래서 `recompute`가 `bk.N or 0`으로 +방어한다)을 그대로 따르면 `invalidAfter`도 `nil`인데, `getOffsetAt`은 +`if bk.invalidAfter == 0 then`으로 시작한다 — `nil == 0`은 거짓이라 다음 줄 +`at <= bk.invalidAfter`에서 **`attempt to compare number with nil`**로 죽는다. +**첫 position의 `setOffsetSource`가 바로 이 경로**라 fresh `bk`에서 반드시 밟는다. + +- **`getBookkeeping`이 `bk`를 만들 때 `offsetCache = {}`, `invalidAfter = 0`으로 + 초기화한다.** (`bk.N`만 `nil` 시작을 유지한다 — 그쪽은 `or 0` 방어가 이미 + 자리를 잡았고 "아직 아무 자리도 등록 안 됨"과 "0번까지 유효"가 다른 뜻이다.) + **[신설, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 — 두 owner 타입(물리 `inst`, Slot 자신) 모두 같은 규칙 하나로 통일.** 이전엔 `bk.N`을 "`Dispatch.drive`가 최초 배열 파트 순회 시점에 이미 아는, 저작 @@ -1458,7 +1542,9 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이 이전엔 여기도 `None`) 둘 다 실재하는 센티널로 채워 구멍을 피하는 것과 같은 원칙(`ref-plan.md`의 "Ref 일반화" 절 "왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서 -최종적으로 `nil`로 되돌아간 건 Ref 콜백/대기자 배열 한정**이고 +최종적으로 `nil`로 되돌아간 건 Ref 콜백/대기자 집합 한정**(**[2026-08-24]** +6라운드 `H-7`로 그건 배열이 아니라 **해시맵 셋**이 됐다 — 구멍 개념 자체가 +없어져 이 대비가 오히려 더 선명해졌다)이고 `sourceList`/`flattened`처럼 순서가 실제로 중요하거나 "채워짐 여부"를 엄밀히 구별해야 하는 배열은 여전히 실재하는 센티널이 맞음, 헷갈리지 말 것). 다만 `recompute`가 @@ -1598,6 +1684,28 @@ end | `spliceArraysUp`/`spliceArraysDown`(자리 삽입·삭제) | `i` | 같은 이유 — 삽입/삭제 후에도 `i` 자리의 offset은 여전히 `1..i-1`의 합이다 | | owner의 베이스 변경(`ownerKey.Offset`이 바뀜 = `_baseObserver`가 도는 순간) | `0` | 1번 자리부터 전부 다시 | +**⭐ [2026-08-24 6라운드 손 트레이싱 `H-3`] 이 표는 산문으로만 있었고 실제 +코드 경로가 하나도 없었다 — 배치할 자리를 명시한다.** 코퍼스 전체에서 +`bk.invalidAfter`에 대입하는 코드는 `getOffsetAt` 안의 `= 1`/`= at` 둘뿐이었고, +`setLength`도 `gatedRecompute`도 `_baseObserver` 콜백도 `spliceArrays*`도 +캐시를 당기지 않았다. 그래서 `Frame { SlotA(Length 2), SlotB(Length 3) }`에서 +`SlotA`가 3으로 커져도 `recompute`의 `getOffsetAt(Frame, 2)`가 **캐시된 2를 +그대로 반환**해 `SlotB.Offset`이 영원히 2에 고정된다(`sum`은 `lengthList`에서 +매번 새로 더하므로 **위로는 맞고 옆으로만 틀린다** — 알아채기 특히 어렵다). +재마운트는 더 나쁘다: `bk`는 `Relate(slot)` 위에 있어 언마운트를 넘어 살아남으므로 +`offsetCache[1]`에 **옛 베이스**가 남는다. + +세 자리 전부 **`recompute`보다 먼저** 당겨야 한다: + +1. **`Dispatch.setLength(ownerKey, i, ...)` 본문** — `lengthList[i]`를 쓴 직후, + `gatedRecompute()`를 부르기 전에 `bk.invalidAfter = math.min(bk.invalidAfter, i)`. + **그 자리 length가 State일 때 등록하는 Observer 콜백 안에서도 같은 줄**이 + 필요하다(나중 emit이 같은 무효화를 요구한다). +2. **`spliceArraysUp`/`spliceArraysDown`** — 삽입·삭제한 위치 `i`로 같은 당김 + (`base/slot-plan.md`의 그 함수들이 해야 하는 일 목록에 반영돼 있다). +3. **`slot._baseObserver` 콜백** — 베이스가 바뀐 경우라 `bk.invalidAfter = 0`. + `recompute`를 부르기 전에 당긴다. + **`recompute`도 이 캐시 위에 얹힌다** — `1..N`을 순서대로 도는 함수라 매 자리에서 `getOffsetAt`이 한 칸씩만 이어붙이므로 전체가 O(N)이고, 별도 접두합 로직을 따로 두지 않는다. (그래서 "`recompute`가 캐시를 채울지 말지"라는 갈래 자체가 없어졌다 — @@ -1605,13 +1713,16 @@ end ```lua local function recompute(ownerKey, bk) - -- [2026-08-21 G절] `0`이 아니라 이 owner의 베이스에서 시작한다. - -- 베이스는 별도로 저장하지 않는다 — Slot이면 자기 `.Offset`이 곧 그 값이고 - -- (부모가 먼저 설정해두므로 이미 정확하다), 최상위 물리 inst엔 베이스가 - -- 아예 없어 항상 0이다. 위 `getOffsetAt`과 같은 식. -- [2026-08-21] offset 값은 위 `getOffsetAt`(접두합 캐시)에서 받는다 — -- 여기서 따로 누적하지 않는다. 순서대로 도는 순회라 캐시가 한 칸씩만 늘어나 - -- 전체 O(N). + -- 전체 O(N). 베이스(이 owner의 시작 offset)를 더하는 것도 `getOffsetAt`의 + -- 몫이다 — Slot이면 자기 `.Offset`, 최상위 물리 inst면 0. + -- **[주석 정정, 2026-08-24 6라운드 손 트레이싱 `H-10`]** 여기 원래 + -- *"`0`이 아니라 이 owner의 베이스에서 시작한다"*고 적혀 있었는데, 접두합이 + -- `getOffsetAt`으로 빠진 뒤로 `sum`은 **`Length` 전용**이 됐고 `0`에서 + -- 시작하는 게 맞다(같은 함수 끝의 주석도 *"`Length`엔 base를 안 더한다"*로 + -- 이미 그렇게 말한다). 옛 주석을 믿고 구현하면 `Length`에 베이스가 더해져 + -- 상위 전체의 길이 합이 틀어진다. local sum = 0 -- [2026-08-21 5라운드 감사] `bk.N or 0` — **빈 Slot 크래시 방어**. -- `bk.N`은 `setLength`가 처음 불릴 때 생기므로(`bk.N = math.max(bk.N or 0, i)`), @@ -1693,8 +1804,12 @@ function Dispatch.setLength(ownerKey, i, len, anchor) bk.lengthList[i] = len bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고 + -- [2026-08-24 `H-3`] 접두합 캐시를 여기까지 당긴다 — `i` 자리의 offset은 + -- `1..i-1`의 합이라 안 바뀌고, 바뀌는 건 그 **뒤**뿐(위 무효화 표). + bk.invalidAfter = math.min(bk.invalidAfter, i) local function gatedRecompute() + bk.invalidAfter = math.min(bk.invalidAfter, i) -- 나중 emit도 같은 무효화가 필요 if not blocker:IsOn() then recompute(ownerKey, bk) end @@ -1711,6 +1826,23 @@ function Dispatch.setLength(ownerKey, i, len, anchor) end ``` +**⭐ [2026-08-24 6라운드 손 트레이싱 `H-19`] `recompute`를 부르는 책임은 여기 +하나다 — 호출부의 명시 `recompute`는 지운다.** `setLength`는 `len`이 상수여도 +마지막에 `gatedRecompute()`를 부르고, 런타임 단건 경로에선 그 owner의 Blocker가 +이미 꺼져 있으므로 그게 곧바로 `recompute`다. 그런데 `base/slot-plan.md`의 +`rawAdd`/`rawReplace` plain 분기는 **바로 다음 줄에서 또** `recompute(self, bk)`를 +불렀다. 크래시는 아니지만(두 번째 호출은 `offset:Get() ~= abs` 가드에 걸려 +대체로 아무것도 안 쓴다) O(N) 순회가 두 번 돌고, 더 중요하게는 **"`recompute`를 +누가 부르는가"의 소스가 두 곳**이 되어 위 `H-3`의 캐시 무효화를 어느 자리에 +둘지가 애매해진다. 확정: **자리의 길이가 바뀌면 `setLength`가 책임진다.** + +- **예외는 자리 자체가 없어지는 경로** — `rawRemove`/`rawUnmount`/`rawDetach`는 + `spliceArraysDown` 뒤에 `setLength`가 없으므로 거기선 명시 `recompute`가 + 남는다(그 경로의 캐시 당김은 `spliceArraysDown`이 한다). +- **배치 경로도 그대로** — `Dispatch.drive`/`materializeSlotTree`는 Blocker를 + 끈 뒤 마지막에 한 번 명시적으로 부른다(그 게이트가 존재하는 이유 자체가 + 등록마다 도는 걸 막는 것이다). + **⭐ [2026-08-21 구현 전 QA 5라운드 `C-4`] 부기 키와 생명주기 앵커는 별개다 — 4라운드 `D-56`의 결론을 되돌린다.** diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index f2b9c2e..4da607d 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -31,15 +31,20 @@ Effect(fn, ...deps) -> EffectHandle 이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출. 재실행 없음 (mount/unmount 전용, React `useEffect(fn, [])`와 동형). -**`state` 지정 시(2026-08-07 여섯 번째 세션 확정)**: Effect는 내부적으로 -`state:Observer(...)`를 감싸는 걸로 구현 — `fn`은 포지셔널 인자로 `state`를 -받고(`fn(state)`, `:Compute`의 `fn(self)` 포지셔널-self 패턴 재사용, -모듈화 목적 — 클로저 캡처 없이 `fn`을 독립적으로 정의/재사용 가능 -— **[2026-08-13 13차 세션 해소] 이 `fn(state)`가 lazy `State` 핸들을 -받는다는 전제는 확정 유지.** 한때 `question.md` 0-Y가 `Effect`를 영향 -대상으로 지목했으나, 실측 결과 **`Effect`는 애초에 무관**했음(자유 -함수라 문제의 조건인 "재귀 타입의 필드 + 로컬 제네릭"에 안 걸림) — -`base/typing-limits.md` 1번 "영향 범위" 표 참고), +**dep 지정 시(2026-08-07 여섯 번째 세션 확정, 2026-08-21 `C-6`으로 N-deps 확장)**: +Effect는 내부적으로 각 dep에 구독을 거는 걸로 구현 — State/Source면 +`state:Observer(...)`, `Ref`면 `:Callback`. +**⚠️ [표기 정정, 2026-08-24 6라운드 `H-14`] 여기 원래 *"`fn`은 포지셔널 인자로 +`state`를 받고(`fn(state)`)"*라고 적혀 있었는데 그건 폐기된 단수 시절 +표기다** — 확정 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고 +**deps는 `fn`에 안 넘어간다**(아래 "`Effect(fn, ...deps)`" 절이 소스, +dep 값은 사용자가 클로저로 직접 읽는다). +같이 붙어 있던 *"이 `fn(state)`가 lazy `State` 핸들을 받는다는 전제는 확정 +유지"*(2026-08-13 13차 세션)도 그 표기에 묶인 서술이라 함께 폐기된다 — +다만 그 항목이 실제로 확인한 것(**`Effect`는 재귀 제네릭 타입 누수와 +애초에 무관**하다, 자유 함수라 "재귀 타입의 필드 + 로컬 제네릭" 조건에 안 +걸림 — `base/typing-limits.md` 1번의 영향 범위 표)은 시그니처와 무관하게 +그대로 유효하다. Observer가 이제 등록 즉시 1회 실행되므로(아래 Observer 절 참고) 그 첫 실행이 "설치"를 겸함. 이후 `state`가 무효화될 때마다 **직전 `fn` 호출이 리턴한 cleanup을 먼저 호출한 뒤 `fn`을 재호출**, 그리고 Effect가 바인드된 @@ -59,8 +64,9 @@ leaf가 죽을 때 **마지막 cleanup을 한 번 더 호출**. 결과적으로 `source-state-plan.md`의 "`:Compute(fn, ...)`" 선례는 이제 **따르는 쪽**의 근거가 됐다(인자 모양을 그 관용구 그대로 맞춤). - **`fn`은 커링 스타일도 권장(2026-08-07 여섯 번째 세션, 사용자 제안)** — - `Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn(state)`를 - 만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링 관용구(`modifier-plan.md` + `Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn`을 + 만들어 반환하는 패턴(**[2026-08-24 표기 정정]** 그 `fn`은 `fn(state)`가 + 아니라 `fn(self)`다 — 위 배너), `Modifier`의 `Boldify(10)` 커링 관용구(`modifier-plan.md` 8번)와 같은 결. `state:Observer(fn)`도 동일하게 커링 스타일을 권장 대상으로 같이 문서화(아래 Observer 절 참고) — 모듈화가 필요하면 둘 다 이 패턴을 쓸 것. - **재실행이 필요 없는 케이스와 혼동하지 말 것**: 값 변화와 무관하게 설치+최종 @@ -72,6 +78,140 @@ leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백 leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다 비쌈) — 필요할 때만 쓰는 걸로 충분. +### ⭐⭐ 그 `Destroying` 바인딩을 **누가 거는가** (2026-08-24 확정, 6라운드 손 트레이싱 `H-11`) + +**갭이 실재했다 — cleanup을 발화시키는 코드가 코퍼스 어디에도 없었다.** +세 문서가 `Destroying`을 **전제로만** 쓰고 아무도 연결하지 않았다: +`base/lifecycle-pattern.md`는 *"인스턴스 라이프사이클 훅 지점은 `Destroying` +하나로 통일"*이라 못박고 `LP-2`가 **`Effect`가 그 훅을 쓰는 유일한 소비자**라 +확정했으며, 위 문단은 비용까지 적어뒀다. 그런데 leaf가 실제로 붙는 유일한 +경로는 `ObserverEffectLeafHandler.process`의 `bindLifetime(inst, v)` 한 줄이고, +`bindLifetime`의 실 구현 스케치는 `gchold[value] = true` + `BindData`에 +gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그걸 부를 +주체도 없었다.** 그래서 leaf가 죽으면 `canExecute`가 거짓이 되어 *"앞으로 +발화하지 마라"*는 성립하지만 **cleanup은 영원히 안 불린다.** + +**파급이 국소적이지 않았다** — 이 한 줄이 없으면 `slot._detachCleanup` +(`Detach`로 홀드된 요소를 파괴하는 **유일한** 경로, +`base/slot-plan.md`가 *"GC 폴백이 아예 없으므로 명시적 정리 경로가 필수"*라고 +적어둔 그것)과 `OnDestroyed`(`base/lifecycle-hooks-plan.md`, `Effect` 위의 순수 +슈가라 통째로 무동작)가 같이 죽는다. + +**확정(사용자, 2026-08-24)**: + +1. **`bindLifetime`/`unbindLifetime`이 `isEffect(value)`를 보고 직접 처리한다.** + **[2026-08-24 재결정, `/code-review high` 지적]** 여기 한때 *"`EffectHandle` + 쪽이 자기 `bindLifetime` 직후에 건다"*고 적었는데 **그 호출부가 실재하지 + 않는다** — 핸들은 남이 자기를 `bindLifetime`하는 걸 관측할 수 없다. 게다가 + `Effect`가 바인드되는 경로는 **둘**이고(`ObserverEffectLeafHandler.process`의 + children 배열 leaf, 그리고 `activateList`가 `_detachCleanup`을 직접 바인드하는 + 내부 경로 — `base/slot-plan.md`) **`Destroying`이 가장 절실한 쪽이 후자**라, + 핸들러 층에 분기를 둬도 안 덮인다. + **사용자 판단(2026-08-24)**: *"`Destroying` 자체가 엔진이 아는 요소이기 + 때문에, 엔진이 처리하는 곳에 두긴 해야합니다. 옵져버는 바로 생성되기 때문에, + bind 상 옵져버 목록을 가져와 자신이 재귀하고, `bindLifetime` 이 처리하는게 + 나아보입니다."* — `bindLifetime`은 이미 `handle._observers`로 cascade하며 + 값을 들여다보는 자리이므로, 그 옆에 `Destroying` 배선을 두는 게 새 층을 + 만드는 것보다 낫다. + - **한때 근거로 든 *"게이트는 값 타입을 안 가린다"*는 이 자리에 안 맞는 + 인용이었다** — `base/source-state-plan.md`의 그 절이 말하는 건 + **`canBound` 판정**이 `:Subscribe()`/`bindLifetime` 두 진입점에서 같다는 + 것이지, `bindLifetime`의 **부수 배선**이 값 종류를 못 본다는 게 아니다. + 실제로 그 함수는 이미 `Effect`면 내부 Observer로 cascade한다. +2. **`unbindLifetime`은 cleanup을 부르지 않는다.** `Destroying` 커넥션을 끊고 + **`Ref` 콜백도 같이 해제**하되(아래 `H-7` 절과 대칭), cleanup은 그대로 + 남긴다 — `destroySlotTree`가 `_detachCleanup`을 `unbindLifetime`하며 달아둔 + 주석(*"이미 손으로 비웠으니 Effect는 할 일 없음"*)과 `E-11`(leaf 바인딩엔 + `:Unsubscribe()`가 안 먹는다)이 그 전제 위에 서 있다. **이 계약을 명시한다** — + 지금까진 어느 쪽도 안 적혀 있었다. + - **bind/unbind가 대칭이라 포탈이 자연히 성립한다** — 언마운트가 콜백을 + 떼고 재마운트의 `bindLifetime`이 다시 건다(`_observers` cascade와 같은 결). +3. **cleanup은 `handle._cleanup` 필드에 보관한다.** `Rerun`이 이미 직전 + cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와 + `Rerun`이 같은 자리를 읽게 된다. + +**의사코드 — `bindLifetime`이 부르는 두 훅**(**[2026-08-24 신설]** 감사 3라운드가 +*"호출부만 있고 정의가 없다"*고 지적해 보강. 다른 신설 헬퍼는 전부 바디가 +있는데 이 둘만 산문뿐이었다): + +```lua +-- quad-base, Effect.luau — `base/lifecycle-pattern.md`의 bindLifetime에서만 불린다. +-- 비공개(`_` 접두사): 사용자 표면이 아니라 배관이다. +function EffectHandle:_bindDestroying(inst) + -- 재바인드(포탈 재마운트)면 옛 연결부터 끊는다 — 멱등. + self:_unbindDestroying() + + -- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점. + self._destroyConn = onDestroying(inst, function() + self:_unbindDestroying() -- 자기 연결/콜백을 먼저 정리(재진입 방지) + local cleanup = self._cleanup + self._cleanup = nil -- 두 번 안 불리게 + if cleanup then cleanup() end + end) + + -- (2) `Ref` dep 콜백 (재)등록 — State/Source dep의 `_observers` cascade와 대칭. + -- `unbind`가 뗐던 걸 여기서 다시 건다(그래서 포탈이 성립한다). + for _, ref in ipairs(self._refDeps) do + local cb = function(value) + -- ⭐ 발화 게이트 — State dep이 전파 루프에서 `canExecute(observer)`로 + -- 걸러지는 것과 같은 자리(아래 `H-7` 절). 해제가 늦거나 누락되는 + -- 창을 이게 덮는다. + if not canExecute(self) then return end + self:Rerun() + end + self._refCallbacks[ref] = cb -- 해제 때 정확히 이 클로저를 떼려면 보관해야 함 + ref:Callback(cb) + end +end + +function EffectHandle:_unbindDestroying() + if self._destroyConn then + self._destroyConn:Disconnect() + self._destroyConn = nil + end + for ref, cb in pairs(self._refCallbacks) do + ref:Uncallback(cb) -- `base/ref-plan.md`의 신설 표면(`H-7`) + self._refCallbacks[ref] = nil + end + -- **`_cleanup`은 안 부른다** — 위 2번 계약. 여기서 부르면 `destroySlotTree`가 + -- `_detachCleanup`을 손으로 비운 뒤 unbind하는 경로에서 이중 호출이 된다. +end +``` + +- **`onDestroying(inst, fn)`은 백엔드 주입 op이다** — base는 `Instance`를 + 모른다(`base/slot-plan.md`의 `native*` 절과 같은 이유). quad-roblox 구현은 + `inst.Destroying:Connect(fn)` 한 줄. **[2026-08-24 반영 완료]** 주입 op + 전체 목록의 단일 소스는 `base/architecture.md`의 `EngineOps.luau` 줄이고 + 거기에 등재했다(`ROADMAP.md` M5 배너에도 같이 적었다) — 한때 여기 + *"`ROADMAP.md` M5에 추가할 것"*이라고만 적어 **소스를 잘못 지목**했었다. +- **필드 셋이 새로 생긴다**: `_destroyConn`(연결 핸들), `_refDeps`(생성자가 + `...deps` 중 `isRef`인 것만 모아둔 배열), `_refCallbacks`(`ref → 내가 건 + 클로저`). 앞의 둘은 `_observers`와 같은 층이고, 마지막 것은 **해제 시 정확히 + 자기 클로저만 떼기 위해** 필요하다(`Ref.Callbacks`가 셋이라 값으로 떼야 한다). + +### ⭐ `Ref` 의존성의 해제 경로 (2026-08-24 확정, 6라운드 손 트레이싱 `H-7`) + +`Effect(fn, someRef)`의 leaf가 죽어도 **`ref.Callbacks`에 클로저가 영원히 +남았다** — `Ref`엔 콜백 해제 API가 없었고(`:Uncallback` 류 없음) +`canExecute` 게이팅도 안 걸린다(그건 Observer 쪽 배관이다). 그 클로저가 +`EffectHandle`을 강참조하므로 누수이고, 이후 `ref:Set`마다 **이미 죽은 leaf의 +직전 cleanup + `fn`을 계속 실행**한다. 같은 절이 *"leaf dedup/cascade가 전부를 +덮어야 한다"*고 요구하는데 `Ref` 쪽엔 그걸 만족시킬 수단 자체가 없었다. + +**확정: `Ref`에 콜백 해제 경로를 추가한다**(`base/ref-plan.md`가 소스). +`EffectHandle`은 자기가 건 `Ref` 콜백 핸들을 들고 있다가, `unbindLifetime`과 +`:Unsubscribe()`에서 같이 해제한다 — `_observers`(State/Source dep)와 대칭이다. + +**⭐ [2026-08-24 추가, 사용자 지적] 해제 경로만으로는 부족하다 — `Ref` 콜백도 +발화 시점에 `canExecute`를 확인한다.** State/Source dep은 State의 전파 루프가 +구독자마다 `canExecute(observer)`를 보고 죽은 것을 건너뛰는데(`base/source-state-plan.md`), +`Ref` 경로엔 그 게이트가 아예 없었다. 해제가 늦거나 누락되는 창이 실재한다 — +예컨대 `unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`이 +안 도는데도 `canExecute`가 거짓이다. **확정된 형태**: `Effect`가 거는 `Ref` +콜백은 본문 맨 앞에서 **자기 핸들에 대해** `canExecute(handle)`를 확인하고, +거짓이면 그대로 리턴한다. 그러면 두 dep 경로가 같은 게이트를 공유하게 되어 +`Effect`의 발화 조건이 dep 종류와 무관해진다. + ### 동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK` (2026-08-14 열한 번째 세션, `PreRef`/`Observer`와 같은 패턴, `base/ @@ -89,13 +229,22 @@ named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순 **보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째 세션, 재확인 후 명시화)**: -- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observer = - observer`(`state`가 주어진 경우만 존재). 이건 GC 방지가 목적이 아니라 +**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`] 이 문단 전체가 아직 "Observer 하나" +전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.** +아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로 +구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려** +그 Observer의 재실행이 통째로 죽는다 — 바로 이 문단 자신이 경고하는 실패 +모드다. 필드를 배열로 바꾸고 cascade/`Subscribe`/`Unsubscribe`를 전부 순회로 +고친다(새 결정 없음, 반영 누락). `Ref` dep은 Observer가 아니라 콜백이라 이 +배열에 안 들어간다 — 그쪽 해제는 아래 `H-7` 문단이 소스. + +- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observers[i] = + observer`(dep이 State/Source인 경우만 존재). 이건 GC 방지가 목적이 아니라 (그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime` cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것. - **`bindLifetime(inst, handle)`은 `state`가 있는 경우 내부 Observer도 - 같은 `inst`로 `bindLifetime(inst, handle._observer)`를 cascade해야 - 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해 + 같은 `inst`로 `handle._observers` **전부**에 대해 + `bindLifetime(inst, observer)`를 cascade해야 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해 `bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`가 `handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유: `canExecute(observer)`가 보는 gcconn 참조는 **그 Observer 자신이 @@ -110,8 +259,8 @@ named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순 무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는 결론은 그대로이고 오히려 근거가 더 직접적이 됨. - **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은 - 전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observer` - 둘 다, 또는 `handle._observer`만으로 충분한지는 구현 세부 — 어느 쪽이든 + 전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observers` + 전부, 또는 `handle._observers`만으로 충분한지는 구현 세부 — 어느 쪽이든 "`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면 안 됨). @@ -194,7 +343,7 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 dedup 되어야하는듯."* - **⭐ 단, 내부 Observer cascade도 그 분기 *안*에 있어야 한다** (5라운드 `EF-5`, 확인됨) — `EffectHandle`은 자기 자신뿐 아니라 - `handle._observer`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup + `handle._observers`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup 분기 **밖**에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다 (handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시 같은 `if` 안에 둘 것. @@ -249,9 +398,26 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect - **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** — State/Source면 `Observer`, `Ref`면 `:Callback`. `:With`로 합치지 않는다. -- **인자 모양은 `:Compute(fn, ...deps)`의 선례 그대로** — trailing deps를 - **lazy 위치 인자**로 콜백에 넘긴다(`base/source-state-plan.md`의 "trailing - deps를 `fn`에 lazy positional 인자로도 노출" 절). 새 규칙이 아니다. +- **⭐ [확정, 2026-08-24 6라운드 손 트레이싱 `H-14`] `fn`의 시그니처는 + `fn(self: EffectHandle) -> (() -> ())?` 이고, `...deps`는 의존성 선언일 뿐 + `fn`에 넘어가지 않는다.** + **사용자 확정**: *"`Effect( fn(self: Effect)->()->(), ...deps )` 가 맞는듯. + Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게 맞아보이고, + Compute 랑은 완전 다름. 난 그냥 `...deps` 넣는게 compute 처럼 그냥 넣을 수 + 있게 하자는거였을 뿐임."* + - **여기 원래 적혀 있던 *"인자 모양은 `:Compute(fn, ...deps)`의 선례 그대로 — + trailing deps를 lazy 위치 인자로 콜백에 넘긴다"*는 삭제한다.** 그 선례의 + 확정 시그니처는 `fn(self, previous?, ...deps)`인데 `Effect`엔 **셋 다 안 + 맞는다**: `self` 자리에 올 리시버 State가 없고(자유 함수), `previous`가 + 담길 캐시 슬롯이 없으며(파생값을 안 만드는 leaf), 오히려 **반환값이 + cleanup이라 의미가 정반대**다. 위쪽에 남아 있던 옛 단수 시절 표기 + `fn(state)`도 같이 폐기된다. + - **그 따름정리로 dep 읽는 법의 비대칭 문제가 사라진다** — State/Source dep은 + `:Get()`이고 `Ref` dep은 `.Value`(`:Get()`이 없다)라, 넘겨줬다면 사용자가 + 인자마다 다른 규칙을 위치로 기억해야 했다. 아무것도 안 넘기므로 그 질문 + 자체가 없어지고, dep 값은 사용자가 클로저로 직접 읽는다. + - `self`를 주는 덕에 `fn` 안에서 `self:Rerun()`/`self:Unsubscribe()` 같은 + 핸들 표면에 바로 닿는다. - **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`가 섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게 될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다. diff --git a/.claude/base/fallback-plan.md b/.claude/base/fallback-plan.md index dfe6bf4..3f93351 100644 --- a/.claude/base/fallback-plan.md +++ b/.claude/base/fallback-plan.md @@ -125,6 +125,61 @@ end `xpcall`+`debug.traceback`으로 **에러 나는 순간에만** 스택을 찍는 패턴)를 그대로 재사용 — 새 트레이싱 메커니즘을 발명하지 않음. +## ⚠️ 미해결 — 실패 이전에 생성된 부분 트리는 회수되지 않는다 (2026-08-24 신설, 6라운드 손 트레이싱 `H-26`) + +**상태: 백로그.** `Fallback`/`Traceback` 자체가 슈가라 **그 둘을 구현할 때 같이 +다룬다**(사용자 판단, 2026-08-24: *"의도적으로 error 를 사용하고자 하는 경우 +항상 컴포넌트들이 쌓이거든. 이건 후행에서 더 다뤄보도록 백로깅해줘. +fallback/traceback 자체가 슈거라서, 그 때 가서 생각해도 될듯"*). + +**무엇이 문제인가**: quad는 자기가 만든 Instance마다 생성 즉시 gcconn을 걸고 그 +클로저가 `inst`를 캡처하므로(`base/lifecycle-pattern.md`의 "(0)" 절) +**참조를 놓는 것만으로는 회수되지 않고 반드시 `Destroy`로만 회수된다.** 이 +모델은 "만든 Instance는 언젠가 반드시 `Destroy`된다"를 전제하는데, 컴포넌트가 +자기 리터럴을 만드는 도중 예외를 던지면 그때까지 완성된 형제/자손은 **트리에 +붙지도, `Destroy`되지도 않은 채** 예외에 실려 스코프를 빠져나간다. + +```lua +local function Broken(props) error("bug!") end +local function Parent(props) + return Frame { + Frame { Text = "child A" }, -- (1) 완주 — gcconn/gchold 확정 + Broken {}, -- (2) error + Frame { Text = "child C" }, -- (3) 도달 안 함 + } +end +local SafeParent = Fallback(Parent, function(err) return Frame { Text = tostring(err) } end) +``` + +Lua 테이블 생성자는 원소를 좌→우로 완전히 평가하므로 `child A`는 실제 +Instance로 완성되고 gcconn이 걸린다. 바깥 `Frame(...)` 호출은 인자 테이블조차 +완성 못 해 **호출되지 않는다.** `Fallback`은 `pcall(base, ...)` 하나라 그 +존재를 알 방법이 없고, `child A`는 (a) 어떤 지역 변수에도 안 남고 (b) 세팅된 +적 없고 (c) 아무도 모르므로 — 자기 gcconn↔gchold 순환만이 그를 살려두고 그걸 +끊는 유일한 수단(`Destroy`)을 부를 주체가 없다. **참조 0개인데 세션 끝까지 안 +죽는다.** 실패 지점 앞에 중첩 서브트리가 있으면 그 전체가 대상이다. + +**왜 `Fallback`/`Traceback`이 대표 사용처인가**: 이 문제는 이 둘 전용이 아니라 +"부분 실패 후 아무도 `Destroy`를 안 부르는 모든 경로"의 일반적 위험인데, +**그 경로를 계속 살려두는 것을 존재 이유로 삼는** 게 정확히 이 둘이다. +예외가 밖으로 나가 아무것도 안 그려지는 기본 경로는 서술 정정으로 끝났지만 +(같은 날 `base/dispatch-core-plan.md`에서 잔여 부기가 인스턴스 GC로 정리된다고 +적은 **틀린 안전망 주장**을 삭제했다), 이쪽은 앱이 계속 도는 걸 약속하는 +자리라 층위가 다르다. + +**구현 시 검토할 갈래**(지금 정하지 않음): +1. `New`/`Dispatch.drive`가 "이번 construction에서 만든 것" 목록을 쌓고 + `pcall` 실패 시 역순 `Destroy` — 누수를 실제로 닫지만 **정상 경로에도** + 부기 비용이 붙고, 중첩 구성 경계를 어떻게 잡을지가 또 문제다. +2. gcconn 자기순환 재고(한 번도 Parent 안 된 채 참조가 끊기면 GC 가능하게) — + `lifecycle-pattern.md` (0)의 핵심 트레이드오프를 건드리므로 재설계 규모가 크다. +3. UB로 명문화 + 컴포넌트 저작자에게 "생성 전에 검증부터" 권고. + +**참고 — 인접 사례는 이미 인정돼 있다**: `materializeSlotTree`의 예외 시 +Blocker가 켜진 채 남는 갭은 사용자 판단으로 이미 *"마운트 도중 예외는 quad가 +복구를 보장하지 않는 상태"*로 정리됐다(`base/slot-plan.md`). 다만 그건 +마운트 경로에 한정된 국소적 결과다. + ## 패키지 배치 `quad-base` — `base`를 그냥 호출하고 결과를 그대로 돌려주는 순수 함수라 diff --git a/.claude/base/gate-plan.md b/.claude/base/gate-plan.md index 332bc91..832c4b3 100644 --- a/.claude/base/gate-plan.md +++ b/.claude/base/gate-plan.md @@ -173,9 +173,15 @@ end) 순간 떼어내는 게 원래 모양이고, 그러면 그 경로 자체가 없다: ``` local batch = self._withheld - self._withheld = {} -- 새 테이블. clear가 아니다 - emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다 + self._withheld = newWithheld() -- 새 테이블. clear가 아니다 + emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다 ``` + **⚠️ [정정, 2026-08-24 6라운드 손 트레이싱 `H-9`] 그 새 테이블도 weak여야 + 한다.** 여기 원래 `self._withheld = {}`라고 적혀 있었는데, 그러면 위에서 + **weak key로 확정한 집합이 첫 flush 스왑에서 평범한 테이블로 바뀐다** — + 그 뒤로는 그 게이트가 죽은 `Epoch`를 붙잡을 수 있다. `OffWithoutEmit`의 + 스왑도 같다. `newWithheld()`(= `setmetatable({}, {__mode = "k"})`) 헬퍼 + 하나로 생성 지점을 통일한다. 하류가 순회하는 것은 `gate._withheld`가 아니라 **받은 `batch`** 다. 중첩 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다. 같은 리비전이 중첩으로 두 번 도달하는 경우는 애초에 문제가 아니다 — 하류 맵이 이미 @@ -226,8 +232,51 @@ end) 5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 — 지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적, - `debounce-throttle-plan.md` 5-4). `Gate` 자체에 `Flush`/`Cancel` 같은 표면을 - 둘지, 그건 정책(Debounce)만의 것으로 둘지. + `debounce-throttle-plan.md` 5-4). + + **⭐ [2026-08-24 해소, 6라운드 손 트레이싱 `H-33`/`H-49`] `Gate` 노드엔 + `Flush`/`Cancel` 같은 표면을 두지 않는다 — 정책이 `Blocker`를 조종한다.** + 확정된 `state:Gate(setup)`는 **State 하나만** 돌려주고 노드 객체를 노출하지 + 않으므로, 제어 표면을 얹으려면 그 확정을 되돌려야 했다. 사용자 확정으로 + 방향이 반대로 잡혔다: + + - **`Blocker`가 자기 정책을 값으로 낸다 — `blocker:Policy(emit) -> onUpstreamEmit`.** + `state:Block(b)`는 그 위의 얇은 래퍼가 된다. 노출되는 새 표면은 이 하나뿐이다. + **⚠️ [2026-08-24 표기 정정, `/code-review high` 지적]** 여기 한때 + `state:Gate(b:Policy)`라고 적었는데 **그건 문법 오류다**(인자 목록 없는 + `b:Policy`). `b.Policy`로 고쳐도 **언바운드 메소드**라 `Gate`가 + `setup(emit)`으로 부르면 `emit`이 `self` 자리에 들어가 정책의 `emit`이 + `nil`이 된다. 정확한 형태는 클로저로 묶는 것이다: + ```lua + state:Block(b) == state:Gate(function(emit) return b:Policy(emit) end) + ``` + - **`Debounce`/`Throttle`은 `emit`을 아예 안 쥔다.** 자기 `Blocker`를 + 사적으로 하나 갖고 **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류 + 판정은 전부 Blocker에 위임한다. 동기 실행이라 같은 호출 안에서 정책이 + 바꾼 Blocker 상태를 그 다음 줄의 `pass()`가 그대로 본다: + ```lua + state:Gate(function(emit) + local b = Blocker() + local pass = b:Policy(emit) -- 실제 emit/보류는 전부 여기 위임 + return function() -- 상류 emit 도착 (동기) + -- ...타이머 리셋 / b:On() / b:Off() 시점 판단만... + pass() + end + end) + ``` + - **Blocker는 설정당 하나가 아니라 적용 핸들당 하나**다(사용자 지적) — + `Debounce{...}` 커링 결과는 여러 곳에 적용될 수 있으므로 `Apply` 시점에 + 생성된다. + - **`pending` 같은 정책 상태는 `HasBlockedEmit`으로 흡수한다** — "보류된 게 + 있는가"를 Blocker가 이미 들고 있으므로 중복 상태를 안 만든다. + `Trailing = false`는 `OffWithoutEmit()`, `Flush`는 `Off()`로 매핑된다. + - **정책 합성은 손으로 중첩한다** — `setup`이 곧 `(emit) -> onUpstreamEmit` + 이라 그 자체가 합성 가능한 타입이다. `state:Gate(p1, p2, ...)` 같은 + 가변인자 슈가는 **두지 않는다**(누가 상류인지가 코드에 그대로 드러나는 + 편이 낫다). + - **기각된 배선(기록)**: "블로커를 바깥에 중첩" + (`blocker:Policy(debounceOnEmit)`)은 unblock 시 흘러나온 emit이 디바운스 + 창을 **새로 시작**시켜 창이 안 끝난다(사용자 지적). 6. **[2026-08-21 정리 — 열린 항목 아님] 재진입.** 여기 한때 *"`onUpstreamEmit` 안에서 같은 게이트의 `emit()`을 재귀적으로 부르는 경우"*라고 적혀 있었는데 **잘못 옮긴 서술이었다**(사용자 지적). `blocker-plan.md`의 "재진입(네스팅)" diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 52969a8..cc8832d 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -327,9 +327,42 @@ function bindLifetime(inst, value) -- 둘 다 weak — gchold는 위 (0) 클로저가, gcconn은 gchold[1]이 이미 안전히 붙잡고 있음. BindData:SetWeak(value, "gchold", gchold) BindData:SetWeak(value, "gcconn", InstData:GetWeak(inst, "gcconn")) + + -- **⭐ [신설, 2026-08-24 6라운드 손 트레이싱 `H-11`] `Effect`면 부수 배선까지 + -- 여기서 한다.** `LP-2`가 *"`Effect`가 `Destroying` 훅을 쓰는 유일한 + -- 소비자"*라고 확정해뒀는데 **그걸 실제로 거는 코드가 코퍼스 어디에도 + -- 없었다** — 그래서 leaf가 죽어도 cleanup이 영영 안 불렸고, + -- `slot._detachCleanup`(Detach 요소를 파괴하는 유일 경로)과 `OnDestroyed`가 + -- 통째로 무동작이었다(`base/effect-plan.md`). + -- **왜 이 자리인가**(사용자 판단 2026-08-24): `Destroying`은 엔진이 아는 + -- 요소라 엔진을 다루는 자리에 있어야 하고, 이 함수는 이미 `Effect`의 내부 + -- Observer 목록으로 cascade하며 값을 들여다본다. `Effect`가 바인드되는 + -- 경로가 둘(children 배열 leaf / `activateList`의 `_detachCleanup` 직접 + -- 바인드)이라 **호출부 쪽에 두면 반드시 한쪽이 샌다.** + if isEffect(value) then + for _, observer in ipairs(value._observers) do -- N-deps cascade + bindLifetime(inst, observer) + end + value:_bindDestroying(inst) -- Destroying 연결 + Ref dep 콜백 (재)등록 + -- 의사코드는 `base/effect-plan.md`가 소스. + -- 그 안에서 주입 op `onDestroying(inst, fn)`을 + -- 부른다(base는 Instance를 모른다). + end end function unbindLifetime(value) + -- [2026-08-24 `H-11`] bind의 대칭 — **cleanup은 부르지 않는다.** + -- `Destroying` 연결을 끊고 `Ref` dep 콜백을 떼기만 한다. cleanup을 여기서 + -- 부르면 `destroySlotTree`가 `_detachCleanup`을 손으로 비운 뒤 unbind하는 + -- 경로에서 이중 호출이 된다(`base/effect-plan.md`의 그 항목이 소스). + -- 이 대칭 덕에 포탈이 자연히 성립한다 — 언마운트가 떼고 재마운트가 다시 건다. + if isEffect(value) then + value:_unbindDestroying() + for _, observer in ipairs(value._observers) do + unbindLifetime(observer) + end + end + local gchold = BindData:GetWeak(value, "gchold") if gchold then gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 493e304..933e49f 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -98,6 +98,30 @@ end - **배열 파트를 훑으며 해시 키를 같이 쓰는 것은 안전** — 숫자 `for`의 상한이 루프 진입 시 한 번만 평가되고, 해시 파트 추가는 그 순회에 영향을 주지 않는다. +- **⭐ [신설, 2026-08-24 6라운드 손 트레이싱 `H-35`] `ProcessedModifierHandler` + 의사코드** — 확정은 위 한 줄(*"전담 nop Handler … 캐치해 + `setOffsetSource(None)`/`setLength(0)`을 등록"*)로 끝나 있었는데, 지목된 + 본보기(`ProcessedPreRefHandler`/`ProcessedPostRefHandler`)엔 실제 골격이 + 있는 반면 이쪽엔 없었다. 게다가 **색인 두 곳에서도 빠져 있었다** — + `base/dispatch-core-plan.md`의 Length/Offset 등록 책임 열거와 말단 핸들러 + 부작용 표, 그리고 `base/architecture.md`의 `Dispatch/` 파일트리 + (`Modifier.luau` 항목이 flatten/체이닝만 적고 자기가 만드는 센티널의 + 핸들러를 언급 안 함). `Modifier`가 하나라도 든 `Frame{...}` 호출은 **전부** + 이 핸들러를 거치므로, 이 문서를 안 읽고 색인만 보고 구현하면 존재 자체를 + 놓친다. 셋 다 반영했다. + + ```lua + ProcessedModifierHandler.priority = <매우 높음, ProcessedPreRefHandler와 동급> + ProcessedModifierHandler.isHandlable(inst, k, v) = (v == ProcessedModifier) + + function ProcessedModifierHandler.process(inst, k, v, index) + -- 순서는 늘 offsetSource 먼저(setLength가 끝에서 recompute를 태우므로) + Dispatch.setOffsetSource(inst, k, None) + Dispatch.setLength(inst, k, 0, inst) + return function() end -- no-op retract — flatten이 이미 끝나 되돌릴 상태가 없음 + end + ``` + - **`ProcessedModifier`의 공개 표면 위치**는 `Detach`/`None`과 같은 판단을 따른다 — 다만 이건 사용자가 볼 일이 전혀 없는 **내부 센티널**이라 최상위로 재노출할 이유가 없어 보인다(`ProcessedPreRef`/`ProcessedPostRef`가 diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 0e66db0..5719859 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -251,8 +251,12 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로 - **기본이 `false`인 이유**: 라이브러리가 사용자 콘솔에 아무것도 안 찍는 게 기본이어야 함. 켜는 건 명시적 opt-in. -- **다중 인스턴스화 시 인스턴스별인지 전역인지는 미정** — 위 "모듈 스코핑" - 절과 같이 정할 것. +- **[해소, 2026-08-24 6라운드 손 트레이싱 `H-48`] 다중 인스턴스화 시 + **인스턴스별**이다 — M1이 이미 그렇게 확정했다.** 여기 "미정"으로 남아 + 있었지만, 커밋된 `quad-base/src/Debug/init.luau`가 `module.debug = false`로 + **인스턴스 필드**를 심고 `quad-types/src/init.luau`의 `Quad` 타입도 + `debug: boolean`을 필드로 갖는다. 결함은 아니지만 다음 세션이 "아직 정할 + 게 남았다"고 읽고 전역으로 바꾸려 들 수 있어 여기서 닫는다. - **[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`/`ML-9`] `Dispatch.listHandlers()`는 이 플래그와 무관하게 항상 호출 가능하다** — 순수 조회(목록 **반환**만 하고 스스로 출력하지 않음)라 게이팅 대상이 아니다. 출력할지는 호출부가 정한다. diff --git a/.claude/base/onchange-plan.md b/.claude/base/onchange-plan.md index 7cb3ce5..2c62bf5 100644 --- a/.claude/base/onchange-plan.md +++ b/.claude/base/onchange-plan.md @@ -53,10 +53,26 @@ `GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에 둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와 다름. -- **`process(inst,k,v,index)`**: `inst:GetPropertyChangedSignal(name):Connect( +- **`process(inst,k,v,index)`**: **먼저 `v == nil`이면 Connect를 건너뛰고 + no-op 클로저를 반환**하고, 아니면 `inst:GetPropertyChangedSignal(name):Connect( function() v(inst[name]) end)` 후 **그 Connection을 `:Disconnect()`하는 클로저를 반환**. 일반 `Handlers/Event.luau`와 같은 결(Connection - 관리뿐, 새 메커니즘 없음). **[정정, 2026-08-13 다섯 번째 세션]** 원래는 + 관리뿐, 새 메커니즘 없음). + - **⭐ [정정, 2026-08-24 6라운드 손 트레이싱 `H-27`] 그 `v == nil` 얼리리턴이 + 빠져 있었다.** 이 문서는 스스로 *"일반 `Handlers/Event.luau`와 같은 결"*이라 + 결론냈는데, `base/event-plan.md`가 확정한 그 "같은 결"의 핵심이 정확히 + **`(k=이벤트키, v=nil)`을 받으면 기존 Connection 해제만 하고 새로 Connect하지 + 않는다**이다. 없으면 `Frame { [OnChange "Position"] = someState }`에서 + `someState:Set(None)`으로 콜백을 끌 때, `NoneHandler` → `NilHandler`를 + 거쳐 이 핸들러가 **`v == nil`로 다시 매치**되어(매치가 키 기반이라 `nil`도 + 잡고, `NilHandler`는 `type(k) == "number"` 전용이라 여기 안 걸린다) + `Connect(function() nil(inst.Position) end)`가 **실제로 심긴다.** 그 순간엔 + 아무 일도 안 일어나고, 나중에 `inst.Position`이 실제로 바뀌면 + **`attempt to call a nil value`**로 터진다 — 즉 "콜백을 끈다"는 동작이 + 실제로는 **"나중에 터질 Connection을 새로 심는"** 동작이 된다. + `base/dispatch-core-plan.md`는 같은 종류의 방어를 `PropertyHandler`에는 + 이미 명시적으로 요구하고 있다(*"`v == nil`이면 셋을 건너뛰는 방어"*) — + `OnChange`만 빠져 있었다. **[정정, 2026-08-13 다섯 번째 세션]** 원래는 별도 `retract(inst,k,v)` 필드가 Disconnect를 담당한다고 적혀 있었으나, Handler 계약이 `process` 1-메소드로 합쳐지며 그 로직이 반환 클로저로 이동 — `connection`은 `process`의 로컬 변수를 클로저가 upvalue로 그대로 diff --git a/.claude/base/quad-types-plan.md b/.claude/base/quad-types-plan.md index 12c2bc1..99814b9 100644 --- a/.claude/base/quad-types-plan.md +++ b/.claude/base/quad-types-plan.md @@ -80,6 +80,41 @@ export type Quad = { } ``` +**⭐⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25` — 실측] 이 레코드는 **닫혀 +있고**, 마일스톤마다 서브시스템 필드를 여기 추가해야 한다.** + +실제 커밋된 `quad-base/src/init.luau`의 `New(): Quad`가 이 별칭을 그대로 반환 +타입으로 쓰는데, `RunInit`은 `(self: Quad, initFn: (Quad) -> any) -> ()`로 +**반환값이 없어 타입을 못 넓히고**, 타입을 넓히는 유일한 경로인 +`AddPlugin`는 이 문서 자신이 **외부 플러그인**용이라고 선을 긋는다. +그런데 `base/architecture.md`의 확정된 결정 13번은 *"`require(quad)`가 돌려준 +걸 바로 `Quad.Dispatch`처럼 씀"*을 **표준 사용법으로 확정**해뒀다. + +실측(`InitDebug`와 완전히 같은 모양의 `InitDispatch`로 재현): +```lua +local quad = New() +quad.Dispatch.addHandler(function() end) +``` +`luau-analyze` → `TypeError: Key 'Dispatch' not found in table 'Quad'`. +즉 M2가 `Dispatch.luau`를 만들고 `module:RunInit(InitDispatch)` 패턴(이 문서가 +이미 예시로 보여준 그 패턴)으로 붙이면 **런타임엔 붙지만 타입엔 영원히 안 +보인다.** `ROADMAP.md` M5의 quad-roblox 주입 경로도 같은 벽에 부딪힌다 — +quad-roblox는 `quad-types`의 좁은 `Quad`만 본다. + +**확정(사용자, 2026-08-24): `quad-types`의 `Quad`를 마일스톤마다 갱신한다.** +- **M2**가 `Dispatch: Dispatch` 필드와 그 타입 재수출을 여기 추가한다 — + `ROADMAP.md` M2 체크리스트에 **항목으로 명시**한다(지금까지 아무도 이 + 필요성을 항목화해두지 않았다). +- 이후 서브시스템도 같은 규칙을 따른다. +- **"가벼운 타입 계약"이라는 이 패키지의 존재 이유와 상충하지 않는다** — + 타입만 재수출하므로 런타임 무게는 안 는다. +- 검토했다 기각된 둘: **quad-base 내부만 넓은 로컬 교차 타입** + (`New(): Quad & { Dispatch: Dispatch }`) — quad-roblox도 결국 + `Dispatch.addHandler`를 부르므로 그 경로엔 별도 노출이 또 필요해진다. + **`(quad :: any).Dispatch` 캐스트** — 가장 싸지만 + `base/typing-limits.md`가 시종 강조하는 명시 바인딩 원칙과 정면으로 + 배치되고 quad-base 자기 코드가 `--!strict`의 이득을 잃는다. + `Version`은 리터럴(singleton) 타입 — `string`이 아니라 정확히 `"0.0.0"`. 이 리터럴이 아래 `CheckVersion`의 판정 근거이자, 그 자체로도 평범한 구조적 타이핑만으로 이미 어느 정도 버전 불일치를 잡아준다(다른 리터럴 diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 670f371..e6e4359 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -110,6 +110,33 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 — 아래 구현 디테일 참고, **[재정정, 2026-08-09 열한 번째 세션] `None`이 아니라 `nil`이 맞음**, 바로 아래 캐비엇 참고). + - **⭐⭐ [재설계, 2026-08-24 6라운드 손 트레이싱 `H-7` 후속] `.Callbacks`는 + 배열이 아니라 `{[callback|thread] = true}` **해시맵 셋**이다.** + `Effect(fn, someRef)`의 leaf가 죽어도 콜백을 뗄 방법이 없다는 갭(`H-7`)을 + **해제 경로 추가**로 닫기로 하면서 자료구조도 같이 바뀌었다 — 배열이면 + 삭제가 선형 탐색이다. **사용자 제안**(2026-08-24): *"Effect 의 callback + 들은 해시맵이 되는게 나아보이는데 `[callback/루틴]=true`"*. + - **해제가 `t[fn] = nil` 하나로 끝난다** — 이게 `H-7`이 요구하는 연산이다. + - **`type(v) == "thread"` 분기는 그대로 성립한다** — 값이 아니라 **키**의 + 타입을 보면 된다(`for k in pairs(self.Callbacks)`). + - **중복 등록은 dedup을 계약화한다**(사용자 확정) — 같은 콜백/thread를 + 여러 번 등록해도 한 번만 발화한다. 해제가 `nil` 하나로 끝나야 하므로 + "몇 번 해제해야 떨어지나"라는 질문이 생기면 안 된다. 사용자가 실수로 + 중복 등록하는 상황도 이 쪽이 안전하다. + - **⭐ 순회 전에 스냅샷을 뜬다** — `pairs` 순회 중 **새 키 추가는 Lua에서 + 미정의**다(기존 키를 `nil`로 지우는 소진은 합법). 같은 문제가 State + 구독자 집합에서 **실측으로 재현**됐고(`base/source-state-plan.md`의 + `H-23` 문단 — 실행마다 결과가 달랐고 한 구독자가 통째로 누락되기도 함), + **둘 다 같은 처방으로 닫는다**: 배열로 복사한 뒤 그 배열을 돈다. + "이번 발화 중에 등록된 콜백은 다음 발화부터 참여한다"가 계약이다. + - **⭐ 이 변경으로 `#t` border 실측 항목이 폐기된다** — 아래 + `R-11` 항목의 ⚠️ 실측 대상("구멍 있는 테이블에서 `#t`가 항상 첫 `nil` + 자리인가"를 M0/M8 스파이크로 확인)은 **해시맵엔 border 개념이 없어서 + 질문 자체가 사라진다.** 같은 이유로 아래 "`table.insert`가 구멍을 + 되찾아 쓴다"(`R-11`)와 "왜 `None`이 아니라 `nil`인가" 논의도 이 자료구조 + 아래에서는 **무의미해진다**(둘 다 배열 전제) — 결론(소진은 `nil`)은 + `t[k] = nil`로 그대로 이어지지만 근거는 "구멍/border가 아예 없다"로 + 바뀐다. 두 항목은 역사 기록으로만 읽을 것. - **[재설계, 2026-08-18 구현 전 QA] 콜백/대기자는 별도 필드 `.Callbacks` 테이블에 담고, `.Value`는 그냥 평범한 hash 필드로 둔다.** 옛 설계(2026-08-09 열한 번째 세션 보강)는 **Ref 객체 자신이 곧 @@ -123,6 +150,28 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 되므로 **hash 파트 충돌 자체가 안 생기고, `__index` 우회 기법을 쓸 이유가 사라진다.** 대가는 Ref 하나당 테이블 하나가 더 만들어지는 것뿐 (`.Callbacks`를 첫 등록 시점에 lazy로 만들지는 구현 재량). + - **⭐ [신설, 2026-08-24 6라운드 손 트레이싱 `H-7`] 콜백 해제 경로 — + `:Callback(fn)`이 해제 핸들을 돌려준다.** 지금까지 `Ref`엔 등록만 있고 + 해제가 없어서, `Effect(fn, someRef)`의 leaf가 죽어도 클로저가 영원히 + 남았다(그 클로저가 `EffectHandle`을 강참조해 누수이고, 이후 `ref:Set`마다 + **이미 죽은 leaf의 `fn`을 계속 실행**했다). + **[2026-08-24 보강, 사용자 지적] 해제 경로와 `canExecute` 게이팅은 택일이 + 아니라 둘 다 필요하다.** 여기 한때 *"`canExecute` 게이팅으로 덮으려던 + 대안은 채택하지 않았다"*고 적었는데, 해제만으로는 창이 남는다 — + `unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`이 안 + 도는데도 `canExecute`가 거짓이다. **해제는 누수를, 게이팅은 발화를** 막는다. + `Ref` 자신은 여전히 `canExecute`를 모르고(그건 Observer 쪽 배관이다) + `Effect`가 거는 **콜백 본문**이 자기 핸들에 대해 확인한다 — + `base/effect-plan.md`가 소스. + - `:Callback(fn)`은 **여전히 `self`를 반환한다**(체이닝 관용구 + `Ref(default):Callback(fn)`이 코퍼스 전반에 쓰인다) — 해제 핸들은 + **`:Uncallback(fn)`**으로 뗀다. 셋이라 `Callbacks[fn] = nil` 한 줄이고, + 중복 등록이 dedup되므로(위) "몇 번 떼야 하나"가 없다. + - `EffectHandle`은 자기가 건 콜백을 들고 있다가 `unbindLifetime`과 + `:Unsubscribe()`에서 `:Uncallback`한다 — State/Source dep의 + `_observers`와 대칭(`base/effect-plan.md`). + - **`:Wait()`의 대기자는 이 표면의 대상이 아니다** — 발화 시 소진되므로 + 뗄 일이 없다. - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 @@ -135,7 +184,41 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 지점에서 정지시킨) thread 하나를 Ref에 등록해두고, 등록한 코드 자신은 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`가 `nil`이면 yield, 있으면 yield 안 함. - - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume + - **⭐ [신설, 2026-08-24 6라운드 손 트레이싱 `H-53`] `:Set(value)`의 순서 — + `.Value`를 **콜백 순회 전에** 쓴다.** `.Value`를 *읽는* 관용구는 코퍼스에 + 여러 번 나오는데 `:Set`이 그걸 언제 쓰는지 보여주는 코드가 없었다 + (`\.Value = ` grep 0건). 콜백은 값을 인자로 직접 받으므로 실무 영향은 + 낮지만, **콜백 A가 `.Value`를 읽는데 콜백 B가 아직 안 돈 시점에 무엇이 + 보이는가**가 사양에 없었다. 확정된 순서: + ```lua + function Ref:Set(value) + self.Value = value -- (1) 먼저 확정 + local snapshot = {} -- (2) [`H-23`] 순회 전 스냅샷 + for k in pairs(self.Callbacks) do table.insert(snapshot, k) end + for _, k in ipairs(snapshot) do + if self.Callbacks[k] == nil then continue end -- 순회 중 해제됐으면 skip + if type(k) == "thread" then + self.Callbacks[k] = nil -- 대기자는 소진 + coroutine.resume(k, self) -- 값이 아니라 **Ref 자신**(아래 참고) + else + k(value) -- 일반 콜백은 원래 값을 받고, 소진 안 함 + end + end + return self + end + ``` + `.Value`가 먼저이므로 **모든 콜백이 새 값을 본다** — 순서에 따라 옛 값이 + 보이는 창이 없다. + - **⚠️ [역사 기록, 2026-08-24 6라운드 `H-7` 후속] 아래 "구현 디테일" 문단은 + `.Callbacks`가 **배열**이던 시절 서술이다 — 지금 자료구조는 해시맵 셋이고 + 확정 의사코드는 위 `:Set` 블록이 소스.** 배열 전제인 부분(`for i, v in + self.Callbacks`, `[i] = nil` 소진, `table.insert`의 빈 슬롯 재사용, + 구멍 있는 테이블의 `#t` border)은 전부 **무효**다. 다만 그 문단이 확정한 + **의미론 셋은 그대로 유효**하고 지금 의사코드도 그걸 지킨다: + (1) 대기자와 콜백을 같은 자료구조에 담고 `type(v) == "thread"`로 분기, + (2) 대기자는 `coroutine.resume(v, self)`로 **Ref 자기 자신**을 넘기며 소진, + (3) 일반 콜백은 **원래 값**을 받고 소진 안 함. + - **(역사) 구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로 `:Set()`될 때, 같은 배열 하나를 `for i, v in self.Callbacks do ... end`로 한 번만 순회하면서(**[2026-08-18]** 순회 대상은 Ref 객체 자신이 아니라 @@ -289,6 +372,14 @@ RefLeafHandler.isHandlable(inst, k, v) = -- HANDLER_PRIORITY_FALLBACK 가드(아래 "동적 경로 가드")가 죽은 코드가 된다. function RefLeafHandler.process(inst, k, v, index) + -- [2026-08-24 6라운드 `H-39`] **말단 핸들러의 배열 자리 부기** — 빠져 있었다. + -- 일반 `Ref`가 등록 대상이라는 건 `base/dispatch-core-plan.md`가 명시적으로 + -- 못박아뒀는데(값이 없는 자리도 짝을 맞춰 `0`), 정작 이 핸들러엔 없었다 — + -- `Frame { Ref(myRef), Frame{} }`처럼 leaf가 앞에 오면 첫 recompute가 + -- `sourceList[k]가 nil`로 죽는다. 아래 `Processed*` 둘과 같은 모양. + Dispatch.setOffsetSource(inst, k, None) + Dispatch.setLength(inst, k, 0, inst) + local old = relate:GetStrong(inst, k) if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip bindLifetime(inst, v) -- v가 이미 다른 자리에 살아있으면 여기서 즉시 error — @@ -409,6 +500,33 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 부작용으로 동기적으로 발화**할 수 있음 — 이때 이벤트 핸들러가 아직 안 채워진 self-ref를 읽으면 터짐. +**⚠️ [조건 명시, 2026-08-24 6라운드 손 트레이싱 `H-42`] 그 "동기 발화"는 +`Workspace.SignalBehavior`에 조건부다.** 코퍼스 90개+ 문서 어디에도 이 설정이 +한 번도 등장하지 않은 채 무조건 사실처럼 서술돼 있었다. + +- `Enum.SignalBehavior`는 `Default`/`Immediate`/`Deferred`. **`Immediate`**에선 + 이벤트가 다른 이벤트를 트리거하면 두 번째 핸들러가 **즉시** 발화한다(중첩 + 실행) — 위 서술이 성립하는 경우다. **`Deferred`**에선 큐 뒤에 붙어 **다음 + resumption point**(입력 처리, `RunService` 콜백, `task` 계열)에서 실행되므로 + 스크립트의 동기 실행 흐름을 끊지 않는다 — **그러면 이 레이스는 발생할 수 + 없고**(quad의 동기 마운트가 전부 끝난 뒤 콜백이 도므로 일반 `Ref`도 이미 + 채워져 있다) `PreRef`는 무해하게 불필요해질 뿐이다. +- 공식 문서 기준 **신규 템플릿 place는 이미 `Deferred`가 기본**이고, 레거시 + `Default`는 현재 `Immediate`와 같지만 앞으로 `Deferred`로 바뀔 예정이다. + 출처: https://create.roblox.com/docs/scripting/events/deferred , + https://create.roblox.com/docs/reference/engine/classes/Workspace#SignalBehavior +- **왜 문구만 고치고 구조는 안 건드리나**: 크래시를 만드는 문제가 아니다. + 진짜 이득은 **나중에 실측할 때** 나온다 — 언젠가 Studio에서 "이 레이스가 + 재현이 안 된다"는 결과가 나오면, 그게 quad 설계가 틀린 건지 place의 + `SignalBehavior` 때문인지 구분할 수 있어야 한다. 실측 계획을 세울 때 place + 설정을 같이 적게 만드는 것이 목적이다. +- **`PreRef`/`PostRef`/pre-pass 구조 자체는 유지한다**(사용자 확정, + 2026-08-24) — 레거시 place와 명시적 `Immediate` 설정이 살아있는 한 필요하고, + 불필요해질 때의 비용이 싸다. +- 참고: `base/dispatch-core-plan.md`의 *"프레임 경계는 여전히 안 낀다(yield + 금지)"*는 **quad 자신의 코드가 yield 안 한다**는 얘기라 이 축과 다르다 — + 반박이 아니다. + **해결**: 이 케이스만 별도 타입 `PreRef`로 분리. - **구현은 `Ref` 그대로 재사용**(같은 `.Value`/`:Set()`/`:Callback()`/ `:Wait()` API) — 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음. @@ -541,12 +659,18 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store ```lua ProcessedPreRefHandler.priority = <매우 높음, NoneHandler와 동급> ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef) - function ProcessedPreRefHandler.process(inst, i, v) + -- [시그니처 정정, 2026-08-24 `H-20`] 계약은 `process(inst, k, v, index)` + -- 4-인자다. 이 핸들러는 재위임을 안 해서 `index`를 실제로 안 쓰지만 + -- 표기는 계약대로 둔다(`NilHandler`/`NoneHandler`는 4-인자로 적혀 있어 + -- 지금까지 표기가 갈려 있었다). 2번째 인자를 `i`가 아니라 `k`로 부르는 + -- 것도 같은 이유 — 배열 위치(`k`)와 재귀 깊이(`index`)를 혼동한 전례가 + -- 실제로 있다(`base/dispatch-core-plan.md`의 Handler 작성 체크리스트 6번). + function ProcessedPreRefHandler.process(inst, k, v, index) -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저 — setLength가 -- 끝에서 gatedRecompute를 경유해 recompute를 돌리므로 -- (`base/dispatch-core-plan.md`의 해제 순서 계약) - Dispatch.setOffsetSource(inst, i, None) - Dispatch.setLength(inst, i, 0) + Dispatch.setOffsetSource(inst, k, None) + Dispatch.setLength(inst, k, 0, inst) return function() end -- no-op retract, 이 자리는 fire가 끝나 -- 되돌릴 상태 자체가 없음 end @@ -819,10 +943,11 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store ```lua ProcessedPostRefHandler.priority = <매우 높음, ProcessedPreRefHandler와 동급> ProcessedPostRefHandler.isHandlable(inst, k, v) = (v == ProcessedPostRef) -function ProcessedPostRefHandler.process(inst, i, v) +-- [시그니처 정정, 2026-08-24 `H-20`] 위 ProcessedPreRefHandler와 같은 이유로 4-인자 +function ProcessedPostRefHandler.process(inst, k, v, index) -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저(위 ProcessedPreRefHandler와 동일 이유) - Dispatch.setOffsetSource(inst, i, None) - Dispatch.setLength(inst, i, 0) + Dispatch.setOffsetSource(inst, k, None) + Dispatch.setLength(inst, k, 0, inst) return function() end -- no-op retract, PreRef와 같은 이유(되돌릴 상태가 없음) end ``` diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 7979d06..7dcb0c4 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -68,8 +68,16 @@ nativeRemove (target, offset, elements, newElements?) -- 빼면서 ** nativeMove (target, fromOffset, elements, toOffset) -- 범위 이동(사이가 밀림) nativeSwap (target, offsetA, elementsA, offsetB, elementsB) -- 두 구간 맞교환(사이 고정) nativeDispose(element) -- 트리 **밖** 값 파괴 +isInst (value): boolean -- [2026-08-24 신설] 이 값이 이 백엔드의 + -- 마운트 가능한 요소(`T`)인가 ``` +**⭐ [2026-08-24 신설] `isInst`는 다른 `native*`와 성격이 다르다 — 조작이 아니라 +판정이고, 미주입이 에러다.** 요소 타입 검증을 블랙리스트에서 화이트리스트로 +뒤집으면서 생겼다(아래 "요소 타입 제약" 절, 6라운드 손 트레이싱 `H-40`). +base는 여전히 `T`가 뭔지 모른다 — **아는 건 백엔드고 base는 주입된 술어만 +부른다.** quad-roblox의 구현은 `typeof(value) == "Instance"` 한 줄이다. + - **`offset`은 전부 0-based 절대 offset**(`Dispatch.getOffsetAt`이 주는 그 값). Roblox 백엔드는 이 인자를 그냥 무시한다 — `LayoutOrder`가 물리 순서와 분리돼 있으므로. @@ -96,6 +104,18 @@ nativeDispose(element) -- 트리 **밖 `nativeRemove` = `nativeExtract` + `nativeDispose` 반복, `nativeMove` = `nativeExtract` + `nativeInsert`, `nativeSwap` = `nativeMove` 2회. 백엔드는 **이득 있는 것만 덮어쓴다.** + - **⚠️ [2026-08-24 보강, 6라운드 손 트레이싱 `H-34`] 단 Roblox 백엔드는 + `nativeMove`/`nativeSwap`을 반드시 덮어써야 한다 — 여기선 조합 폴백이 + "느린 정답"이 아니라 관측 가능한 동작 차이를 만든다.** `Move`/`Swap`이 + 공개 CRUD에 추가된 근거 자체가 *"`Extract`+`Add`는 실제 Parent 조작이 + 두 번(detach+reattach) 일어남"*을 피하려는 것이었는데(아래 "원시 최소화 + 원칙 정정" 절), 조합 폴백은 정확히 그 두 번을 되돌려 `AncestryChanged` + 재발화·깜빡임·재바인딩 비용을 다시 만든다. **offset을 무시하는 백엔드에서 + 순서 이동은 애초에 물리 조작이 아니므로 no-op으로 덮어쓰면 된다.** + quad 자신의 배관은 `Destroying` 하나만 보므로 안 깨진다 — 영향은 + 사용자 코드/렌더 쪽이다. + - `isInst`는 이 조합 폴백의 예외다(위 문단) — 판정이라 조합으로 만들 수 + 없고, 미주입이면 명확한 에러여야 한다. - **⚠️ 전제 — 한 Slot의 물리 자식은 부모 안에서 연속 구간을 차지한다.** 범위 op이 성립하는 근거가 전부 이것이다(offset이 누적합이고 중첩 Slot도 같은 `physicalTarget`을 공유하므로 구조적으로 참). quad 밖에서 그 부모에 자식을 끼워 @@ -133,6 +153,32 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 " 허용해야 할 이유가 없어짐. `element == nil`뿐 아니라 `element == None`도 `Add`(및 내부 `raw*`)에서 즉시 `error` — "Slot 안엔 실제로 마운트 가능한 값만 들어간다"는 단일 규칙으로 단순화. +- **⭐ [전면 정정, 2026-08-24 6라운드 손 트레이싱 `H-40`] 판정은 블랙리스트가 + 아니라 화이트리스트다 — `isSlot` → `isState` → `isInst`.** 아래 "핸들러 계층 + 값 … 금지" 항목은 **열거된 것만** 막는 블랙리스트였고, 그래서 목록에 없는 + 값 타입이 전부 샜다. 실제로 `Tween`이 그대로 통과해 `_elements`에 들어가고 + (물리적으로는 아무것도 안 붙는데 `Length`만 유령 +1), 나중 파괴 경로의 + `nativeDispose(t)`가 `Destroy` 메소드 없는 테이블에서 죽는다. 확정된 판정 + 순서는: + 1. `isSlot(v)` → 중첩 Slot으로 구조적 처리(아래 "Slot-in-Slot 중첩" 절). + 2. `isState(v)` → 래퍼 Slot(`:Single`)으로 풀어 재귀(아래 "반응형 raw 요소" 절). + **그 안쪽 값이 다시 이 판정을 받는다** — State가 나중에 이상한 값으로 + 바뀌어도 같은 자리에서 걸린다. + 3. 그 외 → **`isInst(v)`가 거짓이면 즉시 `error`.** + **관문은 `wrapElement` 하나로 둔다** — 공개 CRUD와 `:List`의 `settle`이 둘 다 + 여길 지나므로 검증 지점이 갈리지 않는다. + **base는 여전히 `T`를 모른다** — `isInst`는 백엔드가 주입하는 술어이고 base는 + 그걸 부르기만 한다(위 `native*` 절). 그래서 화이트리스트인데도 "base는 `T`가 + 뭔지 모른다"는 이 문서의 일관된 입장과 안 부딪힌다. + **왜 Brand로 안 하나**(사용자 판정, 2026-08-24): *"이제 brand 는 각각 따로 + 생성되어서 있는지 없는지 보는건 결국 전부 봐야한다는 의미"* — `Brand`가 + 2026-08-21 재작성으로 인스턴스 브랜드가 된 뒤로 "아무 quad 브랜드나 붙었나"를 + 물으려면 모든 브랜드를 순회해야 한다. + 아래 두 항목(핸들러 계층 값 금지, `nil`/`None` 금지)은 **여전히 유효하지만 + 이제 이 화이트리스트의 따름정리**다 — 그 값들은 셋 중 어디에도 해당하지 않으므로 + 3번에서 걸린다. 다만 **에러 메시지는 계속 구분해서 낸다**(핸들러 계층 값이 + 왜 안 되는지는 아래 근거가 있고, 그걸 "`isInst`가 아님"으로 뭉뚱그리면 + 진단이 나빠진다). - **핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Modifier) 금지, 즉시 `error`** — `Modifier` 필드가 이 값들을 담으면 즉시 `error`로 확정했던 것(`modifier-plan.md` 7번)과 같은 판별 메커니즘(`isRef`/`isPreRef`/`isPostRef`/ @@ -211,6 +257,19 @@ Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 같은 자리에서 `self._mountedInst = inst`도 같이 저장 — `:List()`가 마운트 이후에 호출되는 경우 이 값으로 즉시 활성화(아래 "`Slot:List(...)`"의 "구독 시점" 절 참고). + - **⭐ [2026-08-24 좁힘, 6라운드 손 트레이싱 `H-2`] `_mounted`/`_mountedInst`는 + 이제 "물리 인스턴스가 있는가"만 뜻한다.** `attachSlot`이 5라운드 `F-3`으로 + `materializeSlotTree`(부기 확정) + `mountSlotTree`(물리 대입)로 쪼개진 뒤, + 이 둘은 **뒤쪽에서만** 세팅된다. 그래서 상태가 셋이다: + **미실체화 / 실체화(부기는 있고 물리는 없음) / 마운트**. + - **`slot._physicalTarget` 신설** — `materializeSlotTree` 머리에서 저장한다. + `Dispatch.setLength`의 앵커(그리고 length가 State일 때 `bk.observers`의 + 앵커)가 **실체화 시점에 이미 필요**한데 `_mountedInst`는 그때 아직 `nil`이기 + 때문이다. `raw*`는 앵커가 필요하면 `_physicalTarget`을, `native*`를 부를지 + 말지는 `_mounted`를 본다(아래 `rawAdd`/`rawRemove` 계열). + - 언마운트는 `_mounted`/`_mountedInst`를 지우고 `_physicalTarget`도 같이 + 지운다(재마운트 시 새 target으로 다시 채워진다 — 옛 값을 들고 있으면 죽은 + inst를 강하게 붙잡는다, 아래 `unmountSlotTree`). - **개별 element**: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등) 마다 전역 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서). **[구체화, @@ -788,7 +847,7 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. ` 금지"만 있었고, 반대로 "수동 CRUD를 이미 썼으면 나중에 `:List` 설치 금지"는 없었음 — 이 상태로는 `Slot():Add(x); ...; slot:List(...)` 같은 코드가 조용히 통과해서, `:List`의 reconcile이 `x`의 존재를 전혀 - 모른 채(자기 `mounted`/`keyIndex`가 비어있는 상태로 시작) 새 요소를 + 모른 채(자기 `mounted`/`prevKeys`가 비어있는 상태로 시작) 새 요소를 추가하려다 `x`와 충돌(Length 이중 계산, index 꼬임 등)하는 gap이 있었음. 모든 mutate CRUD(`Slot(initial)`이 호출하는 `:Add` 포함)가 `self._crudUsed = true`를 세팅하고, `:List`/`:Single`(내부적으로 @@ -810,7 +869,9 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. ` `Slot():Add(a):Add(b):Add(c)`와 같은 일을 하는 표기일 뿐이라서: ```lua function Slot(initial) - local self = setmetatable({...}, Slot_mt) + -- [2026-08-24 `H-1`] `_elements`와 짝인 역방향 맵 `_elemIndex`도 여기서 + -- 같이 만든다(둘은 항상 함께 산다 — 위 raw* 인자 규약). + local self = setmetatable({ _elements = {}, _elemIndex = {}, ... }, Slot_mt) if initial ~= nil then self._crudUsed = true -- 빈 테이블이어도 즉시 잠금(아래 참고) for _, v in ipairs(initial) do -- ipairs가 첫 nil에서 멈춤 @@ -877,7 +938,7 @@ Slot():List(data, updateFn, keyFn?) -> Slot -- self 일어나는 목록엔 진짜 `keyFn`을 넘기라고 안내하는 정도로 충분. **`key`의 타입 제약 — 없음, 사이클 간 안정성+유일성만 있으면 됨 -(2026-08-11 세션 명시화).** `key`는 그냥 `mounted`/`userdata`/`keyIndex` +(2026-08-11 세션 명시화).** `key`는 그냥 `mounted`/`userdata`/`prevKeys` 맵의 Lua 테이블 키로 쓰일 뿐이라 string/number/테이블 레퍼런스 등 뭐든 가능 — 유일한 조건은 (1) 같은 논리적 item이면 사이클이 바뀌어도 항상 같은 key(안정성), (2) 서로 다른 item이면 항상 다른 key(유일성). `keyFn`이 @@ -896,7 +957,7 @@ slot:List(data, updateFn, function(item) return item.id end) (2026-08-11 세션)** — `reconcile`이 어차피 `seen[key]`를 채우고 있으므로 그 직전에 `if seen[key] then error(...) end` 확인 하나만 추가하면 거의 공짜(아래 "구현" 절 코드 참고). 조용히 넘어가면 두 item이 `mounted`/ -`userdata`/`keyIndex`의 같은 슬롯을 다투게 돼 한쪽 item이 사라지거나 +`userdata`/`prevKeys`의 같은 슬롯을 다투게 돼 한쪽 item이 사라지거나 뒤섞이는 조용한 버그가 되므로, 다른 Slot CRUD 에러 조건들과 같은 fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `item.id` 관용구를 안 쓰고 실수로 안 유일한 필드를 쓴 경우)에게 즉시 신호를 줌. @@ -1084,8 +1145,8 @@ end 어디 남아있든 상관없음(아무도 참조 안 하면 그냥 GC됨). `index`가 `updateFn` 호출 시점에 이미 "**이 item이 이번 사이클에 살아남으면 -차지할** 압축 위치"로 정확히 계산돼서 넘어오므로(아래 "구현" 절의 -`candidateIndex` 참고, 직전까지의 생존자 수만으로 계산 가능해 이 item +차지할** 물리 위치"로 정확히 계산돼서 넘어오므로(아래 "구현" 절의 +`reconcile` 참고, 직전까지의 생존분만으로 계산 가능해 이 item 자신의 생존 여부와 무관), `updateFn`이 값을 늦게 알아서 임시값→정정 과정을 거칠 필요가 없음 — `nil` 반환(필터 탈락)이면 이 `index` 값은 그냥 버려지고 다음 생존자가 같은 값을 받음. @@ -1139,7 +1200,7 @@ item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하 `userdata`를 살려뒀다면 그대로 이어짐(위 "반환값 두 개는 서로 독립" 참고). **sort는 이 재설계와 무관, 기존 메커니즘으로 이미 커버됨** — 호출부가 -`data`의 순서를 바꾸면 `keyIndex[key] ~= i` 감지 → `Move`가 그대로 +`data`의 순서를 바꾸면 "지금 인덱스 ~= 이번 자리" 감지 → `Move`가 그대로 처리, 새 메커니즘 필요 없음(사용자가 filter와 같이 물었던 것 중 sort는 원래도 문제가 없었음). @@ -1212,6 +1273,11 @@ GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에 ```lua function Slot:List(data, updateFn, keyFn, opts) assert(not self._listed, "Slot already has :List installed") + -- [2026-08-24 6라운드 손 트레이싱 `H-37`] **역방향 가드** — 산문(위 "CRUD API + -- 확정" 절)이 확정해둔 대칭 가드가 의사코드에 안 옮겨져 있었다. 이게 없으면 + -- `Slot():Add(x); slot:List(...)`가 조용히 통과해, reconcile이 `x`의 존재를 + -- 모른 채(빈 `mounted`/`prevKeys`로) 시작한다. + assert(not self._crudUsed, "Slot: cannot install :List after manual CRUD was used") self._listed = true self._listData = data self._updateFn = updateFn @@ -1222,8 +1288,19 @@ function Slot:List(data, updateFn, keyFn, opts) -- 기본이 true이므로 **false일 때만** 필드를 세운다(nil = owned). if opts and opts.Owned == false then self._owned = false end - if self._mounted then - activateList(self, self._mountedInst) -- 이미 마운트돼 있으면 즉시 활성화 + -- [2026-08-24 `H-2`] 판정 기준을 `_mounted`가 아니라 **`_physicalTarget`**으로 + -- 넓힌다 — 실체화만 된 상태(부기는 서 있고 물리는 아직)에서 `:List`가 + -- 설치되는 경우도 지금 활성화해야 `materializeSlotTree`의 자식 루프 분기와 + -- 어긋나지 않는다. 물리 op은 `rawAdd`가 `_mounted`로 알아서 가른다. + if self._physicalTarget then + -- 초기 population도 `materializeSlotTree`와 같은 이유로 게이팅한다 — + -- 안 그러면 아이템마다 `recompute`가 돌아 O(n²)(그 절의 `blocker:On()` 문단). + local blocker = getBlocker(self) + blocker:On() + activateList(self, self._physicalTarget) + blocker:OffWithoutEmit() + local bk = getBookkeeping(self) + if bk then recompute(self, bk) end end return self end @@ -1239,11 +1316,11 @@ function activateList(self, physicalTarget) -- `Detach`로 뗐다 돌아온 자식 Slot이 `:List`를 갖고 있으면 -- `materializeSlotTree`가 `slot._listed`를 보고 여기를 다시 부른다. -- 가드가 없으면 `data:Observer(fn)` 구독이 하나 더 생기고 - -- `mounted`/`userdata`/`keyIndex` 클로저 상태가 **통째로 새로 만들어져** + -- `mounted`/`userdata`/`prevKeys` 클로저 상태가 **통째로 새로 만들어져** -- 옛 상태를 잃은 채 reconcile이 두 벌 돈다(기존 요소를 전부 새 것으로 -- 오인해 다시 그림). `_crudUsed`/`_listed`와 같은 결의 플래그. if self._listActivated then - -- 재마운트(포탈 포함) — 클로저 상태(mounted/userdata/keyIndex)는 + -- 재마운트(포탈 포함) — 클로저 상태(mounted/userdata/prevKeys)는 -- 그대로 두고 **GC 앵커만 새 target으로 옮긴다.** 이걸 안 하면 -- 자원이 옛 physicalTarget에 매달린 채로 남아, 그게 죽는 순간 -- 살아있는 이 Slot의 `:List`가 조용히 반응을 멈추고(`_listObserver`) @@ -1263,11 +1340,21 @@ function activateList(self, physicalTarget) local keyFn, updateFn = self._keyFn, self._updateFn local offset = self.Offset - local mounted, userdata, keyIndex = {}, {}, {} + -- **⭐ [2026-08-24 6라운드 손 트레이싱 `H-1`] `keyIndex`가 인덱스 맵에서 + -- 단순 키 집합으로 내려앉았다** — 이름도 `prevKeys`로 바꾼다. + -- 옛 설계는 `keyIndex[key]`를 `_elements` 인덱스로 쓰고 `raw*`에 그대로 + -- 넘겼는데, 그 값은 **사이클 도중엔 stale**이다(`rawAdd`/`rawRemove`가 + -- 배열을 시프트하는데 교체는 사이클 끝에 한 번뿐). 이제 인덱스는 + -- `indexOfRaw(self, element)`(= `slot._elemIndex` 조회)로 그때그때 구하고, + -- 여기 남는 건 **"직전 사이클에 존재했던 키"**라는 집합 용도뿐이다. + local mounted, userdata, prevKeys = {}, {}, {} -- [2026-08-21] 한 키의 처분을 실제로 수행하는 공통 로직 — 정상 사이클과 -- 소멸 루프가 같은 걸 쓴다(분기가 두 군데로 갈리면 반드시 어긋남). - local function settle(key, result, detach, pos) + -- [2026-08-24 `H-2`] 4번째 인자는 **`_elements` 슬롯 위치**(`slotPos`)다 — + -- 옛 이름 `pos`는 리프 카운터와 배열 인덱스를 한 변수에 겹쳐 쓰고 있었다 + -- (아래 `reconcile` 참고). + local function settle(key, result, detach, slotPos) local wasMounted = mounted[key] -- [2026-08-21 5라운드 `DE-7`] `_detached`는 **lazy** — 없을 수 있다. -- 읽기는 항상 nil 체크, 쓰기는 `getDetached(self)`(getOrCreate)로. @@ -1280,7 +1367,7 @@ function activateList(self, physicalTarget) -- **prev가 아예 없어도(마운트도 detach도 아님) nop** — 사용자가 "지금 -- prev가 있는지"를 추적할 의무가 없다. 이미 detach 상태여도 nop. if wasMounted ~= nil then - local idx = keyIndex[key] -- 이 키가 지금 차지한 _elements 인덱스 + local idx = indexOfRaw(self, wasMounted) -- [`H-1`] 지금 이 순간의 실제 인덱스 if self._owned == false then -- [2026-08-21 5라운드 `DE-13`] **내 것이 아니면 붙잡지 않는다** — -- 언마운트 + 소유권 반납으로 끝내고 `_detached`에 넣지 않는다. @@ -1295,7 +1382,11 @@ function activateList(self, physicalTarget) end elseif result == nil then -- "지워라". Owned=false면 파괴 대신 언마운트만(아래 "Owned" 절). - if prev ~= nil then releaseElement(self, keyIndex[key], prev, wasDetached ~= nil) end + if prev ~= nil then + -- [`H-1`] detach 중이던 요소는 `_elements` 밖이라 인덱스가 없다(nil) + local idx = if wasDetached ~= nil then nil else indexOfRaw(self, wasMounted) + releaseElement(self, idx, prev, wasDetached ~= nil) + end mounted[key] = nil if detached then detached[key] = nil end elseif result == unwrapElement(prev) then @@ -1306,10 +1397,13 @@ function activateList(self, physicalTarget) detached[key] = nil -- **재마운트** — detach된 걸 되살림 -- 4번째 인자 = fromDetached. 소유권을 놓은 적이 없으므로 -- `claimOwner`가 재클레임을 허용해야 한다(위 그 함수 주석). - rawAdd(self, prev, pos, true) -- 물리 요소(=래퍼)를 그대로 되돌림 + rawAdd(self, prev, slotPos, true) -- 물리 요소(=래퍼)를 그대로 되돌림 mounted[key] = prev - elseif keyIndex[key] ~= pos then - rawMove(self, keyIndex[key], pos) -- 그대로 쓰되 위치만 이동 + else + local idx = indexOfRaw(self, wasMounted) -- [`H-1`] + if idx ~= slotPos then + rawMove(self, idx, slotPos) -- 그대로 쓰되 위치만 이동 + end end else -- 교체. @@ -1322,34 +1416,73 @@ function activateList(self, physicalTarget) -- detach 중이던 건 이미 `_elements` 밖이라 "자리 교체"가 성립 안 함 releaseElement(self, nil, prev, true) -- index 없음(트리 밖) detached[key] = nil - rawAdd(self, element, pos) + rawAdd(self, element, slotPos) elseif wasMounted ~= nil then - rawReplace(self, keyIndex[key], element, self._owned ~= false) + -- [`H-1`, 2026-08-24 `/code-review high` 지적으로 보강] + -- `rawReplace`는 **자리를 유지한 채 내용만** 바꾼다. 그래서 같은 + -- 사이클에 순서까지 바뀌었으면(값 교체 + 리오더가 겹치는 경우) + -- 교체 후 자리를 맞춰줘야 한다 — 안 그러면 그 요소만 옛 배열 + -- 자리에 남아, 이웃들의 `rawAdd`/`rawMove`가 `updateFn`에 알려준 + -- 순서와 다른 배열을 상대로 계산하게 된다. + local idx = indexOfRaw(self, wasMounted) + rawReplace(self, idx, element, self._owned ~= false) + if idx ~= slotPos then rawMove(self, idx, slotPos) end else - rawAdd(self, element, pos) + rawAdd(self, element, slotPos) end mounted[key] = element -- **물리 요소**를 기억한다(래퍼일 수 있음) end end + -- **⭐ [2026-08-24 6라운드 손 트레이싱 `H-2`/`H-31`/`H-38`로 재작성]** + -- 셋을 같이 고쳤다: + -- (`H-2`) **두 좌표계를 분리했다.** 옛 `pos` 하나가 *리프 개수*(중첩 Slot은 + -- `.Length`만큼 전진)이면서 동시에 *`_elements` 배열 인덱스*로 쓰였다. + -- 이제 배열 자리는 `slotPos`(생존 아이템마다 +1)이고, `updateFn`에 넘기는 + -- 물리 위치는 **`Dispatch.getOffsetAt`에서 구한다**(아래 참고). + -- (`H-31`) **중복 키 검사를 선행 패스로 뺐다.** 옛 코드는 메인 루프 안에서 + -- item마다 검사해서, N번째에서 중복이 발견될 때 1..N-1의 `settle`이 이미 + -- 물리/부기를 커밋한 뒤였다. 중복 키는 사용자 데이터에서 가장 흔한 실수라 + -- 도달 빈도가 다른 패닉 경로와 다르다. `keyFn`을 한 번 더 도는 O(n)이 + -- 붙지만 `:List`가 이미 O(n)이라 상수배다. + -- (`H-38`) **키 집합을 증분 갱신한다.** 마지막 일괄 교체를 없앴다 — + -- `updateFn`이 중간에 던지면 앞선 `settle`은 커밋됐는데 집합만 옛것으로 + -- 남아, 그 사이클에 새로 생긴 키를 다음 사이클 소멸 루프가 못 물어 + -- 영구 고아가 된다. 계약: **reconcile은 원자적이지 않지만 중단된 + -- 지점까지는 정합하다.** local function reconcile(items) - local newKeyIndex, seen = {}, {} - local pos = 0 -- 압축된(실제 마운트된) 위치 카운터, raw 루프 인덱스 i와 다름 - + -- 선행 패스 1 — 키 수집 + 중복 검사. 여기선 아무것도 mutate하지 않는다. + local keys, seen = table.create(#items), {} for i, item in ipairs(items) do local key = keyFn(item, i) -- keyFn은 raw i를 받음(:List 파라미터 설명 참고) if seen[key] then error("Slot:List — duplicate key: " .. tostring(key)) end seen[key] = true + keys[i] = key + end + + local slotPos = 0 -- `_elements` 자리 카운터 — 생존 아이템마다 정확히 +1 + + for i, item in ipairs(items) do + local key = keys[i] -- [2026-08-21] prev는 "이 키의 요소" — 마운트돼 있든 detach돼 있든. -- 그래서 detach된 걸 그대로 반환하면 재마운트가 된다(settle 참고). -- [2026-08-21 5라운드 `C-3`] `updateFn`에는 **래핑 전 값**을 준다 -- (물리 요소가 래퍼 Slot이어도 사용자는 자기가 준 State를 다시 본다). local prev = unwrapElement(mounted[key] or (self._detached and self._detached[key])) - local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능) - local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key]) + local candidateSlot = slotPos + 1 -- "이 item이 살아남으면 차지할" 배열 자리 + -- [2026-08-24 `H-2`] `updateFn`의 `index`는 **물리 위치**다 — 부기에서 + -- 그때그때 뽑는다. 실체화가 순차로 일어나므로 이 시점엔 1..candidateSlot-1의 + -- 길이가 이미 전부 확정돼 있다(`rawAdd`가 마운트 전에도 부기를 하도록 + -- 바뀐 것이 이걸 성립시킨다). 옛 코드는 `result.Length:Get()`을 직접 + -- 더했는데, **새로 만들어진 중첩 Slot의 `.Length`는 그 시점 항상 0**이라 + -- 아무것도 반영하지 못했다. + -- `offset`은 이 Slot의 base **Source**(위 `local offset = self.Offset`)라 + -- 숫자를 쓰려면 `:Get()`. 1-based 상대 위치로 준다(옛 `candidateIndex`와 같은 기준). + local physIndex = Dispatch.getOffsetAt(self, candidateSlot) - offset:Get() + 1 + local result, ud = updateFn(item, physIndex, offset, prev, userdata[key]) if result == None then result = nil end -- 편의: None도 nil과 동일 취급 -- [2026-08-18, 이름 2026-08-19 확정] Detach는 "이 자리를 비우되 죽이지는 말라"는 지시. -- 아래 "Detach" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다. @@ -1357,22 +1490,25 @@ function activateList(self, physicalTarget) if detach then result = nil end if result ~= nil then - -- [2026-08-11 일곱 번째 세션] result가 nested Slot이면 그 - -- .Length만큼 건너뛴다 — 다음 형제의 index가 이 아이템이 - -- 실제로 차지하는 물리적 개수를 반영해야 함(아래 "index도 - -- nested-Slot 결과의 Length만큼 건너뛰어야 함" 절 참고) - pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1) + slotPos = candidateSlot -- 배열 자리는 요소 하나당 하나(중첩 Slot도 한 칸) end - settle(key, result, detach, pos) - - userdata[key] = ud -- result와 무관, 그대로 기록 - newKeyIndex[key] = pos + -- [`H-38`, 2026-08-24 `/code-review high` 지적으로 순서 정정] + -- **`settle` *앞*에서 기록한다.** 뒤에 두면 `settle`이 물리/부기를 + -- 이미 커밋한 뒤 던질 때(`rawAdd` 안의 `claimOwner` 거부 등) 그 키가 + -- 집합에 안 들어가, 다음 사이클 소멸 루프가 못 물어 영구 고아가 + -- 된다 — `H-38`이 고치려던 바로 그 모양이다. 선행 패스가 `seen`을 + -- 미리 채우는 것과 같은 이유. + prevKeys[key] = true + settle(key, result, detach, slotPos) + userdata[key] = ud -- result와 무관, 그대로 기록 end -- [재설계, 2026-08-21] 소멸 루프 — 조용히 파괴하지 않고 **처분을 묻는다**. -- 아래 "`KeyGone`" 절이 소스. - for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key + -- [`H-38`] 스냅샷을 뜬 뒤 돈다 — 루프 안의 `settle`이 `prevKeys`를 + -- 건드리므로(아래 `prevKeys[key] = nil`) 순회 중 원본을 바꾸면 안 된다. + for key in pairs(table.clone(prevKeys)) do -- 직전 사이클에 존재했던 전체 key if not seen[key] then local prev = unwrapElement(mounted[key] or (self._detached and self._detached[key])) local result, ud = updateFn(KeyGone, 0, offset, prev, userdata[key]) @@ -1386,11 +1522,12 @@ function activateList(self, physicalTarget) error("Slot:List — KeyGone에는 nil/None(파괴) 또는 Detach(홀드)만 반환할 수 있음 " .. "(자리가 없어진 키에 새 요소를 마운트할 수 없고, prev 유지도 모순)") end - settle(key, result, detach, 0) -- pos는 의미 없음(자리를 안 차지함) + settle(key, result, detach, 0) -- slotPos는 의미 없음(자리를 안 차지함) userdata[key] = ud -- 유저가 nil을 반환해야 지워짐 + prevKeys[key] = nil -- [`H-38`] 이 키는 이제 없다 — + -- **다음 사이클엔 다시 안 묻는다** end end - keyIndex = newKeyIndex -- 데이터에서 사라진 키는 여기 없으므로 **다음 사이클엔 다시 안 묻는다** end -- [이관, 2026-08-21] `_detached` 정리용 Effect는 원래 `mountSlotTree`가 @@ -1444,23 +1581,30 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 실제 Slot 안 마운트된 개수는 `i`보다 항상 적어짐 — 그 상태로 `rawAdd`를 `i` 위치에 부르면 `Add`의 "범위 밖 index는 clamp 없이 error"(위 "CRUD API 확정" 절)에 걸려 그냥 터짐. `pos`는 이번 사이클에서 **지금까지 실제로 -마운트된 개수**만 세는 별도 카운터라 이 문제가 없음 — `keyIndex`/`rawMove`/ -`rawAdd`도 전부 이 `pos` 기준으로 통일. filter 탈락 없이 순서대로 통과하는 +마운트된 개수**만 세는 별도 카운터라 이 문제가 없음 — `rawMove`/`rawAdd`도 +전부 이 카운터 기준으로 통일(**[2026-08-24 `H-2`] 그 카운터는 이제 `slotPos`이고 +리프 개수와 분리됐다**, 위 `reconcile`). filter 탈락 없이 순서대로 통과하는 흔한 경우엔 `pos == i`라 체감상 달라지는 게 없음. -**[같은 세션 후속] `updateFn`에 넘기는 `index`가 `candidateIndex`(=`pos + 1`)인 +**[같은 세션 후속] `updateFn`에 넘기는 `index`가 "살아남으면 차지할 위치"인 이유 — `idx`를 `:List`가 State로 관리하던 안을 기각하며 나온 재설계.** -`candidateIndex`는 **"이 item이 이번 사이클에 살아남으면 차지할 압축 -위치"** — 직전까지 처리된 item들의 생존 개수(`pos`)만으로 계산되므로 -이 item 자신이 살아남을지와 무관하게 `updateFn` 호출 **전에** 이미 정확히 -알 수 있음. 그래서 `result ~= nil`일 때만 `pos = candidateIndex`로 커밋— +그 값은 **"이 item이 이번 사이클에 살아남으면 차지할 위치"** — 직전까지 +처리된 item들의 생존분만으로 계산되므로 이 item 자신이 살아남을지와 무관하게 +`updateFn` 호출 **전에** 이미 정확히 알 수 있음. +(**[2026-08-24 `H-2`] 계산 방식만 바뀌었다** — 옛 `candidateIndex = pos + 1`은 +리프 카운터와 배열 인덱스를 겹쳐 써서 틀렸고, 지금은 배열 자리를 `slotPos`로 +따로 세고 넘기는 값은 `Dispatch.getOffsetAt`에서 뽑는다. **"살아남으면 차지할 +위치를 미리 준다"는 이 성질 자체는 그대로다.**) +그래서 `result ~= nil`일 때만 `slotPos`를 커밋— 살아남지 못하면(`nil` 반환) 그 값은 그냥 버려지고 다음 생존자가 같은 값을 받음. 이 덕에 `updateFn`은 항상 **정확한 최종값**을 받아서, 위 "왜 `LayoutOrder`를 Slot이 대신 안 해주는가" 절의 `Source(index)` 예시처럼 새 원소를 처음부터 올바른 값으로 만들 수 있음(임시값→나중에 정정하는 -이중 write가 생기지 않음) — `candidateIndex` 자체가 다음 값을 미리 -계산해두는 것뿐이라 look-ahead(아직 안 본 뒤쪽 item을 미리 훑는 것)가 -전혀 필요 없는, 여전히 단일 forward pass. +이중 write가 생기지 않음) — 그 값 자체가 다음 자리를 미리 계산해두는 +것뿐이라 look-ahead(아직 안 본 뒤쪽 item을 미리 훑는 것)가 전혀 필요 없는, +여전히 단일 forward pass(**[2026-08-24 `H-31`] 다만 중복 키 검사만은 +선행 패스로 분리됐다** — `keyFn`만 도는 패스라 `updateFn` 호출은 여전히 +한 번뿐이다). - **`data:Observer(fn)`**: 새 구독 프리미티브 아님 — 2026-08-07 여섯 번째 세션에 이미 "등록 즉시 1회 실행" 확정된 그 메소드를 그대로 씀. @@ -1477,17 +1621,17 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 200개 중 값만 갱신되는 사이클엔 200번의 값싼 함수 호출이 있을 뿐, 200번의 재구성이 있는 게 아님. - **`mounted`/`userdata`를 정리하는 루프가 `mounted`가 아니라 이전 - 사이클의 `keyIndex`를 순회하는 이유** — `userdata`가 이제 `result == + 사이클의 키 집합(`prevKeys`)을 순회하는 이유** — `userdata`가 이제 `result == nil`이어도 살아남을 수 있어서(위 "반환값 두 개는 서로 독립"), 어떤 key가 `mounted[key] == nil`인 채로(필터 탈락 상태) `data`에서 완전히 사라지면 `pairs(mounted)`로는 그 key가 아예 안 잡혀서 `userdata`가 못 치워지고 샘 — 직전 사이클에 실제로 존재했던 **전체** key 집합 - (`keyIndex`, 매 사이클 모든 key에 대해 채워짐)을 순회해야 이 케이스를 + (`prevKeys`, 매 사이클 모든 key에 대해 채워짐)을 순회해야 이 케이스를 놓치지 않음. `userdata` 안에 사용자가 직접 넣어둔 `Source`(예: 위 `LayoutOrder` 예시의 `layoutOrder`)도 이 정리 대상에 자연히 포함됨 — `:List` 자신은 그 안을 안 들여다보지만, `userdata[key] = nil`이 되는 순간 참조가 끊겨 GC됨. -- **`mounted`/`userdata`/`keyIndex`**: `activateList`(마운트 시점 1회 +- **`mounted`/`userdata`/`prevKeys`**: `activateList`(마운트 시점 1회 실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) 불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도 같이 GC됨(아래 "구독 시점" 절). @@ -1577,7 +1721,7 @@ GC 폴백이 아예 없으므로 명시적 정리 경로가 **필수**다. - **`slot._detached: {[key]: T}?` — Slot 필드, 단 lazy(`nil` 허용).** 클로저 업밸류가 아니라 필드여야 `destroySlotTree`/`dispose`의 walk가 닿는다. - (`mounted`/`userdata`/`keyIndex`가 `activateList`의 업밸류로 남는 것과 다른 + (`mounted`/`userdata`/`prevKeys`가 `activateList`의 업밸류로 남는 것과 다른 이유 — **마운트된 요소는 `_elements`를 통해 walk가 이미 닿기 때문**이다.) - **⭐ [2026-08-21 5라운드 `DE-7`] 모든 Slot이 이 테이블을 미리 갖지 않는다** — `_detached`를 채우는 건 `:List`의 `settle`뿐인데 Slot 대부분은 @@ -1661,8 +1805,8 @@ updateFn(item: T | KeyGone, index, offset, prev, ud) 시그니처도 안 바뀐다(`nil`을 넣으면 타입이 바뀜). `offset`은 Slot의 것을 그대로 넘긴다(항상 유효). - **한 번만 묻는다 — 새 규칙이 필요 없다.** 소멸 루프는 **직전 사이클의 - `keyIndex`**(= 그때 데이터에 있던 키)만 순회하는데, 사라진 키는 이번 - 사이클 `keyIndex`에 안 들어가므로 **다음 사이클엔 대상이 아니다.** 홀드된 + `prevKeys`**(= 그때 데이터에 있던 키)만 순회하는데, 사라진 키는 소멸 + 루프가 그 자리에서 지우므로 **다음 사이클엔 대상이 아니다.** 홀드된 것은 `_detached`/`userdata`에 조용히 남아 있다가 (a) 키가 데이터에 다시 나타나면 `prev`로 부활하고, (b) owner가 죽으면 `activateList`가 설치한 `_detachCleanup` Effect가 정리한다(**[이관, 2026-08-21]** 원래 @@ -1772,13 +1916,18 @@ Slot:Single(state, updateFn?, opts?) `Dispatch.setLength(inst,i, self.Length)`를 부르는 것과 같은 자리에서 같이 트리거되면 됨. -**`:List()`가 마운트 이후에 불리는 경우 — `self._mounted`면 즉시 활성화 -(확정)**: 마운트는 1회성 이벤트라, `:List()`가 마운트보다 늦게 호출되면 -그 이벤트를 기다리는 방식으론 영영 활성화가 안 됨 — `:List()`가 -`self._mounted`를 확인해서 이미 참이면 그 자리에서 바로 -`activateList(self, self._mountedInst)`를 호출(마운트 시점에 물리 target을 -`self._mountedInst`로 같이 저장해둠). CRUD와의 상호배타 가드(`self._listed`)와 +**`:List()`가 실체화 이후에 불리는 경우 — 그 자리에서 즉시 활성화 (확정)**: +마운트는 1회성 이벤트라, `:List()`가 늦게 호출되면 그 이벤트를 기다리는 +방식으론 영영 활성화가 안 됨 — `:List()`가 그 자리에서 바로 +`activateList`를 호출한다. CRUD와의 상호배타 가드(`self._listed`)와 같은 자리에서 자연스럽게 처리됨 — 호출 순서에 대한 새 제약을 추가하지 않음. +**⚠️ [판정 기준 정정, 2026-08-24 6라운드 `H-2`] 그 판정은 `self._mounted`가 +아니라 `self._physicalTarget`이다** — 여기 원래 *"`self._mounted`를 확인해서 +이미 참이면 … `activateList(self, self._mountedInst)`"*라고 적혀 있었는데, +`_mounted`는 이제 **물리 인스턴스 유무**만 뜻하므로 그 기준은 "실체화만 된 +상태"(부기는 서 있고 물리는 아직)를 놓친다. 실제 확정 의사코드는 위 +`Slot:List` 블록이 소스이고, 거기서 초기 population을 Blocker로 감싸는 +것까지 같이 한다. **canExecute와 "등록 즉시 1회 실행"의 관계 — 초기 실행은 게이팅과 무관하게 무조건 일어남(사용자 확인)**: `data:Observer(fn)`가 등록되는 순간 @@ -1800,7 +1949,7 @@ Destroy 시 자동으로 끊는 Connection)이 죽어 `canExecute`가 거짓이 future 재실행이 no-op됨(위 "`state:Observer(fn)`" 절 원칙 재사용) — 그리고 "이전 state를 계속 관측하는 것도 관둬야 한다"는 요구도, `gchold`가 `Relate(inst)`(weak-keyed) 아래 있어서 `inst`가 죽으면 그 안에 강참조로 -붙잡혀 있던 Observer/클로저(`mounted`/`userdata`/`keyIndex`를 포함해)가 +붙잡혀 있던 Observer/클로저(`mounted`/`userdata`/`prevKeys`를 포함해)가 전부 같이 GC 대상이 되는 것으로 공짜로 해결 — 명시적으로 구독을 끊는 새 코드가 필요 없음, `base/lifecycle-pattern.md`의 "정리(`retract`)는 기본적으로 GC에 위임" 원칙 그대로. @@ -1890,7 +2039,21 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 없이 **`:List`를 정확히 0/1개짜리 배열로 감싸는 sugar**: ```lua -local function identityUpdateFn(item) return item end +-- [정정, 2026-08-24 6라운드 손 트레이싱 `H-22`] **`KeyGone`을 흡수해야 한다.** +-- 소멸 루프는 `updateFn(KeyGone, ...)`을 부른 뒤 `nil`/`None`/`Detach`가 아닌 +-- 반환을 전부 error로 막는데(5라운드 `DE-9`), 옛 identity는 받은 `KeyGone`을 +-- 그대로 돌려주므로 **그 error에 100% 걸렸다.** 영향 범위가 `Slot:Add(state)` +-- sugar 전부 / `:Single(state)`의 updateFn 생략 전부 / 내부 `wrapElement` 전부라 +-- 사실상 반응형 raw 요소 기능 전체가 첫 nil-전이에서 죽었다(같은 문서가 +-- *"`State`(nilable)도 특별 취급 없이 그냥 됨"*을 보장하고 있어 문서 내부 +-- 모순이기도 했다). 흡수 후 기본 동작은 "키가 사라지면 파괴"이고, 이는 +-- `Owned` 표와도 맞는다(`Owned = false`면 `releaseElement`가 언마운트만 한다). +-- **사용자가 직접 쓰는 identity성 `updateFn`에도 같은 처리가 필요하다** — +-- `:List` 파라미터 설명에 명시할 것. +local function identityUpdateFn(item) + if item == KeyGone then return nil end + return item +end function Slot:Single(state, updateFn, opts) updateFn = updateFn or identityUpdateFn -- [2026-08-11 일곱 번째 세션] 기본값 추가 @@ -1923,7 +2086,7 @@ sugar로 재정의되면서, `updateFn` 생략이 유효해야 그 sugar가 성 컴포넌트가 Slot을 리턴하는" 우회가 애초에 필요 없어짐. Slot-in-Slot 중첩(아래 절)의 정당화 근거는 이것과 별개 — 컴포넌트 결합 시 결과 타입이 뭐든(Instance/Slot) 균일하게 다룰 수 있어야 한다는 요구. -- `mounted`/`userdata`/`keyIndex` 전부 `:List`가 이미 갖고 있는 걸 +- `mounted`/`userdata`/`prevKeys` 전부 `:List`가 이미 갖고 있는 걸 그대로 재사용, 코드 중복 없음. `:List`와 마찬가지로 `self._crudUsed` 체크 대상(내부적으로 `:List`를 호출하므로 자동 적용). @@ -2010,6 +2173,11 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 -- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다. local function materializeSlotTree(slot, physicalTarget, ownerKey, position) + -- [2026-08-24 6라운드 `H-2`] **앵커를 먼저 저장한다.** `setLength`의 4번째 + -- 인자(그리고 length가 State일 때 `bk.observers`의 앵커)가 실체화 시점부터 + -- 필요한데 `_mountedInst`는 `mountSlotTree`에서야 채워진다. `raw*`가 이 + -- 필드를 본다(위 "`isMounted` 이중 추적 분리" 절의 3상태). + slot._physicalTarget = physicalTarget -- offset 먼저 — activateList가 updateFn에 이 값을 넘겨야 하므로(C1) -- [2026-08-21 5라운드 G절 검토 중 발견] **기존 Source가 있으면 재사용한다.** -- 매번 `Source(0)`을 새로 만들면, 언마운트가 `slot.Offset`을 일부러 보존해둔 @@ -2023,10 +2191,14 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) -- `slot.Offset`**에서 시작한다 — 그래야 자식 offset이 절대값이 된다(안 그러면 -- depth ≥ 2에서 부모 베이스만큼 어긋남). 베이스를 부기에 따로 복사해두지 -- 않는다(같은 값이 두 곳에 생김) — `base/dispatch-core-plan.md`의 `recompute`. - -- `_mounted`는 여전히 false — reconcile의 rawAdd가 "아직 마운트 전" - -- 경로(= `_elements`에만 넣고 끝)를 타야 함(RC-3/RC-4). 이제 이 조건이 - -- **함수 경계로 강제**된다: `_mounted`를 켜는 코드가 이 함수엔 아예 없음. - if slot._listed then activateList(slot, physicalTarget) end + -- `_mounted`는 여전히 false — reconcile의 rawAdd가 **물리 op을 건너뛰는** + -- 경로를 타야 함(RC-3/RC-4). 이제 이 조건이 **함수 경계로 강제**된다: + -- `_mounted`를 켜는 코드가 이 함수엔 아예 없음. + -- **⭐ [2026-08-24 정정, `H-2`] 옛 서술은 그 경로를 "`_elements`에만 넣고 + -- 끝"이라고 적었는데 그건 이제 틀리다** — `rawAdd`는 마운트 전에도 부기를 + -- 전부 한다(`native*`만 건너뛴다). 그래야 population 도중 + -- `Dispatch.getOffsetAt`이 성립하고, `updateFn`에 넘기는 `index`를 거기서 + -- 구할 수 있다(아래 "`:List`의 `index`도 nested-Slot 결과의" 절). -- 자식 부기만, 재귀로 길이를 bottom-up 확정. -- Blocker가 감싸는 게 이제 **등록뿐**이라 "배치 등록 게이팅"이라는 @@ -2034,6 +2206,13 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용 blocker:On() + -- **⭐ [2026-08-24 순서 변경, `H-2`] `activateList`가 이제 Blocker *안*에서 + -- 돈다.** 5라운드 `AS-5`가 이걸 밖에 둬도 된다고 한 근거는 *"그 안에선 + -- 게이팅할 recompute 자체가 안 일어난다"*였는데, 위 정정으로 일어나게 + -- 됐다 — 밖에 두면 population 중 아이템마다 `recompute`가 돌아 O(n²)다. + -- (`getOffsetAt`은 `lengthList`를 직접 읽으므로 게이트 안에서도 정확하다 — + -- Blocker가 막는 건 `recompute`지 부기 등록이 아니다.) + -- [2026-08-21 5라운드 G절] **깊은 전파** — 앞 형제의 길이가 변해 내 베이스가 -- 밀리면 내 자식들의 offset도 다시 계산돼야 한다. -- `_listObserver`/`_detachCleanup`과 **같은 취급**: 생성 1회, 재마운트 땐 @@ -2050,15 +2229,32 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) end) bindLifetime(physicalTarget, slot._baseObserver) end - for i, element in ipairs(slot._elements) do - if isSlot(element) then - -- 재귀 — 자식이 자기 끝에서 setLength(slot, i, 자기Length)까지 하고 옴 - materializeSlotTree(element, physicalTarget, slot, i) - else - -- 평범한 요소: 자기 자리의 offset은 아무도 안 읽으므로 None, - -- length는 상수 1. 순서는 늘 offsetSource → setLength(C4). - Dispatch.setOffsetSource(slot, i, None) - Dispatch.setLength(slot, i, 1, physicalTarget) -- 4번째 = 생명주기 앵커(5라운드 `C-4`) + -- **⭐ [2026-08-24 분기, `H-2`. 같은 날 `/code-review high` 지적으로 조건 정정]** + -- `activateList`가 **최초 population을 실제로 수행할 때만** 이 루프를 + -- 건너뛴다 — 그때는 그 안의 `rawAdd`가 이미 자리마다 등록을 마쳤으므로 + -- 여기서 또 돌면 같은 자리를 두 번 등록한다. + -- **⚠️ 조건이 `slot._listed` 하나면 포탈 재마운트가 깨진다**: 재마운트에선 + -- `activateList`가 `_listActivated` 멱등 가드에 걸려 **앵커만 새 target으로 + -- 옮기고 즉시 리턴**하므로(위 그 함수), 루프까지 건너뛰면 보존된 + -- `_elements` 안의 중첩 Slot들이 **다시 실체화되지 않는다** — + -- `_physicalTarget`이 `nil`인 채 `_mounted`만 켜지고, `_baseObserver`가 + -- **죽은 옛 target**에 매달린 채 남는다(그 가드 자신이 경고하는 실패 모드). + if slot._listed and not slot._listActivated then + activateList(slot, physicalTarget) -- 최초 population — rawAdd가 자리마다 등록 + else + if slot._listed then + activateList(slot, physicalTarget) -- 재마운트: 앵커만 새 target으로 + end + for i, element in ipairs(slot._elements) do + if isSlot(element) then + -- 재귀 — 자식이 자기 끝에서 setLength(slot, i, 자기Length)까지 하고 옴 + materializeSlotTree(element, physicalTarget, slot, i) + else + -- 평범한 요소: 자기 자리의 offset은 아무도 안 읽으므로 None, + -- length는 상수 1. 순서는 늘 offsetSource → setLength(C4). + Dispatch.setOffsetSource(slot, i, None) + Dispatch.setLength(slot, i, 1, physicalTarget) -- 4번째 = 생명주기 앵커(5라운드 `C-4`) + end end end -- [2026-08-21 감사] 위 재귀가 **예외를 던지면 이 줄에 도달하지 못해 @@ -2110,9 +2306,16 @@ local function mountSlotTree(slot, physicalTarget) end -- (3) 공개 진입점 — 이름/시그니처/호출부 전부 옛것 그대로. 몸통만 두 줄. -local function attachSlot(slot, physicalTarget, ownerKey, position) +local function attachSlot(slot, physicalTarget, ownerKey, position, mount) materializeSlotTree(slot, physicalTarget, ownerKey, position) - mountSlotTree(slot, physicalTarget) + -- [2026-08-24 `H-2`] **부모가 아직 마운트 전이면 실체화까지만 한다.** + -- `rawAdd`가 마운트 전에도 부기를 하게 되면서(위 그 함수) 자식 Slot에도 + -- "부기는 하되 물리는 안 함"이 필요해졌다. `mount`가 생략되면 true + -- (기존 호출부 전부가 마운트까지 원하는 경로라 기본값이 옛 동작이다) — + -- `rawAdd`/`rawReplace`만 `self._mounted`를 그대로 넘긴다. + if mount ~= false then + mountSlotTree(slot, physicalTarget) + end end ``` @@ -2161,12 +2364,12 @@ attachSlot(slotValue, inst, inst, k) -- ownerKey = 물리 inst 자신 **이미 마운트된 outer에 nested Slot을 나중에 `Add`하는 경우(런타임에 카테고리 추가):** ```lua --- rawAdd 안, element가 Slot이고 self가 이미 마운트돼 있을 때 -if isSlot(element) and self._mounted then - attachSlot(element, self._mountedInst, self, index) -end --- self가 아직 마운트 전이면 _elements에만 들어가고, self가 나중에 --- attachSlot될 때 materializeSlotTree/mountSlotTree의 루프가 처리 +-- rawAdd 안, element가 Slot일 때 +-- **[2026-08-24 정정, `H-2`]** 옛 서술은 *"self가 아직 마운트 전이면 +-- `_elements`에만 들어가고 나중에 처리"*였는데, 그러면 최초 population 중 +-- `getOffsetAt`이 성립하지 않는다. 이제 **부기는 항상 하고 물리만 가른다.** +attachSlot(element, self._physicalTarget, self, index, self._mounted) +-- ^^^ mount 여부 ``` **이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인, @@ -2197,10 +2400,21 @@ Slot=그 `.Length`)이 됨. plain 요소만 있는 흔한 경우엔 항상 합== -- 물리 트리에서만 떼어내고 `_elements`/자식 소유권은 통째로 보존하므로, -- 같은 Slot을 나중에 다른 곳에 다시 마운트할 수 있음(= 포탈). local function unmountSlotTree(slot) - for i, element in ipairs(slot._elements) do + -- **⭐ [2026-08-24 정정, 6라운드 손 트레이싱 `H-6`] 두 가지를 고쳤다.** + -- (1) `physicalTarget`이 **어디에도 안 묶여 있었다** — 인자는 `slot` 하나뿐인데 + -- 아래 루프가 그 이름을 참조했다. 로컬로 먼저 뽑아 쓴다(같은 함수가 + -- 아래에서 `slot._mountedInst = nil`로 지우므로 읽는 순서도 지켜야 한다). + -- (2) **역순 순회로 바꿨다** — 앞에서부터 빼면 뒤가 물리적으로 당겨져 + -- 두 번째부터는 `getOffsetAt`이 주는 부기 offset과 실제 물리 위치가 + -- 어긋난다. Roblox 백엔드는 `elements` 배열로 받으니 무해하지만 + -- offset을 신뢰하는 백엔드(DOM `childNodes[offset]` 최적화 등)에선 + -- 틀린 자리를 짚는다. 뒤에서부터 빼면 앞쪽 offset이 안 밀려 매번 정확하다. + local physicalTarget = slot._mountedInst + for i = #slot._elements, 1, -1 do + local element = slot._elements[i] if isSlot(element) then unmountSlotTree(element) -- 재귀 — 중첩 Slot도 똑같이 비파괴 - else + elseif physicalTarget then -- [`H-12`] 실체화만 된 상태면 뗄 물리가 없다 nativeExtract(physicalTarget, Dispatch.getOffsetAt(slot, i), { element }) -- 파괴 아님(주입 op) end -- releaseOwner를 **안 부름** — 자식들은 여전히 이 slot의 소유. 이게 @@ -2217,7 +2431,7 @@ local function unmountSlotTree(slot) -- Slot의 `:List`가 조용히 멈추고(`_listObserver`) `_detached`가 -- 파괴된다(`_detachCleanup`) — 포탈 경로에서 실제로 터진다. -- **핸들과 `_listActivated`는 보존한다**(언마운트는 파괴가 아니다) — - -- 구독과 그 클로저 상태(`mounted`/`userdata`/`keyIndex`)는 재마운트 + -- 구독과 그 클로저 상태(`mounted`/`userdata`/`prevKeys`)는 재마운트 -- 후에도 이어져야 하고, 새 target에 다시 걸어주는 건 `activateList`의 -- 멱등 가드다. 파괴 쪽(`destroySlotTree`)만 핸들까지 `nil`로 지운다. if slot._listObserver then unbindLifetime(slot._listObserver) end @@ -2226,6 +2440,8 @@ local function unmountSlotTree(slot) -- `slot._detached`는 **안 건드린다** — 언마운트는 파괴가 아니고, -- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유). slot._mounted, slot._mountedInst = false, nil + slot._physicalTarget = nil -- [2026-08-24 `H-2`] 앵커도 같이 — 안 지우면 죽은 + -- inst를 계속 강하게 붙잡는다(재실체화가 새로 채운다) -- [정정, 2026-08-20 `SL-75`] slot.Offset은 건드리지 않는다 — nil로 되돌리면 -- 그 Source를 이미 구독 중인 다운스트림이 영구히 끊긴다(포탈이 깨짐). -- stale한 채 남겨두고, 재마운트 시 setOffsetSource의 즉시 계산이 덮어쓴다. @@ -2266,7 +2482,7 @@ local function destroySlotTree(slot) -- [2026-08-21 감사] `:List` 구독도 푼다 — **파괴이므로 `unmountSlotTree`와 -- 달리 핸들과 `_listActivated`까지 지운다.** 안 풀면 `gchold[physicalTarget]`이 - -- observer를(그리고 그 클로저가 붙잡은 `mounted`/`userdata`/`keyIndex`와 + -- observer를(그리고 그 클로저가 붙잡은 `mounted`/`userdata`/`prevKeys`와 -- 파괴된 slot 자신을) 계속 강하게 붙잡는다 — 중첩 Slot은 아무리 깊어도 -- `physicalTarget`이 트리 최상위 inst 하나라(`materializeSlotTree`가 같은 -- 값을 재귀에 그대로 넘김) **자식 Slot만 죽고 그 inst는 살아있는 게 흔한 @@ -2290,6 +2506,7 @@ local function destroySlotTree(slot) -- `_mounted == true`로 남아 "마운트된 Slot의 재마운트는 즉시 throw"(위 절)에 -- 영원히 걸리고, `_mountedInst`가 죽은 inst를 계속 강하게 붙잡음. slot._mounted, slot._mountedInst = false, nil + slot._physicalTarget = nil -- [2026-08-24 `H-2`] 같은 이유로 앵커도 -- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은 -- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도 -- 최상위 파괴 지점(SlotHandler.process가 반환하는 클로저, 위)에서만 한 번. @@ -2306,12 +2523,43 @@ end -- * 예외는 `rawAdd(self, element, index, fromDetached?)` 하나 — 새로 넣는 -- 대상이라 element가 인자인 게 당연하다(그 element는 **이미 래핑된 물리 -- 요소**여야 한다, 아래 "래핑은 raw 바깥에서" 항목). --- * 그래서 element만 손에 쥔 호출부(`:List`의 `settle`)가 index를 구해야 --- 하는데, **그 값은 이미 `keyIndex`가 들고 있다**(그 키가 지금 차지한 --- 압축 위치 = `_elements` 인덱스). `indexOfRaw`(O(n) 선형 탐색)는 그게 --- 없는 예외 경로용 폴백이지 기본 경로가 아니다. 둘 중 하나로 통일할지 얇은 --- 변환 계층을 둘지는 아직 M6 구현 세부로 열려 있음 — 이 블록/아래 --- reconcile 블록 둘 다 그 결정 전 illustrative 예시. +-- * **⭐ [전면 정정, 2026-08-24 6라운드 손 트레이싱 `H-1`]** 여기 원래 +-- *"element만 손에 쥔 호출부(`:List`의 `settle`)가 index를 구해야 하는데, +-- 그 값은 이미 `keyIndex`가 들고 있다"*라고 적혀 있었다. **그게 틀렸다** — +-- `keyIndex`는 사이클 **끝**에 한 번 교체되는데 사이클 도중의 +-- `rawAdd`/`rawRemove`가 배열을 시프트하므로, 그 뒤에 처리되는 키들의 +-- `keyIndex` 값은 전부 어긋나 있다(`[a,b]`→`[x,a,b]`가 조용히 `a,b,x`가 +-- 되고, 전체 삭제는 해시 순회 순서에 따라 `nil` 인덱싱까지 간다). +-- * **확정된 해법 — 역방향 인덱스 맵을 raw 층이 직접 든다.** +-- **`slot._elemIndex`**(`물리 요소 → _elements 인덱스`)를 `_elements`와 +-- 같은 수명으로 두고, **자리를 밀거나 당기는 모든 연산** +-- (`spliceArraysUp`/`spliceArraysDown`/`rawMove`/`rawSwap`/`rawReplace`/ +-- `rawAdd`)이 같이 갱신한다. detach된 요소는 `_elements` 밖이므로 +-- 맵에서도 빠진다(`rawDetach`가 지운다). +-- - **`raw*`의 index 시그니처는 그대로다**(위 목록 유지) — 바뀌는 건 +-- "호출부가 index를 어디서 구하는가"뿐이다. +-- - **`indexOfRaw(self, element)`가 이 맵 조회가 되고, 폴백이 아니라 +-- 기본 경로로 승격된다.** `settle`은 `keyIndex[key]` 대신 +-- `indexOfRaw(self, prev)`를 쓴다. +-- - **층 분리는 오히려 더 깨끗해진다** — 맵이 `_elements`와 같은 층에 +-- 살아서 `raw*`가 `:List`의 클로저 상태(`mounted`/`prevKeys`)를 볼 +-- 필요가 없다. **사용자 판단**(2026-08-24): *"raw* 가 층위를 알아야할 +-- 이유를 모르겠는 상태. 그냥 k->realElem 을 list 에선 저장하고 … +-- realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 +-- 같이 업데이트해주는 편이 나아보이기도. quad-base 의 현 모양에서 더 +-- 확장 될 땐, index 를 알아야하게 되는 경우가 많아질것이라, 선제 +-- 처리로 해결하는게 맞아보이는데"*. +-- - **비용**: 시프트는 이미 O(n) memmove라 맵 갱신이 점근 비용을 안 +-- 올리고, 조회만 O(n)→O(1)이 된다. +-- - **키 유일성은 이미 보장돼 있다** — `claimOwner`/`claimOwnerAt`이 같은 +-- 요소의 이중 배치를 error로 막으므로(위 "요소 소유권" 절) `물리 요소 → +-- index`는 함수로 잘 정의된다. +-- - **부수 효과**: `:List`의 `keyIndex`가 인덱스 맵일 이유가 없어져 +-- **단순 키 집합**으로 내려앉는다(소멸 루프의 "직전 사이클에 존재했던 +-- 키" 용도만 남음) — 아래 `reconcile` 참고. +-- - 앞으로 `Move`/`Swap`/`Extract`/`Splice`와 공개 `Slot:IndexOf`가 전부 +-- 이 맵을 쓴다(그것들이 element→index를 필요로 하는 게 이 결정의 +-- 근거 중 하나였다). -- [신설, 2026-08-13 여섯 번째 세션] rawRemove의 비파괴 짝 — `:List`의 -- reconcile과 `Extract` 계열이 씀. rawRemove와 **딱 하나만 다름: 안 죽인다.** function rawUnmount(self, index) @@ -2321,11 +2569,16 @@ function rawUnmount(self, index) unbindLifetime(bk.observers[index]) end releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음) + -- [2026-08-24 6라운드 `H-12`] **부기는 항상, 물리 op만 `_mounted`로 가른다** — + -- `rawAdd`와 같은 규칙(위 "`isMounted` 이중 추적 분리" 절의 3상태). + -- `unmountSlotTree`는 자기 안에서 `_mounted`를 보므로 여기선 안 가른다. if isSlot(element) then unmountSlotTree(element) - else nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end + elseif self._mounted then + nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) + end - spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고 - recompute(self, bk) + spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.N — 아래 참고 + recompute(self, bk) -- 자리가 없어지는 경로엔 setLength가 없으므로 여기서 명시 호출 end -- [신설, 2026-08-21] rawUnmount의 "소유권 유지" 짝 — `Detach`가 쓴다. @@ -2340,9 +2593,11 @@ function rawDetach(self, index) end -- releaseOwner를 **안 부름** — 이게 rawUnmount와의 유일한 차이 if isSlot(element) then unmountSlotTree(element) - else nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end + elseif self._mounted then -- [2026-08-24 `H-12`] 물리 op만 가름 + nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) + end - spliceArraysDown(self, index) + spliceArraysDown(self, index) -- `_elemIndex`에서도 이 요소가 빠진다(트리 밖이 됨) recompute(self, bk) end @@ -2362,6 +2617,43 @@ function releaseElement(self, index, element, wasDetached) else rawUnmount(self, index) end -- 언마운트만(사용자 것) end +-- **⭐ [신설, 2026-08-24 6라운드 손 트레이싱 `H-29`] `collectLeaves(slot)` — +-- 중첩 Slot이 차지하는 물리 리프를 순서대로 평탄화해 모으는 헬퍼.** +-- `native*` 계층이 *"빠지는 요소는 반드시 `elements` 배열로 넘긴다"*를 계약으로 +-- 못박았는데(위 그 절), `_elements[index]`가 Slot이면 넘길 배열이 없다. +-- `rawRemove`/`rawUnmount`는 Slot 요소를 `destroySlotTree`/`unmountSlotTree`로 +-- 빠져나가 이 문제를 피했지만 `Move`/`Swap`/`Extract`/`Splice`엔 그 우회가 없다. +-- **사용자 확정(2026-08-24): 헬퍼를 새로 둔다**(대안이던 "중첩 Slot은 +-- unmount+attach 경로로" 는 `Move`/`Swap`을 따로 둔 이유를 되돌린다 — `H-34`). +function collectLeaves(slot, out) + out = out or {} + for _, element in ipairs(slot._elements) do + if isSlot(element) then collectLeaves(element, out) + else table.insert(out, element) end + end + return out +end + +-- **⭐ [2026-08-24 `H-29`] 아직 의사코드가 없는 `raw*` — 작성 시 지킬 것.** +-- `rawMove`/`rawSwap`/`rawExtract`/`rawSplice`/`rawClear`는 이름만 있었다 +-- (`rawMove`는 `reconcile`이 **직접 부르는** 함수인데도). 새 결정이 필요한 건 +-- 위 `collectLeaves` 하나였고, 나머지는 아래 규약대로 쓰면 된다: +-- 1. **함께 치환되는 것** — `_elements`, `slot._elemIndex`(`H-1`), +-- `bk.lengthList`, `bk.sourceList`, `bk.observers`. 넷 다 **position +-- 인덱스**이고 `sourceList[i]`는 그 중첩 Slot 자신의 `slot.Offset`이라 +-- position이 아니라 **요소에 귀속**된다 → 전부 같은 순열로 움직인다. +-- 2. **`bk.N`은 안 변한다** — 자리 수가 그대로이므로(`spliceArraysUp`/`Down`과 +-- 갈리는 지점). +-- 3. **캐시는 당긴다** — 바뀐 최소 위치로 `bk.invalidAfter`를 `math.min`(`H-3`). +-- 4. **`recompute`는 `setLength`에 일임**(`H-19`) — 순서만 바뀌어 길이 합이 +-- 그대로여도 offset은 전부 바뀌므로, 자리 이동 후 해당 위치들의 +-- `setLength`를 다시 태워 게이트를 통과시킨다. +-- 5. **물리 op에 넘길 `elements`는 `collectLeaves`로 만든다**(중첩 Slot 요소). +-- 6. **물리 op은 `_mounted`로 가른다**(`H-12`) — 부기는 항상. +-- 참고: 이 문서에 남아 있던 *"`raw*` 내부 호출 규약은 공개 API와 다를 수 +-- 있음(구현 세부, M6에서 확정)"*은 5라운드의 index 통일로 이미 닫힌 +-- **stale**이라 근거로 인용하지 말 것. + -- **raw 3형제 — 갈리는 축이 둘(파괴하는가 / 소유권을 놓는가)**: -- rawRemove : 소유권 반납 + **파괴** -- rawUnmount: 소유권 반납 + 파괴 안 함 ← 요소를 살려서 내보내는 경로 @@ -2372,23 +2664,43 @@ end -- [신설, 2026-08-21 5라운드 `C-1`] rawAdd — 이 문서에서 가장 많이 참조되는데 -- 정의가 없어서 `_mounted` 분기가 다른 함수 주석에만 흩어져 있었다. 새 결정은 -- 없고 기존 서술을 모은 것(사용자 확인, 5라운드). +-- **⭐ [전면 재작성, 2026-08-24 6라운드 손 트레이싱 `H-2`/`H-5`/`H-12`/`H-19`]** +-- 옛 버전은 `if not self._mounted then return index end`로 **부기까지 통째로** +-- 건너뛰었다. 그러면 `:List`의 최초 population 동안 `bk.lengthList`가 비어 있어 +-- `Dispatch.getOffsetAt(self, 2)`가 `nil`을 읽는다 — `updateFn`에 넘길 `index`를 +-- offset에서 구하려면 그 자리에서 이미 부기가 서 있어야 한다. +-- **이제 `_mounted`는 물리 인스턴스 유무만 가른다**(위 "`isMounted` 이중 추적 +-- 분리" 절의 3상태): **부기는 실체화 시점부터 항상 하고, `native*`만 가린다.** +-- **⚠️ [2026-08-24 재정정, `/code-review high` 지적] 위 재작성이 가드를 하나 +-- 통째로 지웠었다.** 상태는 **셋**인데(미실체화/실체화/마운트) `_mounted` 경계만 +-- 코드에 남기고 **첫 경계를 안 뒀다** — 그러면 `Slot { frameA }` 생성자가 +-- (그 자체가 `:Add`를 부른다, 위 그 절) `materializeSlotTree`보다 훨씬 먼저 +-- `Dispatch.setLength(self, 1, 1, nil)` → `gatedRecompute` → `getOffsetAt` → +-- `ownerKey.Offset:Get()`에서 **`slot.Offset`이 아직 nil이라 즉시 죽는다.** +-- 중첩이면 더 나쁘다: `attachSlot(element, nil, …)` → `bindLifetime(nil, …)`은 +-- 이 문서가 스스로 치명적이라 적어둔 그것이다. +-- **`_physicalTarget`이 곧 "실체화됐는가"의 판정이다.** function rawAdd(self, element, index, fromDetached) claimOwner(element, self, fromDetached) -- 이미 누가 갖고 있으면 error(detach 재마운트만 예외) index = index or (#self._elements + 1) table.insert(self._elements, index, element) + reindexFrom(self, index) -- `_elemIndex` 갱신 — **상태와 무관하게 항상** - if not self._mounted then - -- **아직 마운트 전: 부기도 물리도 없다.** 이 분기가 `materializeSlotTree`가 - -- `activateList`를 Blocker **밖**에서 불러도 안전한 이유다(5라운드 `AS-5`) - -- — 게이팅할 recompute 자체가 안 일어난다. 나중에 attachSlot이 통째로 처리. + if self._physicalTarget == nil then + -- **아직 실체화 전: 부기의 앵커도 베이스 offset도 없다.** `_elements` + -- (와 그 역방향 맵)에만 넣고 끝낸다 — 나중에 `materializeSlotTree`가 + -- 통째로 처리한다. 여기서 부기를 시도하면 위 배너의 크래시가 난다. return index end local bk = getBookkeeping(self) - spliceArraysUp(self, index) -- _elements 외 배열들을 한 칸 밀고 bk.N 증가 + spliceArraysUp(self, index) -- 부기 배열들을 한 칸 밀고 + -- bk.N 증가 + `lengthList[index]` 자리표시자(`H-5`) if isSlot(element) then - attachSlot(element, self._mountedInst, self, index) -- 자식이 자기 부기+물리를 다 함 + -- 자식이 자기 부기+물리를 다 한다. 아직 마운트 전이면 **실체화까지만** — + -- `attachSlot`이 `materializeSlotTree`/`mountSlotTree`로 갈린다(아래 그 절). + attachSlot(element, self._physicalTarget, self, index, self._mounted) else -- [순서 재정렬, 2026-08-21] **자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중.** -- 옛 "부기 전부 먼저"는 base가 물리적으로 자리를 비워둘 수 있다는 전제였는데 @@ -2396,30 +2708,51 @@ function rawAdd(self, element, index, fromDetached) -- "물리와 부기의 순서" 절). Dispatch.setOffsetSource(self, index, None) -- 내 자리 offset은 1..index-1의 합이라 -- 내 삽입으로 안 변한다 → 먼저 해도 안전 - nativeInsert(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) - Dispatch.setLength(self, index, 1, self._mountedInst) -- **뒤를 미는 것**은 그 다음 - recompute(self, bk) + if self._mounted then -- [`H-12`] 물리 op만 가른다 + nativeInsert(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) + end + Dispatch.setLength(self, index, 1, self._physicalTarget) -- **뒤를 미는 것**은 그 다음 + -- [`H-19`] 명시 `recompute(self, bk)`를 **삭제했다** — `setLength`가 상수 + -- 길이에도 마지막에 `gatedRecompute()`를 부르므로 두 번 돌고 있었고, + -- "recompute를 누가 부르는가"의 소스가 두 곳이면 `H-3`의 캐시 무효화를 + -- 어디 둘지가 애매해진다. **자리의 길이가 바뀌면 `setLength`가 책임진다.** end return index end -- [신설, 2026-08-21 5라운드 `B-5`] rawReplace — 자리를 유지한 채 내용만 교체. -- `destroyOld`는 호출부가 정한다(공개 `Replace`는 항상 true, `:List`는 `_owned`). --- [2026-08-21] `indexOfRaw(self, element)` — `_elements`를 **언래핑 없이 그대로** --- 선형 탐색해 인덱스를 찾는 비공개 폴백. 공개 `Slot:IndexOf`와 다르다: 그쪽은 --- 사용자가 넘긴 **언래핑된 값**으로 찾아주는 API고(위 "래핑/언래핑" 절), 이쪽은 --- 물리 요소를 그대로 찾는다. **기본 경로가 아니다** — reconcile은 `keyIndex`가 --- 이미 인덱스를 들고 있어 이걸 부를 필요가 없다(위 raw* 인자 규약). +-- [2026-08-21, **정정 2026-08-24 `H-1`**] `indexOfRaw(self, element)` — +-- **`self._elemIndex[element]` 한 번 조회**. 공개 `Slot:IndexOf`와 다른 점은 +-- 그대로다: 그쪽은 사용자가 넘긴 **언래핑된 값**으로 찾아주는 API고(위 +-- "래핑/언래핑" 절), 이쪽은 물리 요소를 그대로 찾는다. +-- **옛 서술("O(n) 선형 탐색하는 예외 경로용 폴백, reconcile은 `keyIndex`가 +-- 인덱스를 들고 있어 이걸 안 부른다")은 폐기됐다** — `keyIndex`는 사이클 +-- 도중 stale이라 그 용도로 쓸 수 없었고(`H-1`), 이제 이게 **기본 경로**다. +-- `settle`이 `keyIndex[key]` 대신 이걸 부른다. function rawReplace(self, index, newElement, destroyOld) local oldElement = self._elements[index] claimOwner(newElement, self) -- 새 요소 먼저 클레임(실패하면 아무것도 안 바뀜) releaseOwner(oldElement, self) self._elements[index] = newElement -- **시프트 없음** — 자리 수가 안 변한다 + -- [2026-08-24 `H-1`] 역방향 맵도 같이 — 자리 수는 안 변해도 **주인이 바뀐다** + self._elemIndex[oldElement] = nil + self._elemIndex[newElement] = index if not self._mounted then - if destroyOld then -- 트리 밖이라 물리 op이 필요 없다 + if destroyOld then -- 물리 트리 밖이라 native* 교체가 필요 없다 if isSlot(oldElement) then destroySlotTree(oldElement) else nativeDispose(oldElement) end end + -- [2026-08-24 `H-12`, 같은 날 `/code-review high`로 가드 보강] + -- 부기는 **실체화된 뒤라야** 한다 — `rawAdd`와 같은 이유(앵커/베이스가 + -- 아직 없으면 `setLength`가 `getOffsetAt`에서 죽는다). + if self._physicalTarget ~= nil then + if isSlot(newElement) then attachSlot(newElement, self._physicalTarget, self, index, false) + else + Dispatch.setOffsetSource(self, index, None) + Dispatch.setLength(self, index, 1, self._physicalTarget) + end + end return end @@ -2437,7 +2770,7 @@ function rawReplace(self, index, newElement, destroyOld) op(self._mountedInst, offset, { oldElement }) end if isSlot(newElement) then - attachSlot(newElement, self._mountedInst, self, index) + attachSlot(newElement, self._physicalTarget, self, index, self._mounted) return end Dispatch.setOffsetSource(self, index, None) @@ -2446,8 +2779,8 @@ function rawReplace(self, index, newElement, destroyOld) local op = if destroyOld then nativeRemove else nativeExtract op(self._mountedInst, offset, { oldElement }, { newElement }) -- 한 번에 end - Dispatch.setLength(self, index, 1, self._mountedInst) - recompute(self, bk) + Dispatch.setLength(self, index, 1, self._physicalTarget) + -- [`H-19`] 명시 `recompute(self, bk)` 삭제 — `setLength`에 일임(위 `rawAdd` 참고) end function rawRemove(self, index) @@ -2464,13 +2797,49 @@ function rawRemove(self, index) -- [2026-08-21] 파괴 경로는 **한 번에** — 빼기와 파괴를 백엔드가 융합할 수 있다 -- (Roblox는 Parent=nil 없이 그 자리에서 Destroy가 더 싸다). if isSlot(element) then destroySlotTree(element) - else nativeRemove(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end + elseif self._mounted then -- [2026-08-24 `H-12`] 물리 op만 가른다 + nativeRemove(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) + else + -- **⚠️ [2026-08-24 재정정, `/code-review high` 지적] `else`가 비어 있었다.** + -- `nativeRemove`가 곧 **파괴**였는데(위 주석: "빼기와 파괴를 백엔드가 + -- 융합") `_mounted`로 가리기만 하니, 마운트 전 창에서 요소가 + -- `_elements`에서 빠지고 **아무도 안 죽인다**. quad Instance는 gcconn + -- 때문에 `Destroy` 말고는 회수 경로가 없으므로(`base/fallback-plan.md`의 + -- ⚠️ 절) 지연 GC가 아니라 **영구 누수**다. `rawReplace`의 마운트 전 + -- 분기가 이미 `nativeDispose`로 올바르게 처리하고 있었다. + nativeDispose(element) -- 트리 밖이라 offset이 필요 없다 + end - spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고 + spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.N recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만 end ``` +**⭐ [2026-08-24 6라운드 손 트레이싱 `H-1`/`H-5`/`H-3`] `spliceArraysUp`/ +`spliceArraysDown`이 해야 하는 일이 셋 늘었다.** + +- **`slot._elemIndex`는 별도 헬퍼 `reindexFrom(self, from)`이 맡는다**(`H-1`, + **[2026-08-24 분리]**). 이 맵은 `_elements`의 역방향이라 **실체화 여부와 + 무관하게 항상** 정확해야 하는데, `spliceArrays*`는 부기(`bk`)를 만지므로 + 실체화된 뒤에만 부를 수 있다 — 그래서 둘을 갈랐다. `reindexFrom`은 + `from`부터 끝까지 `_elemIndex[self._elements[i]] = i`를 다시 쓰고, + 제거 경로에선 빠지는 요소를 맵에서 **뺀 뒤** 부른다. + `_elements`를 시프트하는 자리는 **전부** 이걸 부른다(`rawAdd`의 미실체화 + 얼리리턴 포함). `spliceArraysUp`/`Down`은 자기 몫으로 이걸 같이 부르되, + 하는 일은 아래 부기 항목들이다. +- **`spliceArraysUp`은 `lengthList[index]`에 자리표시자(`0`)를 채운다**(`H-5`). + 옛 순서는 `spliceArraysUp`이 `bk.N`을 먼저 올리고 `lengthList[index]`는 + `setLength`가 마지막에 채워서, **그 사이에 `nativeInsert`가 끼어 있었다.** + Roblox의 `Parent` 대입은 `ChildAdded`/`DescendantAdded`를 **동기 발화**시키므로 + 그 핸들러가 같은 owner에 손대면 `recompute`가 `lengthList[index] == nil`을 + 읽어 `sum += nil`로 터진다(`sourceList[index]`는 `None`으로 채워져 있어 + 그쪽 error 가드엔 안 걸린다). **동기 재진입이라 "체인 도중 yield 금지" + 불변식으로는 안 덮인다.** `sourceList`가 `None`으로 채워지는 것과 대칭을 + 맞춰 창 자체를 없앤다. +- **캐시를 앞으로 당긴다**(`H-3`) — `bk.invalidAfter = math.min(bk.invalidAfter, index)`. + `base/dispatch-core-plan.md`의 무효화 표가 규정한 세 규칙 중 하나이고, + 지금까지 산문으로만 있고 코드 경로가 없었다. + **[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는 배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가 이 코퍼스 어디에도 명시된 적이 없었음.** @@ -2593,11 +2962,40 @@ Single(element)`을 대신 삽입** — raw 반응형 요소는 전부 Slot-in-S -- [정정, 2026-08-21 5라운드 `C-3`] 래핑을 공개 `Add` 안에 인라인으로 두지 않고 -- **공용 헬퍼 하나**로 뺀다 — `Replace`/`Extract(index, new)`/`Splice`/`:List`의 -- reconcile까지 전부 같은 래핑이 필요하기 때문(아래 절). +-- **⭐ [전면 정정, 2026-08-24 6라운드 손 트레이싱 `H-40`]** 옛 의사코드는 이 +-- 한 줄이 전부였고, **같은 문서가 확정해둔 가드를 하나도 하지 않았다.** +-- 산문이 이미 정확한 알고리즘을 서술해뒀으므로 옮기기만 한 것이다. function Slot:Add(element, index) - return rawAdd(self, wrapElement(element), index) + -- (1) `:List`가 설치돼 있으면 수동 CRUD 금지 (위 "CRUD API 확정" 절) + assert(not self._listed, "Slot: :List가 설치된 Slot엔 수동 CRUD를 쓸 수 없음") + -- (2) index 범위 검증 — **clamp 안 함**, 범위 밖이면 error + if index ~= nil and (index < 1 or index > #self._elements + 1) then + error("Slot:Add — index가 범위 밖(1.." .. (#self._elements + 1) .. "): " .. tostring(index)) + end + -- (3) 요소 타입 검증은 `wrapElement`가 한다(위 그 함수 — `isSlot`/`isState`/`isInst`) + local wrapped = wrapElement(element) + -- (4) 역방향 가드용 플래그 — 이게 없으면 `Slot():Add(x); slot:List(...)`가 + -- 조용히 통과한다(`H-37`이 지적한 대칭의 반대쪽) + self._crudUsed = true + return rawAdd(self, wrapped, index) end ``` +**⭐ [2026-08-24 `H-30`/`H-31`] 여러 요소를 받는 CRUD는 검증을 선행 패스로 +분리한다.** `Splice`의 확정 문장 *"검증은 실제 mutate 전에 전부 먼저 통과해야 +함(일부만 적용된 채 중간에 에러나는 반쪽 상태 방지)"*을 지키는 코드 경로가 +없었다 — "이미 마운트됨" 판정을 실제로 하는 곳은 `rawAdd` 안의 `claimOwner` +하나뿐이고 그건 **요소를 삽입하기 직전에 요소마다** 돈다. 그래서 +`slot:Splice(1, 0, a, b, c)`에서 `c`만 남의 소유면 `a`/`b`는 이미 들어간 +**반쪽 상태**가 된다. 확정: + +- `Splice`/`Replace`/`Extract(index, new)`는 `raw*`를 부르기 **전에** + `newElements` 전량에 대해 (a) `wrapElement`(타입 검증 포함)와 + (b) `elementOwner` 조회(이미 누가 갖고 있으면 error)를 **먼저 다 돌린다.** +- 통과한 래핑 결과를 그대로 `raw*`에 넘긴다(두 번 래핑하지 않는다). +- 같은 처방이 `:List`의 중복 키 검사에도 적용됐다(`reconcile`의 선행 패스, + 위 그 의사코드). + ### ⭐ 래핑/언래핑은 Slot 전체에 걸린 연산이다 (2026-08-21 구현 전 QA 5라운드 `C-3`) **사용자 지적**: *"replace, extract 등에서도 래핑해야하므로 래핑이 하나의 @@ -2608,7 +3006,25 @@ end ```lua -- Slot 내부 비공개 헬퍼 둘. 이 둘만이 래핑을 아는 자리다. local function wrapElement(v) - if not isState(v) then return v end + -- [2026-08-24 6라운드 손 트레이싱 `H-40`] **요소 타입 검증의 단일 관문**이 + -- 여기다. 공개 CRUD와 `:List`의 `settle`이 둘 다 이 함수를 지나므로, + -- State가 나중에 이상한 값으로 바뀌는 경우도 같은 자리에서 걸린다. + -- 판정은 화이트리스트다(위 "요소 타입 제약" 절): `isSlot` → `isState` → + -- `isInst`. 셋 중 어디에도 안 걸리면 error. + if isSlot(v) then return v end + if not isState(v) then + -- 진단을 위해 핸들러 계층 값은 따로 잡는다(왜 안 되는지 근거가 다르다) + if isRef(v) or isPreRef(v) or isPostRef(v) or isObserver(v) or isEffect(v) or isModifier(v) then + error("Slot: 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Modifier)은 요소가 될 수 없음") + end + if v == nil or v == None then + error("Slot: nil/None은 요소가 될 수 없음 — 실제로 마운트 가능한 값만") + end + if not isInst(v) then -- 백엔드 주입 술어(위 `native*` 절) + error("Slot: 이 백엔드가 마운트할 수 없는 값") + end + return v + end local sub = Slot() sub:Single(v, nil, { Owned = false }) -- updateFn은 identity 기본값. -- **`Owned = false`를 여기서 실제로 넘긴다** — 안쪽 @@ -2619,7 +3035,16 @@ end local function unwrapElement(el) if el == nil then return nil end - return el._wrapped or el -- 래퍼면 원래 값, 아니면 자기 자신 + -- [정정, 2026-08-24 6라운드 손 트레이싱 `H-21`] **`isSlot` 가드가 필수다.** + -- 옛 코드는 `el._wrapped or el` 한 줄이었는데, `el`은 **물리 요소**이고 + -- quad-roblox에서 그건 사실상 Roblox `Instance`다 — **없는 멤버를 인덱싱하면 + -- `nil`이 아니라 에러**(`_wrapped is not a valid member of Frame`)라 + -- `:List`가 raw Instance를 다루는 **모든 두 번째 사이클**이 여기서 죽었다. + -- base 일반성 관점에서도 틀렸다: `T`가 number/boolean인 백엔드면 Luau에서도 + -- `attempt to index number`다. 래퍼는 **항상 Slot**이고 `isSlot`은 `Brand`의 + -- weak-key 조회라(`base/brand-plan.md`) Instance/userdata/원시값 전부에 안전하다. + if isSlot(el) then return el._wrapped or el end + return el end ``` @@ -2862,12 +3287,27 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 제외**(2026-08-14 열 번째 세션, 사용자 확정): ```lua +-- **⭐ [정정, 2026-08-24 6라운드 손 트레이싱 `H-28`/`H-43`]** 옛 의사코드는 +-- 소유권 가드를 **`isSlot` 분기 안에만** 뒀다. 그런데 위 산문은 대상 구분 없이 +-- *"아직 어느 트리에 의해 살아있길 요구되고 있으면 파괴를 거부하고 즉시 +-- `error`"*라 선언하고, 그 근거인 `elementOwner`는 Slot이든 plain 마운트 가능 +-- 값이든 **전부** 커버한다. 그래서 **가장 흔한 대상인 "Slot에 마운트된 +-- Instance"가 `else`로 새어 그대로 파괴**됐다 — +-- `local f = Frame{}; slot:Add(f); dispose(f)`가 `f:Destroy()`까지 가고 +-- `slot._elements`엔 죽은 Instance가, `elementOwner`엔 클레임이 남아 +-- `dispose`가 막으려던 바로 그 UB가 일어난다. **가드를 분기 밖으로 올린다.** function dispose(value) + -- (1) 소유권 가드 — 값 종류와 무관하다(`elementOwner` 조회는 타입을 안 봄) + if elementOwner:GetStrong(value) ~= nil then + error("dispose: 이 값은 아직 트리가 살아있길 요구 중임 — 먼저 Remove/Extract 할 것") + end + -- (2) [`H-43`] Slot도 Instance도 아닌 값이 백엔드로 그냥 흘러가지 않게 if isSlot(value) then - -- 위 elementOwner 기반 판정 재사용 — 요구 중이면 error, 아니면 재귀 파괴 - ... + destroySlotTree(value) -- 재귀 파괴 + elseif isInst(value) then + nativeDispose(value) -- 아래 주입 op else - nativeDispose(value) -- 아래 주입 op + error("dispose: 이 백엔드가 파괴할 수 없는 값") end end ``` @@ -3088,9 +3528,44 @@ Slot-in-Slot으로 `T = Instance | Slot`가 허용되면서, `:List` 실제 요소를 차지함. 원래 `candidateIndex = pos + 1`(고정 +1)로만 커밋했다면, `updateFn`이 `index`를 LayoutOrder 계산에 쓰는데 어떤 아이템이 nested Slot(Length=3)을 반환할 때 다음 아이템의 `index`가 3만큼 안 건너뛰고 -1만 건너뛰어 물리적으로 겹치는 LayoutOrder 범위가 나옴 — **의도된 동작으로 -확정, 위 "구현" 절의 `reconcile` 의사코드에 이미 반영됨**(`pos = candidateIndex -- 1 + (if isSlot(result) then result.Length:Get() else 1)`). +1만 건너뛰어 물리적으로 겹치는 LayoutOrder 범위가 나옴 — **의도 자체는 확정.** + +**⭐ [전면 정정, 2026-08-24 6라운드 손 트레이싱 `H-2`] 그 의도를 구현하던 +방식이 두 군데 틀려서 다시 썼다.** 옛 의사코드는 +`pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1)` +였는데: + +1. **같은 변수를 `_elements` 배열 인덱스로도 썼다.** `pos`는 물리 리프 개수 + 기준인데 `_elements`는 **중첩 Slot 하나당 한 칸**이다 — 두 좌표계가 한 + 변수에 겹쳐 있었고, 그 값이 그대로 `rawAdd`/`rawMove`의 index 인자로 + 갔다. 첫 아이템이 중첩 Slot이면 `pos = 1 - 1 + 0 = 0`이 되어 + `table.insert(self._elements, 0, S)`가 된다 — **Luau에선 에러도 안 난다** + (실측: `table.insert(t, 0, x)`는 `t[0] = x`, `#t` 불변). 그러면 + `ipairs`로 도는 모든 walk(실체화·마운트·파괴·언마운트)가 그 요소에 + **영원히 안 닿고**, 그 서브트리는 gcconn 때문에 GC도 안 되어 조용히 + 영구 누수가 된다. +2. **`.Length`를 읽는 시점이 틀렸다.** `updateFn`이 반환하는 중첩 Slot은 + 정의상 아직 어디에도 마운트 안 된 것이고(이미 마운트됐으면 `rawAdd`의 + `claimOwner`가 error), `Length`를 갱신하는 주체는 `recompute`뿐이며 + 그건 실체화 시점에야 돈다 — 즉 **그 시점 `.Length`는 항상 `0`**이라 + `isSlot(result)` 분기는 언제나 `+ 0`이었다. 애초에 아무것도 반영하지 + 못했다. + +**확정된 해법**(사용자: *"offsetAt 으로 구함 → 기본적으로 activation 자체는 +순차로 일어나므로 length 자체는 확정되는게 맞을것임 … 부모 슬롯의 offset 은 +먼저 setOffsetSource 되므로 처리가 됨"*): + +- **배열 자리와 물리 위치를 분리한다.** 배열 자리는 `slotPos`(생존 아이템마다 + 정확히 +1), `updateFn`에 넘기는 `index`는 **`Dispatch.getOffsetAt`에서 + 뽑는다**(위 "구현" 절의 `reconcile`). +- **그게 성립하려면 실체화가 순차여야 하고, 그래서 `rawAdd`가 마운트 전에도 + 부기를 하도록 같이 바꿨다** — 옛 `rawAdd`는 `_mounted`가 거짓이면 부기까지 + 통째로 건너뛰어서, 최초 population 동안 `bk.lengthList`가 비어 있었다. + 이제 `_mounted`는 물리 인스턴스 유무만 가리고 `native*`만 가린다(위 + "`isMounted` 이중 추적 분리" 절의 3상태). +- 그 결과 **첫 사이클과 이후 사이클의 `index`가 같아진다** — 옛 방식은 + 같은 데이터인데도 첫 프레임만 다른 값을 줬다(LayoutOrder 계산이 첫 + 프레임에 틀렸다). **남는 캐비엇 — `index`는 여전히 raw 스냅샷이라, nested Slot의 Length가 outer `:List`의 reconcile 없이 나중에 바뀌면 그 이후 형제들의 `index`는 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index 08db352..806b6ae 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -554,7 +554,26 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"( 안 늘어남(노드 1개). 구현은 `:With(...)`가 이미 하는 "구독 목록 확장" 로직을 Compute 노드 생성 시점에 그대로 적용하는 것뿐 — 새 메커니즘 아님. -- **`Effect(fn, ...)`/`state:Observer(fn, ...)`류 trailing-args 확장은 +- **⭐⭐ [부분 역전, 2026-08-24 반영 — 근거는 5라운드 `C-6`, 발견은 6라운드 + 손 트레이싱 `H-13`] `Effect(fn, ...deps)`는 이제 **허용**된다. `Observer`만 + 기각으로 남는다.** 아래 문단은 그 역전 **이전** 서술이고, `base/effect-plan.md`가 + 이미 2026-08-21에 뒤집어둔 것을 이 문서가 반영하지 않아 **두 base 문서가 + 정반대를 말하고 있었다.** 이 문서가 반응형 코어의 정본이라 구현자가 + `Effect` 표면을 짜기 전에 반드시 읽는 자리이고, 그대로 두면 5라운드가 닫은 + 갭(**`Ref`는 `:With`로 못 합치므로 `Effect`의 의존성이 될 방법이 아예 없다**)이 + 되돌아온다. + - **역전 근거**(`effect-plan.md`): *"각 의존성에 구독을 따로 걸면 **합치는 + 노드 자체가 안 생긴다** — 감출 비용이 애초에 없다."* 즉 아래 문단의 전제 + ("의존성이 둘 이상이면 합칠 별도 노드가 필요하다")가 `Effect`에 대해서는 + 성립하지 않는다. `Effect`는 파생값을 안 만드는 leaf라 각 dep에 독립 + 구독을 걸면 그만이다. + - **`state:Observer(fn, ...)`는 기각을 유지한다. 다만 근거를 새로 쓴다** + (옛 근거는 `Effect`와 공유하던 것이라 지금은 비어 있다, 사용자 확정 + 2026-08-24): **`Observer`는 리시버 State 하나에 붙는 구독이고, 여럿을 + 엮는 건 `Effect`가 대신한다.** 즉 역할 분담이지 비용 은폐 회피가 아니다. + - **아래 "일반 원칙" 항목도 `:Compute` 한정으로 좁힌다** — 그 항목 자신의 + 정정 문단이 소스. +- **(역전 전 원문) `Effect(fn, ...)`/`state:Observer(fn, ...)`류 trailing-args 확장은 기각 — 여기선 진짜 새 노드가 생기기 때문.** Effect/Observer는 Compute와 달리 **자기 자신이 결과를 담는 State 노드가 아님**(파생값을 안 만드는 순수 leaf 소비자, 위 "독립 프리미티브 vs 파생 데이터" 분류에서도 @@ -572,7 +591,13 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"( - **일반 원칙으로 정리**: "trailing args sugar는 그게 정말 무료일 때만 붙인다 — 호출부가 이미 만들어야 하는 노드에 엣지만 얹는 경우(Compute)엔 sugar, 없던 노드를 새로 만들어야 하는 경우(Effect/Observer의 다중 - 의존성 병합)엔 sugar 없이 `:With`를 명시적으로 남긴다." `quadnomicon` + 의존성 병합)엔 sugar 없이 `:With`를 명시적으로 남긴다." + **⚠️ [범위 축소, 2026-08-24 `H-13`] 이 원칙의 적용 범위는 `:Compute` 계열과 + "정말 새 노드가 생기는" 자리로 좁힌다.** 괄호 안의 `Effect` 예시는 틀린 + 것으로 드러났다 — `Effect`의 다중 의존성은 각 dep에 독립 구독을 걸 뿐 + **합치는 노드를 안 만들므로** 이 원칙의 대상이 아니다(위 역전 배너). + 원칙 자체("숨겨지는 비용이 있는가")는 그대로 유효하고, 바뀐 건 *어디에 + 비용이 있는가*에 대한 사실 판단이다. `quadnomicon` 에세이 후보로 좋음(`research/documentation-content-map.md` 6번 항목 다음에 추가) — "왜 Compute만 여러 deps를 편하게 받고 Effect/Observer는 안 그런가"가 겉보기엔 비일관적으로 보이지만 실제로는 "숨겨지는 비용이 @@ -1035,6 +1060,30 @@ override할 자리를 구조적으로 열어두는 것.) 선호 — 포인터 해싱 비용만 들고 값 자체엔 부작용 없음. rbvm의 `getNamespaceOf`류가 비슷한 외부 weak-table 인덱싱을 씀 (`base/lifecycle-pattern.md` 참고). +- **⭐⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-23` — 실측] 전파 루프는 + 구독자 집합을 **스냅샷으로 복사한 뒤** 돈다.** Lua/Luau에서 순회 중 + **기존 키의 값 변경은 안전하지만 새 키 추가는 미정의**인데, **그 순회 도중 + 같은 테이블에 새 키가 추가되는 정상 경로가 있다.** + - **실측(로컬 `luau` 0.734)**: 구독자 8개짜리 집합을 순회하며 첫 콜백에서 + 새 구독자 1개를 등록했더니 `obs8 fired=2` / `obs4 fired=2` / + `obs1 fired=2, obs2 fired=0`처럼 **실행마다 결과가 달랐다**(어떤 실행은 + 깨끗했다). `pairs()`로 명시해도 같았고 **크래시는 안 났다** — 즉 터져서 + 금방 잡히는 종류가 아니라 **간헐적으로 한 Observer가 통째로 누락되는** + 종류다. + - **가상의 오용이 아니라 문서가 권장하는 조합에서 나온다**: + `slot:List(store.items, function(...) return Row { Text = store.items:Compute(...) } end)` + — `store.items:Set(...)` → 전파 루프 시작 → 그중 `_listObserver`가 + `reconcile` → `updateFn` → `Dispatch.drive` → `store.items:Compute(...)`가 + **같은 집합에 새 키를 삽입**한다. 재진입 자체는 이미 정상 경로로 + 인정돼 있다(`Dispatch.process`의 (A)/(B) 분기). + - **`Epoch` 규칙으로는 못 막는다** — dedup은 "같은 emit이 두 번 도착했을 때 + 접는" 것이라 **이중 발화는 접히지만 누락은 안 접힌다.** + - **확정된 계약**: 순회 전에 배열로 스냅샷을 뜨고 그 배열을 돈다. + **이번 파동 중에 붙은 구독자는 다음 파동부터 참여한다.** 스냅샷과 실제 + 발화 사이에 죽은 구독자는 기존 `canExecute` 게이트가 걸러준다(그래서 + 스냅샷이 stale해도 안전하다). 비용은 파동마다 배열 하나. + - **같은 처방이 `Ref.Callbacks`에도 적용된다**(`base/ref-plan.md`) — 그쪽도 + 같은 모양의 순회이고, 같은 날 해시맵 셋으로 바뀌면서 더 분명해졌다. - **인자 없는 `state:Observer()` — "항상 관측" 유틸.** `fn`을 생략하면 내부적으로 no-op 콜백을 쓰는 것으로 취급해, 그냥 "이 State를 계속 능동적으로 관측 상태로 유지"하는 용도로만 씀. 위 "`previous` 인자" @@ -1317,6 +1366,15 @@ ObserverEffectLeafHandler.isHandlable(inst, k, v) = -- 매치돼버려 죽은 코드가 됨(2026-08-14 열두 번째 세션 수정) function ObserverEffectLeafHandler.process(inst, k, v, index) + -- [2026-08-24 6라운드 `H-39`] **말단 핸들러의 배열 자리 부기** — 빠져 + -- 있었다. 이 자리는 물리 리프를 하나도 기여하지 않으므로 짝을 맞춰 `0`. + -- 없으면 `Frame { someObserver, Frame{} }`처럼 leaf가 앞에 오는 배치가 + -- 첫 `recompute`에서 `sourceList[k]가 nil`로 죽는다 + -- (`base/dispatch-core-plan.md`가 확정한, 값이 없는 자리도 짝을 맞춰 `0`을 + -- 등록한다는 규칙). + Dispatch.setOffsetSource(inst, k, None) + Dispatch.setLength(inst, k, 0, inst) + local old = relate:GetStrong(inst, k) if old ~= v then -- 이미 같은 값이 이 자리를 차지 중이면 재바인딩 skip bindLifetime(inst, v) -- Effect는 내부적으로 자기 Observer까지 cascade(`base/effect-plan.md`) diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index 93cd863..3bba40b 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -256,9 +256,19 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 **따라서 "Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 필요한가"라는 질문도 성립 안 함으로 종결** — 두 가지 독립적인 이유로 이중 해소됨. (1) 애초에 그런 경우를 만들지 않기로 확정(위 문단). (2) 설령 -발생해도 State/Source 그래프 구독이 전부 weak-keyed GC-native(명시적 -`dispose()` 호출이 아예 없음, `base/lifecycle-pattern.md`의 GC 위임 원칙 -재사용)라 "같은 걸 두 번 해제"할 행위 자체가 존재하지 않음(GC는 멱등). +발생해도 State/Source 그래프 구독에 **명시적 `dispose()` 호출이 아예 없어서** +(`base/lifecycle-pattern.md`의 GC 위임 원칙 재사용) "같은 걸 두 번 해제"할 +행위 자체가 존재하지 않음(GC는 멱등). + +> **⚠️ [근거 정정, 2026-08-24 6라운드 손 트레이싱 `H-36`]** 위 (2)번은 원래 +> *"그래프 구독이 **전부** weak-keyed GC-native"*라고 적혀 있었다. 그 명제는 +> `base/source-state-plan.md`가 **스스로 미확정이라고 선언한 항목**(중간 +> State가 살아남는가 — 구독 엣지의 방향성, 상류가 strong인지 weak인지)이라 +> 근거로 쓸 수 없다. 사용자가 지목한 해법 방향("하류로 weak, 상류로 strong") +> 으로 닫히면 그래프는 더 이상 "전부 weak"가 아니게 된다. +> **결론(이중 해제 불가)은 안 뒤집힌다** — (1)번만으로도 성립하고, (2)번도 +> "해제 호출 자체가 없다"로 다시 쓰면 방향성과 무관하게 참이다. 그 미해결이 +> 닫힐 때 이 문단을 같이 훑을 것. ## Store가 담을 수 없는 값 — Modifier diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index f1aa310..9f71304 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -155,9 +155,23 @@ local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[k]: true}}} — -- TagHandler 자신은 `.priority` 없음(직접 등록 안 됨) — 아래 "패키지 배치" 절의 -- `TagFallbackHandler = { priority = HANDLER_PRIORITY_FALLBACK, isHandlable = TagHandler.isHandlable, -- process = TagHandler.process }`가 실제로 등록되는 얇은 래퍼(2026-08-14 열두 번째 세션 정정) -TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용 +-- [정정, 2026-08-24 6라운드 손 트레이싱 `H-52`] **`type(k) == "number"` 가드 추가.** +-- 주석은 "array-part 전용"이라 말하면서 실제 판정은 값만 봤다. `RefLeafHandler`가 +-- 2026-08-18에 정확히 같은 버그를 고친 전례가 있다 — 빠지면 named 자리로 흘러온 +-- 값을 잡으려는 `HANDLER_PRIORITY_FALLBACK` 가드가 죽은 코드가 된다. +TagHandler.isHandlable(inst, k, v) = (type(k) == "number" and isTag(v)) function TagHandler.process(inst, k, v, index) + -- [2026-08-24 6라운드 `H-39`] **말단 핸들러의 배열 자리 부기** — 빠져 있었다. + -- `Tag`는 물리 리프를 하나도 기여하지 않으므로 짝을 맞춰 `0`. 없으면 + -- `Frame { Tag("card"), TextLabel { … } }`처럼 Tag를 자식보다 앞에 두는 + -- **아주 흔한 배치**가 첫 `recompute`에서 `sourceList[k]가 nil`로 죽는다 + -- (`base/dispatch-core-plan.md`의 등록 책임 절). 그룹 `Attribute`와 함께 + -- "Length/Offset 비참여 카테고리"로 재정의하는 갈래도 있었으나 `bk.N`의 + -- 의미가 바뀌어 파급이 커서 기각(사용자 확정 2026-08-24). + Dispatch.setOffsetSource(inst, k, None) + Dispatch.setLength(inst, k, 0, inst) + local added = {} for name in v:Names() do local holders = tagNameMap:GetStrong(inst, name) diff --git a/.claude/base/tween-plan.md b/.claude/base/tween-plan.md index 4c847c8..8515747 100644 --- a/.claude/base/tween-plan.md +++ b/.claude/base/tween-plan.md @@ -466,6 +466,14 @@ tween:Mapped(fn: (T) -> U): Tween -- opts를 clone하고 Value만 fn(Value) - **타입이 안전하게 성립한다** — `Tween`는 immutable raw 값이고 `Value` 외의 필드는 값 타입과 무관한 옵션(`Time`/`Style`/…)이라, `Value`만 `U`로 바꾼 `Tween`를 만드는 건 타입 레벨에서 깨끗하다. + - **⚠️ [단서, 2026-08-24 6라운드 손 트레이싱 `H-24` — 실측] 단 선언 방식이 + 갈린다.** 이 시그니처는 `base/typing-limits.md` 1번이 지적한 재귀 제네릭 + 누수(`Foo` 안에서 `-> Foo`)와 **글자 그대로 같은 모양**이라, + 인라인 제네릭 메소드로 선언하면 `luau-analyze`가 **진단 없이 조용히 + 통과**시킨다(`Tween`의 `.Value`를 `number`에 넣어도 안 잡힘). + **구현 시 `typeof(named function)` 스타일(③)로 선언할 것** — 그러면 정상적으로 + 잡힌다. 위 "타입이 안전하게 성립한다"는 *의미론상* 맞지만 *체커가 지켜준다*는 + 뜻은 아니다. - **`Tween`가 immutable이라는 기존 확정과 일관** — `:Mapped`는 원본을 안 건드리고 `table.clone` 후 `Value`만 교체해 `Tween(opts)`로 다시 만든다(`Tag`/`Modifier`의 clone 체이닝과 같은 계열). diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index f087cfd..981511d 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -241,8 +241,33 @@ self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T | `state:Compute(fn)` | `typeof(named fn)`(③) | 콜백 파라미터 명시 주석 필요 | ✅ 무주석이어도 안전(다운스트림) | | `state:With(...)` | 인라인 + 쪼개기 | 쪼개기로 해결(이형 dep 포함) | ❌ 명시 바인딩 필요 | | `state:Apply(factory)` | 인라인 | factory 파라미터 주석 필요 | ❌ 명시 바인딩 필요 | -| `Effect(fn, state)` | — | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) | +| `Effect(fn, ...deps)` | — | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) | | `state:Observer(fn)` | — | 해당 없음(로컬 제네릭 없음) | 해당 없음(`EffectHandle` 반환) | +| `tween:Mapped(fn)` | 인라인 제네릭 메소드 | — | ❌ **조용히 통과**(아래 `H-24`) | +| `tween:Mapped(fn)` | `typeof(named fn)`(③) | 콜백 파라미터 명시 주석 필요 | ✅ 안전 | + +**⭐ [2026-08-24 추가, 6라운드 손 트레이싱 `H-24` — 실측] `Tween:Mapped`가 +이 표에서 빠져 있었다.** 확정된 시그니처 `tween:Mapped(fn: (T) -> U): Tween`는 +위 1번이 *"이것만"* 문제라고 못박은 모양(`Foo` 안에서 `-> Foo`)과 **글자 +그대로 같은데**, 이 문서도 `base/tween-plan.md`도 그 사실을 몰랐다(양쪽 grep 0건). + +실측: +```lua +--!strict +export type Tween = { Value: T, Mapped: (self: Tween, fn: (T) -> U) -> Tween } +local t = (nil :: any) :: Tween +local mapped = t:Mapped(function(x: number) return tostring(x) end) +local wrong: number = mapped.Value -- Tween.Value는 string → 에러여야 정상 +``` +`luau-analyze` → **진단 0건**(조용히 통과). 같은 검사를 ③(`typeof(named function)` +선언)으로 바꾸면 `TypeError: Expected this to be 'number', but got 'string'`으로 +**정상적으로 잡힌다** — 즉 **기존 완화책이 그대로 통하는데 아무도 적용을 +지시하지 않고 있었다.** 새 설계 결정은 필요 없다. + +(§6의 `type function`을 거친 값과는 다른 문제다 — `Mapped`는 `type function`을 +안 거치는 순수 제네릭 메소드다. 같은 각도로 최근 확정 표면을 훑어봤고 +`state:Gate(setup)`/`EpochMap`/`Effect`는 **전부 이 패턴이 아니라 무관**했다 — +셋 다 제네릭 self를 다른 타입 인자로 반환하지 않는다.) `state:With(...)`/`state:Apply(factory)`도 원리상 ③으로 같은 이득을 받을 것으로 예상되나(둘 다 `Compute`와 같은 "재귀 자기 반환" 모양), diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 3c13a00..aeec802 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -9,7 +9,8 @@ > props 순회가 **단일 일반화 `for`**로 정정되면서, 두 루프 버전을 검증하던 > `01`이 낡아 `done/` → `rewrite-required/` 이동(검증 대상인 순서 계약 > 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이 -> 없는 실측 항목(`R-11`의 `table.insert` 구멍 재사용, 중간 State GC)을 +> 없는 실측 항목(중간 State GC — **[2026-08-24]** 같이 나열돼 있던 `R-11`의 +> `table.insert` 구멍 재사용은 6라운드 `H-7`로 **전제가 사라져 폐기**됐다)을 > 여기 모은다. 직전 갱신은 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad` > 버전 패턴 체크 + `AddPlugin` 체이닝) 검증용 `23` 신규 추가 → > `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성. @@ -141,7 +142,7 @@ | 파일 | 확인된 것 | |---|---| -| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | +| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 **당시의** Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인. **[2026-08-24]** 그 대비의 한쪽(Ref 콜백)은 6라운드 `H-7`로 **해시맵 셋**이 되어 사라졌지만, 이 스파이크가 실제로 확인한 것(**일반 Lua 테이블에서 `nil` 구멍과 `None` 채움의 거동 차이**)은 그대로 유효하다 — `sourceList`/`flattened`처럼 순서가 중요한 배열이 여전히 그 결론 위에 선다 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 | | `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 | @@ -156,7 +157,7 @@ |---|---| | `08-type-source-satisfies-state` | ✅ 핵심 질문(Source⊇State 구조적 서브타이핑) 통과. 잔여 케이스(자기 이름을 다른 인자로 재귀 참조)는 **[2026-08-13 13차 세션] Luau 현 한계로 확정** — quad가 풀 대상 아님, `base/typing-limits.md` 1번 | | `09-type-modifier-overridden-subtype` | ✅ 통과 — 문서가 우려한 `FrameModifier`↔`GuiObjectModifier` 서브타입 깨짐이 그대로 재현, fallback(`any`)은 정상 | -| `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로) | +| `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로). **[2026-08-24 `H-54`] 단 스파이크 자신의 주석이 실제 결과와 어긋난다** — *"이건 당연히 통과해야 함"*이라 적어둔 동질 대조군(line 41-43)이 실제로는 에러를 낸다(`AttributeKey(name)`이 문맥에서 `T`를 못 추론해 `unknown`으로 남음). 오히려 "왜 진짜 테스트 대상이 조용히 통과하는지(= narrowing이 아예 안 일어남)"를 설명해주는 정합적 결과라 **이 총론은 그대로 유효**하고, 근거 라인만 다르다 — 재작성 시 참고 | | `13-type-ref-preref-subtype` | **[2026-08-19 재작성]** ✅ 통과 — `PreRef`/`PostRef` 둘 다 `Ref`를 구조적으로 만족(음성 대조군도 정확히 에러). 런타임 B섹션은 `22`로 분리(A의 더미 스텁이 B 실행을 막던 문제 해결) | | `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 | | `16-type-store-key-typefunction` | **[2026-08-15]** ✅ 통과 — 원인은 설계 문제가 아니라 `types.newfunction` API 버전 드리프트(배열이 아니라 `{head=...}` 레코드). `ProcessStoreType`이 정확히 `{ty: Source, count: Source}` 구조를 만족, 음성 대조군 4건(틀린 Get/Set 타입 2건, 존재하지 않는 메소드) 전부 정확히 에러. 근거: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | @@ -205,6 +206,6 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치 | 검증할 것 | 왜 | 출처 | |---|---|---| -| `table.insert`가 배열 중간의 구멍을 재사용하는가 | `Ref` 콜백 배열이 죽은 슬롯을 `None`으로 두는 설계의 전제. 재사용하지 않으면 슬롯이 무한 증가한다 | QA 4라운드 `R-11`, `base/ref-plan.md` | +| ~~`table.insert`가 배열 중간의 구멍을 재사용하는가~~ **[2026-08-24 폐기]** | **전제 자체가 없어졌다** — 6라운드 `H-7`로 `Ref.Callbacks`가 배열에서 `{[callback\|thread] = true}` **해시맵 셋**으로 바뀌었고, 해시맵엔 border 개념도 구멍도 없다(`base/ref-plan.md`). 이 스파이크는 만들지 말 것 | QA 4라운드 `R-11`(폐기), 6라운드 `H-7` | | 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M3 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 3번 | | `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` | diff --git a/.claude/qa-request/pre-implementation-handtrace-round6-followup.md b/.claude/qa-request/pre-implementation-handtrace-round6-followup.md new file mode 100644 index 0000000..7be36fe --- /dev/null +++ b/.claude/qa-request/pre-implementation-handtrace-round6-followup.md @@ -0,0 +1,375 @@ +# 6라운드 손 트레이싱 — 처리 결과 + +**상태**: **[2026-08-24] 전량 처리·반영 완료.** 사용자와 대화형으로 하나씩 +결정했고(`H-1`~`H-54`), 그 결정을 각 `base/` 문서에 **전부 반영했다**. +이 문서는 **결정과 근거의 기록**이고, 지금 유효한 설계는 항상 `base/`가 소스다. + +원 발견 보고는 `pre-implementation-handtrace-round6.md`. + +**반영된 문서**: `slot-plan.md` / `dispatch-core-plan.md` / `source-state-plan.md` / +`effect-plan.md` / `ref-plan.md` / `gate-plan.md` / `blocker-plan.md` / +`debounce-throttle-plan.md` / `tag-plan.md` / `attribute-plan.md` / +`modifier-plan.md` / `onchange-plan.md` / `tween-plan.md` / `typing-limits.md` / +`fallback-plan.md` / `quad-types-plan.md` / `store-plan.md` / +`module-lifecycle-plan.md` / `architecture.md`, 그리고 루트 `ROADMAP.md`와 +`.claude/todos.md` / `luau-test/STATUS.md`. + +## A. 처리 완료 (결정됨) + +### `H-39` — 말단 핸들러 4종의 `setLength`/`setOffsetSource` 미등록 +**결정: 넷 다 똑같이 등록**(갈래 1 + 2-(a)). `TagHandler`/ +`AttributeGroupHandler`/`RefLeafHandler`/`ObserverEffectLeafHandler`의 +`process` 맨 앞에서 `setOffsetSource(inst,k,None)` → `setLength(inst,k,0)`. +`AttributeGroupHandler`도 예외로 두지 않는다 — Tag/Attribute를 "Length/Offset +비참여 카테고리"로 재정의하면 `bk.N`의 의미가 바뀌어 파급이 크다. + +### `H-25` — `New(): Quad`가 닫힌 타입 +**결정: `quad-types`의 `Quad`를 마일스톤마다 갱신**(갈래 2). M2가 +`Dispatch` 필드와 그 타입 재수출을 `quad-types`에 추가하는 걸 `ROADMAP.md` +M2 체크리스트 항목으로 명시한다. quad-roblox(M5)도 같은 경로로 본다. + +### `H-1` — `keyIndex`가 사이클 도중 stale +**결정: 사용자 역제안 채택 — 역방향 인덱스 맵을 raw 층에 둔다.** +- `:List`는 `k → realElem`만 들고, 배열 인덱스를 직접 들지 않는다. +- **`slot._elemIndex`** 신설(`realElem → index`), `_elements`와 같은 수명. + 자리를 밀고 당기는 모든 연산(`spliceArraysUp`/`Down`, `rawMove`, + `rawSwap`, `rawReplace`, `rawAdd`)이 같이 갱신한다. detach된 요소는 + `_elements` 밖이므로 맵에서도 빠진다. +- **raw\*의 index 시그니처는 유지**(5라운드 결정 그대로). 대신 + `indexOfRaw(self, element)`가 선형 탐색이 아니라 이 맵 조회가 되고, + "폴백이 아니라 기본 경로"로 승격된다. +- 근거: 층 분리가 오히려 깨끗해지고(raw\*가 `:List` 클로저를 안 봄), + 시프트는 이미 O(n)이라 맵 갱신이 점근 비용을 안 올리며, 조회가 O(1)이 + 된다. `claimOwnerAt`이 같은 요소의 이중 배치를 이미 error로 막으므로 + 키 유일성이 보장된다. +- **부수 효과**: `:List`의 `keyIndex`가 인덱스 맵에서 **단순 키 집합**으로 + 내려앉는다(소멸 루프의 "직전 사이클 키" 용도만 남음) → `H-16`이 같이 + 사라진다. + +### `H-2` — `pos`(리프 카운터)를 배열 인덱스로 겸용 +**결정: 카운터를 분리하고, `updateFn`의 `index`는 `getOffsetAt`으로 구한다.** +사용자 원 디자인 초안대로 — activation이 순차이므로 그 시점 length는 +확정돼 있다. 이를 성립시키기 위해 초기 population 경로를 같이 고친다: +- **`_mounted`는 "물리 인스턴스가 있는가"만 가리킨다.** `rawAdd`의 + 얼리리턴을 **`native*` 호출만 가리는 분기**로 좁히고, 부기 + (`spliceArraysUp`/`setOffsetSource`/`setLength`/중첩이면 실체화)는 + 마운트 전에도 수행한다. +- **`slot._physicalTarget` 신설** — `materializeSlotTree` 머리에서 저장. + `setLength`의 앵커/`bk.observers` 앵커가 마운트 전에도 필요하기 때문. + `_mountedInst`는 지금대로 "마운트됨"만 뜻한다(미실체화/실체화/마운트 3상태). +- **`_listed`면 `materializeSlotTree`의 자식 실체화 루프를 건너뛴다** — + `activateList`가 이미 전부 등록하므로 이중 등록이 된다. `:List`와 CRUD가 + 상호배타라 정확하다. +- **`blocker:On()`을 `activateList` 앞으로 옮긴다** — 5라운드 `AS-5`의 + 근거("그 안에선 게이팅할 recompute 자체가 안 일어난다")가 사라지므로. + population 중 아이템마다 recompute가 도는 O(n²)를 막는다. + `_baseObserver` 생성은 여전히 `blocker:On()` 뒤. + +### `H-5` — `spliceArraysUp`이 `bk.N`을 먼저 올려 여는 창 +**결정: `spliceArraysUp`이 `lengthList[index]`에 자리표시자를 채운다.** +`sourceList`가 `None`으로 채워지는 것과 대칭. 동기 재진입(`ChildAdded`)이 +그 창에서 `sum += nil`로 터지던 것이 닫힌다. + +### `H-6` — `unmountSlotTree`의 미정의 `physicalTarget` + offset 어긋남 +**결정: `physicalTarget`은 `slot._mountedInst`를 로컬로 먼저 뽑아 쓴다** +(지우는 대입보다 위에서 읽는 순서 유지). 곁가지는 **역순 순회로 변경** — +뒤에서부터 빼면 앞쪽 offset이 안 밀려 매번 정확하다. 백엔드 계약을 +좁히지 않아도 닫힌다. + +### `H-7` — `Effect`의 `Ref` 의존성을 뗄 방법이 없음 +**결정: `Ref`에 콜백 해제 경로를 추가**(갈래 a). + +### (파생) `Ref.Callbacks`를 해시맵 셋으로 +**결정: `{[callback/thread] = true}`로 변경**(사용자 제안). 해제가 O(1)이 +되어 `H-7`이 요구하는 연산과 맞고, `type(v) == "thread"` 분기는 키 타입 +검사로 그대로 성립한다. +- **같은 콜백/thread의 중복 등록은 dedup을 계약화**(여러 번 등록해도 한 번 + 발화). 해제가 `t[fn] = nil` 하나로 끝나야 하기 때문. +- **`ref-plan.md`의 ⚠️ 실측 대상(구멍 있는 테이블에서 `#t` border가 항상 + 첫 `nil`인가 — M0/M8 스파이크)이 폐기된다** — 해시맵엔 border 개념이 없다. +- `table.insert` 재사용 근거(`R-11`)와 "`None`이 아니라 `nil`" 논의도 이 + 자료구조 변경에 맞춰 다시 쓴다. + +### `H-23` — 전파 도중 새 구독자가 붙으면 누락/이중 발화 (실측) +**결정: 순회 전 스냅샷**(갈래 1). State 구독자 집합과 `Ref.Callbacks` +**둘 다** 배열로 복사한 뒤 그 배열을 순회한다. "이번 파동 중에 붙은 +구독자는 다음 파동부터 참여"를 계약으로 명시. 스냅샷 사이에 죽은 구독자는 +기존 `canExecute` 게이트가 걸러준다. + +### `H-11` — `Effect`의 leaf 사망 cleanup을 부르는 배선이 없음 +**결정(1번 소항목): `EffectHandle` 쪽이 자기 `bindLifetime` 직후에 +`Destroying`을 건다.** `bindLifetime`이 값 타입을 가리지 않는다는 기존 +원칙을 유지한다. 2·3번 소항목(=`unbindLifetime`이 cleanup을 부르는가 / +cleanup을 어디 보관하는가)은 아직 미확정. + +### `H-12` — `rawRemove`/`rawUnmount`/`rawDetach`에 마운트 전 분기 부재 +**결정: `native*` 호출(및 `_mountedInst`를 쓰는 줄)만 `_mounted`로 가드**해 +`rawAdd`와 대칭을 맞춘다. `H-2`의 "부기는 마운트 전에도 한다"와 같은 규칙을 +네 함수가 공유한다. + +### `H-13` — `Effect(fn, ...deps)` 역전이 정본 문서에 미반영 +**결정: `Observer`는 기각 유지하되 근거를 새로 쓴다.** `source-state-plan.md`가 승격해둔 일반 원칙(없던 노드를 새로 +만들어야 하면 sugar를 안 붙인다)은 **`:Compute` 한정**으로 +좁히고, `Effect`의 역전(`C-6`)을 그 문서에 반영한다. `Observer` 기각의 새 근거는 +"Observer는 리시버 State 하나에 붙는 구독이고, 여럿을 엮는 건 `Effect`가 대신한다". + +### `H-14` — `Effect`의 `fn` 시그니처 +**결정: `Effect(fn: (self: Effect) -> (() -> ())?, ...deps)`.** +- `fn`은 **`self`(Effect 핸들) 하나만 받는다.** `...deps`는 **의존성 선언일 뿐 + `fn`에 안 넘어간다** — dep 값은 사용자가 클로저로 직접 읽는다. +- 사용자 논거: *"Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게 + 맞아보이고, Compute 랑은 완전 다름. 난 그냥 `...deps` 넣는게 compute 처럼 + 그냥 넣을 수 있게 하자는거였을 뿐임."* +- 그 따름정리로 **`Ref` dep의 `.Value` vs State dep의 `:Get()` 비대칭 문제가 + 사라진다**(아무것도 안 넘기므로). 문서의 *"trailing deps를 lazy 위치 인자로 + 콜백에 넘긴다"*와 옛 `fn(state)` 표기는 **둘 다 삭제**. + +### `H-17` — `Dispatch.drive`의 Blocker 범위 +**결정: "`drive` 전체"로 넓혀 적고 계약화.** `F-4-1`의 단일 일반화 `for`에선 +배열 파트 종료 시점이 관측되지 않고 `postRefList` 소비는 애초에 해시 파트보다 +뒤이므로, 실제 범위를 문장에 맞춘다. 추가로 **`PostRef` 콜백은 게이트가 켜진 +채로 실행되며, 그 안에서 일어난 Length 변화는 직후의 명시적 `recompute` 한 +번으로 정리된다**를 계약으로 명시(지금은 우연히 맞고 있을 뿐). + +### `H-18` + `H-45` — attribute 이름을 그룹 사이에서 옮길 때 emit 순서 의존 +**결정: UB로 못박는다.** 사용자 논거: *"state 로 그 경로가 열리는데 +어떤 방식으로 process 를 걸어도 외부에 바꿀지 말 지 알 방법이 없음. 외부에서 +먼저 retract 가 나도록 유도하는게 아닌 한 알 방법이 없어보임. 애초에 싱크이기도 +하고, 또 이걸 허용하면 process/retract 계약의 전체에 대한 예외들이 생기고 +오버 엔지니어링으로 보임."* `H-45`(단건 `AttributeKey` ↔ 그룹)도 같은 결정으로 +닫힌다. + +### `H-19` — `recompute`가 두 번 도는 것 +**결정: `setLength`에 일임하고 `rawAdd`/`rawReplace`의 명시 `recompute` 호출을 +삭제.** "자리의 길이가 바뀌면 `setLength`가 책임지고 `recompute`를 태운다"로 +소스를 하나로 만든다 → `H-3`의 `invalidAfter` 당김도 `setLength` 한 자리에만 +두면 된다. + +### `H-21` — `unwrapElement`가 Instance에서 크래시 +**결정: `isSlot` 가드**(갈래 1). `if isSlot(el) then return el._wrapped or el end +return el`. 래퍼는 항상 Slot이고 `isSlot`은 Brand의 weak-key 조회라 +Instance/userdata/number 전부에 안전하다. + +### `H-22` — 기본 identity `updateFn`이 `KeyGone`을 되돌려 항상 error +**결정: `identityUpdateFn`이 `KeyGone`을 흡수**(갈래 1) — +`if item == KeyGone then return nil end`. 사용자가 직접 쓴 identity에도 같은 +처리가 필요하다는 건 문서로 안내한다. + +### `H-24` — `Tween:Mapped`의 재귀 제네릭 타입 누수 +**결정: 단순 정정.** `typing-limits.md`의 영향 범위에 `Tween:Mapped` 행을 +추가하고, `tween-plan.md`의 `Mapped` 절에 "인라인 제네릭 메소드가 아니라 +`typeof(named function)`으로 선언할 것" 각주. 기존 완화책이 그대로 통한다. + +### `H-26` — 부분 생성 후 예외로 생긴 Instance가 영구히 안 죽음 +**결정: 둘로 나눈다.** +1. **기본 경로(예외가 밖으로 나가 아무것도 안 그려지는 경우)는 서술 정정으로 + 끝낸다.** 같이 **`dispatch-core-plan.md`에서 잔여 부기가 인스턴스 GC로 + 정리된다고 적은 문장을 고친다** — gcconn 불멸성과 양립하지 않는 **틀린 안전망 + 주장**이다. +2. **`Fallback`/`Traceback` 중 생성된 부분 트리는 백로그.** 사용자 판단: + *"의도적으로 error 를 사용하고자 하는 경우 항상 컴포넌트들이 쌓이거든. 이건 + 후행에서 더 다뤄보도록 백로깅해줘. fallback/traceback 자체가 슈거라서, 그 + 때 가서 생각해도 될듯."* + +### `H-29` — 정의 없는 `raw*` 다섯 +**결정: 작성 + `collectLeaves(slot)` 헬퍼 신설.** 재귀 리프 수집 헬퍼를 두고 +`Move`/`Swap`/`Extract`/`Splice`가 공유한다(`native*`의 "빠지는 요소는 반드시 +elements 배열로" 계약을 그대로 지킴). 작성 시 같이 명시할 것: 함께 치환되는 +배열(`lengthList`/`sourceList`/`bk.observers`/`_elemIndex`), `bk.N`은 안 변함, +`recompute`는 `setLength`에 일임(`H-19`). + +### `H-32`/`H-33`/`H-49` — `Debounce`/`Throttle`과 `Gate`의 관계 +**결정: 정책 합성 구조를 확정.** +- **`Gate` 노드에 `Flush`/`Cancel` 표면을 두지 않는다.** 확정된 + `state:Gate(setup)`(State 하나만 반환)이 그대로 유지된다. +- **`Blocker`가 자기 정책을 값으로 낸다 — `blocker:Policy(emit) -> onUpstreamEmit`.** + `state:Block(b)`는 그 위의 얇은 래퍼가 된다. +- **`Debounce`/`Throttle`은 emit을 아예 안 쥔다.** 자기 `Blocker`를 사적으로 + 갖고 **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류는 Blocker에 위임한다: + ```lua + state:Gate(function(emit) + local b = Blocker() + local pass = b:Policy(emit) + return function() -- 상류 emit 도착 (동기) + ...타이머 리셋 / b:On() / b:Off() 시점 판단만... + pass() -- 그 시점 상태로 Blocker가 판정 + end + end) + ``` + 동기 실행이라 같은 호출 안에서 정책이 바꾼 Blocker 상태를 `pass()`가 본다. +- **Blocker는 `Debounce` 설정당 하나가 아니라 적용 핸들당 하나**다 — `Debounce{}` + 커링 결과는 여러 곳에 적용될 수 있으므로 Apply 시점에 생성된다. +- **`pending`은 Blocker의 `HasBlockedEmit`으로 흡수**한다(중복 상태를 안 만든다). + `Trailing=false`는 `OffWithoutEmit()`, `Flush`는 `Off()`로 매핑되어 `H-32`의 + 새는 경로가 구조적으로 사라진다. +- 합성 순서(누가 상류인가)는 손으로 중첩해 표현한다 — **`state:Gate(p1, p2, ...)` + 가변인자 슈가는 두지 않는다.** +- **기각된 대안(기록)**: "블로커를 바깥에 중첩"(`blocker:Policy(debounceOnEmit)`)은 + unblock 시 흘러나온 emit이 디바운스 창을 새로 시작시켜 창이 안 끝난다. + +### `H-38` — reconcile의 예외 원자성 +**결정: 키 집합을 증분적으로 갱신**(마지막 일괄 교체 폐지). `settle` 직후에 +키를 넣고 소멸 루프에서 뺀다. 계약은 "reconcile은 원자적이지 않지만 **중단된 +지점까지는 정합하다**". `H-31`(duplicate key)의 선행 검증 패스와 함께 그 실패 +모양을 닫는다. + +### `H-39`~`H-42`, `H-46`, `H-52` (4차) +- **`H-40`** — 빠진 가드 넷을 전부 의사코드에 반영. 타입 검증은 **화이트리스트로 + 뒤집되 base가 `T`를 아는 방식이 아니라 백엔드 주입으로** 한다: **`isInst`를 + 백엔드 계약(주입 op)에 추가**하고 quad-roblox가 `typeof(v) == "Instance"`로 + 채운다. 판정 순서는 `isSlot` → 구조 처리 / `isState` → 래퍼 Slot으로 풀어 + 재귀 / 그 외 → `isInst(v)`가 거짓이면 error. **관문은 `wrapElement` 하나** + (공개 CRUD와 `:List`의 `settle`이 둘 다 통과하므로 State가 나중에 이상한 값이 + 되는 경우도 같은 자리에서 걸린다). 사용자 논거: 브랜드 기반 판정은 불가 — + *"이제 brand 는 각각 따로 생성되어서 있는지 없는지 보는건 결국 전부 봐야한다는 + 의미"*. + - **파생: `Splice`의 `T | {T}` 기각은 유지하되 근거를 정정한다.** *"근거 정정. + 근데 실질적으로 저 splice 는 T 가 slot/state 일 수도 있어서, isSlot/state 도 + 봐야함. 당연하게도 `Splice(..., comp())` 패턴은 흔할 수 있어서 그래. 그리고 + 여전히 splice 는 소수의 요소를 다루는게 많고, 테이블 생성 비용을 지불할 + 의미가 없다는 근거는 여전해."* +- **`H-42`** — **문서만 정정**. "동기 발화"를 `SignalBehavior.Immediate` 조건부로 + 명시(Deferred에선 이 레이스가 없고 `PreRef`는 무해하게 불필요). 그 뒤의 큰 + 질문(구조 존속)은 **유지**로 결론 — Immediate place가 살아있는 한 필요하고 + 불필요해질 때의 비용이 싸다. +- **`H-46`** — **top-level `Slot.luau` 신설**(다른 값 타입과 대칭, + `slot-plan.md`가 이미 적어둔 파일명과도 일치). `Dispatch/Slot.luau`는 + 핸들러/부기만. +- **`H-52`** — **대칭적으로 가드 추가**(`type(k)=="number"` + 동적 경로 가드), + `RefLeafHandler`가 2026-08-18에 받은 수정과 같은 모양. + +### 일괄 단순 정정 (사용자 확인: "전부 그대로 반영") +`H-3`(캐시 무효화 3규칙을 `setLength`/splice/`_baseObserver`에 실제 배치) · +`H-4`(`bk` 스펙에 `offsetCache`/`invalidAfter` 추가, `0`/`{}` 초기화 명시) · +`H-8`(`_observers` 배열화 + cascade/Subscribe/Unsubscribe 순회) · +`H-9`(`withheld` 스왑도 weak-key로) · `H-10`(`sum` 주석 정정) · +`H-20`(`process(inst, k, v, index)` 표기 통일) · `H-27`(`OnChange`의 `v == nil` +얼리리턴) · `H-28`+`H-43`(`dispose`의 소유권 가드를 `isSlot` 분기 밖으로) · +`H-30`+`H-31`(선행 일괄 검증 패스) · `H-34`+`H-44`(`native*` 계약 보강 — +Roblox 백엔드는 `nativeMove`/`nativeSwap`을 덮어써야 함, ROADMAP M6 줄 갱신) · +`H-35`(`ProcessedModifierHandler` 의사코드 + 색인 두 곳) · `H-36` · `H-37` · +`H-41`(`groupClaimKeys` 배선) · `H-47`~`H-51` · `H-53` · `H-54`. + +## B. 답할 게 없는 항목 + +- **`H-15`** — 2차 패스에서 이미 철회(오탐). +- **`H-16`** — `H-1` 해법의 부수로 소멸(`keyIndex`가 인덱스 맵이 아니게 됨). +- **`H-2`의 크래시 주장 정정** — 3차 패스의 실측(`table.insert(t, 0, x)`는 + Luau에서 안 터짐)대로 "크래시"가 아니라 "조용한 영구 고아"로 본문을 고친다. + 결론과 처방은 그대로. + +## C. 백로그로 넘긴 것 + +- **`H-26`의 2번** — `Fallback`/`Traceback` 중 생성된 부분 트리의 회수. + 그 둘이 슈가라 구현 시점에 다룬다. + +## D. 반영 후 `/code-review high` (2026-08-24) — 7건, 전부 유효 + +반영을 커밋하기 전에 사용자가 `/code-review high`를 돌렸고 **7건이 나왔으며 +전부 유효했다.** 그중 셋(1·2·3)은 **이번 반영이 새로 만든 회귀**다 — 감사자가 +보는 축(코퍼스 정합성)으로는 안 보이고 diff 자체를 읽어야 보이는 종류였다. + +1. **`rawAdd`가 미실체화 가드를 통째로 잃었다**(높음). `H-2` 재작성이 + `if not self._mounted then return index end`를 지우면서 **3상태 중 첫 + 경계에 아무것도 안 뒀다.** `Slot { frameA }` 생성자가 `:Add`를 부르는데 + 그건 `materializeSlotTree`보다 훨씬 먼저라 `slot.Offset`이 `nil`이고, + `setLength` → `gatedRecompute` → `getOffsetAt`의 `ownerKey.Offset:Get()`에서 + 즉시 죽는다. 중첩이면 `bindLifetime(nil, …)`까지 간다. + → **`self._physicalTarget == nil`이면 `_elements`/`_elemIndex`만 갱신하고 + 리턴**하도록 복원. `rawReplace`의 미마운트 분기에도 같은 가드를 넣었다. + 같이 정리된 것: `_elemIndex` 갱신을 `reindexFrom(self, from)` 헬퍼로 떼어 + **실체화 여부와 무관하게 항상** 돌게 했다(`spliceArrays*`는 부기만 담당). +2. **`rawRemove`가 마운트 전 창에서 요소를 안 죽인다**(높음). `H-12`가 + `nativeRemove`를 `_mounted`로 가렸는데 **`nativeRemove`가 곧 파괴**였다 + (백엔드 융합). `else` 분기가 비어 있어 요소가 `_elements`에서 빠지고 + 아무도 안 죽인다 — gcconn 때문에 GC도 안 되므로 **영구 누수**다. + → `else nativeDispose(element)` 추가(`rawReplace`가 이미 하던 대로). +3. **`_listed` 분기가 포탈 재마운트를 깬다**(중간). 재마운트에선 + `activateList`가 `_listActivated` 멱등 가드에 걸려 앵커만 옮기고 리턴하므로, + 자식 루프까지 건너뛰면 보존된 `_elements` 안의 중첩 Slot이 **다시 실체화되지 + 않는다**(`_physicalTarget`이 `nil`인 채 `_mounted`만 켜지고 `_baseObserver`가 + 죽은 옛 target에 매달린다). → 조건을 + **`slot._listed and not slot._listActivated`**로 좁히고, `else` 가지에서 + 재마운트 시 `activateList`(앵커만)를 부른 뒤 루프를 돌게 했다. +4. **`H-11`의 두 결정이 서로 모순**(중간). "`bindLifetime`은 값 타입을 안 + 가린다"를 지키기로 해놓고 "`unbindLifetime`이 `Destroying`을 끊는다"를 + 같이 정했는데, 후자는 정확히 그 분기를 요구한다. 게다가 *"`EffectHandle` + 쪽이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가 실재하지 않는다** + (핸들은 남이 자기를 bind하는 걸 관측 못 한다). `Effect` 바인드 경로가 + 둘이라(leaf 핸들러 / `activateList`의 `_detachCleanup` 직접 바인드) 핸들러 + 층에 둬도 안 덮인다. + → **재결정(사용자, 2026-08-24): `bindLifetime`/`unbindLifetime`이 + `isEffect`를 보고 직접 처리한다.** *"Destroying 자체가 엔진이 아는 요소이기 + 때문에, 엔진이 처리하는 곳에 두긴 해야합니다. 옵져버는 바로 생성되기 + 때문에, bind 상 옵져버 목록을 가져와 자신이 재귀하고, bindLifetime 이 + 처리하는게 나아보입니다."* 근거로 들었던 "게이트는 값 타입을 안 가린다"는 + **인용이 틀렸다** — 그 절은 `canBound` 판정이 두 진입점에서 같다는 얘기지 + 부수 배선 얘기가 아니고, 실제로 그 함수는 이미 `_observers`로 cascade한다. + 의사코드는 `base/lifecycle-pattern.md`에 반영. + - **⭐ 같은 자리에서 사용자가 추가 지적**: `Effect`의 **`Ref` 콜백도 + `canExecute`를 거쳐야 한다.** 해제 경로만으로는 창이 남는다 — + `unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`이 안 + 도는데도 `canExecute`가 거짓이다. **해제는 누수를, 게이팅은 발화를** 막는다. + → `Effect`가 거는 `Ref` 콜백은 본문 맨 앞에서 `canExecute(handle)`를 + 확인하고 거짓이면 리턴한다. 이로써 두 dep 경로가 같은 게이트를 공유한다. + (`H-7` 처리 때 "그 대안은 채택하지 않았다"고 적었던 서술을 정정했다 — + 택일이 아니라 둘 다 필요하다.) +5. **`Block`/`Gate` 동치 표기가 호출 불가**(중간). `state:Gate(b:Policy)`는 + 문법 오류이고 `state:Gate(b.Policy)`는 언바운드 메소드라 `emit`이 `self` + 자리에 들어가 게이트가 영영 안 열린다. + → `state:Gate(function(emit) return b:Policy(emit) end)`로 두 문서 정정. +6. **`settle`의 교체 분기가 새 자리로 안 옮긴다**(낮음). `rawReplace`는 자리를 + 유지하는데 `slotPos`를 무시해서, 값 교체와 리오더가 같은 사이클에 겹치면 그 + 요소만 옛 자리에 남는다. → 교체 후 `idx ~= slotPos`면 `rawMove`. +7. **`prevKeys[key] = true`가 `settle` 뒤에 있어 `H-38` 계약이 약해진다**(낮음). + `settle`이 커밋 후 던지면 그 키가 집합에 안 들어가 다음 사이클 소멸 루프가 + 못 물어 영구 고아가 된다 — `H-38`이 고치려던 바로 그 모양. → `settle` **앞**으로. + +**교훈(기록)**: `conventions.md`가 *"`/code-review`는 감사자를 대체하지 않는다"* +라고 적어둔 그대로였다. 이번엔 감사자를 돌리기 **전에** code-review가 먼저 +돌았는데, 잡힌 7건 중 셋이 "이번 diff가 만든 새 결함"이라 코퍼스 정합성 +각도로는 애초에 안 보이는 것들이었다. + +## E. 반영 후 `quad-doc-auditor` 감사 루프 (2026-08-24) — 9라운드, 34건 + +`/code-review high` 뒤에 감사 루프를 돌렸다. `conventions.md`의 절차대로 +**한 턴에 하나씩**(병렬 금지) 돌리고 **라운드마다 각도를 바꿨으며**, 새 발견이 +**0건인 라운드가 나올 때까지** 반복했다. 라운드별 발견: **5 → 7 → 2 → 2 → 3 → +9 → 2 → 4 → 0**. + +| 라운드 | 각도 | 발견 | +|---|---|---| +| 1 | `base/` 문서들끼리 정합성(옛 주장 잔존) | 5 | +| 2 | 인덱스 레이어(README/ROADMAP/question/todos/STATUS/archive/reference) | 7 | +| 3 | **이번에 새로 쓴 서술 자체**의 내부 모순·인용 검증·정의/사용 일치 | 2 | +| 4 | 이번에 **안 바뀐** 문서가 조용히 틀려졌는가 | 2 | +| 5 | **어휘 추적** — 뜻이 바뀐 낱말을 옛 뜻으로 쓰는 산문 | 3 | +| 6 | `ROADMAP.md`의 **미완료 체크박스**를 구현자 눈으로 | 9 | +| 7 | 6라운드 픽스 검증 + `agents`/`tools`/followup 사각 | 2 | +| 8 | **완결성** — "N개 중 일부만 고친 자리" 세기 | 4 | +| 9 | 수렴 확인 + 전 코퍼스 스윕 | **0** | + +**각도를 바꾼 게 실제로 작동했다.** 가장 많이 잡은 6라운드(9건)는 문서 +정합성이 아니라 *"이 체크박스로 코드를 짜면 무엇이 나오는가"*를 물은 +라운드였고, 그때 `ROADMAP.md`의 미완료 항목들이 대거 stale인 게 드러났다 — +M6엔 폐기된 `pos` 공식이 "확정"으로 남아 있었고, M2엔 접두합 캐시 무효화 +계약이 통째로 없었으며, M8은 `bindLifetime`이 *"둘만 한다"*고 적어 `H-11`의 +Effect 분기와 직접 모순이었다. 말단 핸들러 4종의 부기 의무(`H-39`)는 어느 +체크박스에도 없었다. + +**반복해서 드러난 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 +고쳤다".** 1라운드는 *배너를 달아놓고 그 배너가 부정하는 위쪽 문장을 안 고친* +것(핸드오버 체크리스트 2번), 2라운드는 *`base/` 19개를 바꾸고 `.claude/README.md`를 +한 줄도 안 고친* 것(체크리스트 6번), 7·8라운드는 *그 README를 고칠 때 11개 행 +중 6개만 고치고 다섯을 빠뜨린* 것이었다. 8라운드를 아예 **완결성 축**(새 이름 +하나당 나타나야 할 자리 넷 — 정의 문서/소스 트리/ROADMAP 체크박스/README 행 — +을 열거해 세기)으로 잡은 게 그래서였고, 실제로 4건이 더 나왔다. + +**같이 채운 절차적 공백 둘**: `conventions.md`가 요구하는 +`session/2026-08-24-01-handtrace-round6-resolution.md` 원문과 +`session-summary.md` 항목을 남겼다(설계 결정이 대량으로 오간 세션인데 기록이 +없었다). 그리고 이 감사에서 파생된 새 결정이 셋 있어 `base/`에 함께 반영했다 — +**주입 op `onDestroying(inst, fn)` 신설**(`_bindDestroying` 의사코드를 쓰다 +드러남, base는 `Instance`를 모르므로), **`EffectHandle`의 필드 다섯** +(`_destroyConn`/`_refDeps`/`_refCallbacks` 등), **`quad-types`의 `Quad` 갱신을 +M3/M6/M7/M8/M10에도 항목화**(확정이 "마일스톤마다"였는데 M2에만 있었다). diff --git a/.claude/qa-request/pre-implementation-handtrace-round6.md b/.claude/qa-request/pre-implementation-handtrace-round6.md index fb8f1e1..b5c9862 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round6.md +++ b/.claude/qa-request/pre-implementation-handtrace-round6.md @@ -1,9 +1,11 @@ # 구현 전 손 트레이싱 **6라운드** — 최근 확정분 전수 추적 + 2~4차 전역 패스 -**상태**: **[2026-08-22 작성, 2026-08-23 2·3차 패스, 2026-08-24 4차 패스 추가] 사용자 회신 대기.** -아무것도 고치지 않았다 — `base/`는 한 줄도 안 건드린 상태이고, 아래는 전부 -**발견 보고**다. 판단이 필요한 항목이 섞여 있어서(특히 `H-1`) 임의로 -반영하지 않았다. +**상태**: **[2026-08-24] 회신·반영 완료 — 이 문서는 이제 발견 기록이다.** +작성 시점(2026-08-22~24)엔 아무것도 안 고친 발견 보고였으나, 같은 날 사용자와 +대화형으로 `H-1`~`H-54` 전부를 처리하고 `base/`에 반영했다. +**결정과 근거는 `pre-implementation-handtrace-round6-followup.md`가 소스**이고, +지금 유효한 설계는 항상 `base/`가 소스다 — 아래 본문은 **발견 당시의 서술** +이므로 그대로 믿지 말 것(특히 각 항목의 "갈래"는 선택 전 목록이다). **⭐⭐ [2026-08-23] 3차 패스가 맨 아래 붙어 있다(`H-21`~`H-38`)** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 92c27db..f441985 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -239,16 +239,22 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: `ref-plan.md` "동적 경로로 도착한 PreRef는 런타임에도 명시적으로 에러" 절 바로 아래 "PreRef는 '취소'라는 개념이 없다" 항목 참고. -7. **왜 `Compute(fn, ...)`는 여러 의존성을 편하게 받고 `Effect`/`Observer`는 - 안 받는가** (2026-08-11 세션 원자료, `source-state-plan.md` "`:Compute(fn, - ...)` — 추가 의존성을 trailing args로 직접 받는 sugar" 절) — 겉보기엔 - 비일관적인 API 표면(하나는 React `useMemo`식 trailing deps sugar를 받고, - 다른 둘은 명시적 `:With` 호출을 강제)이 실은 "sugar가 새 노드 생성 비용을 - 감추는가"라는 단일 원칙에서 갈라져 나온다는 게 소재 — `Compute`는 어차피 - 자기 결과를 담을 노드를 만들어야 해서 추가 구독을 얹는 게 공짜지만, - `Effect`/`Observer`는 자기 자신이 State 노드가 아니라 다중 의존성 병합에 - 진짜 새 노드가 필요해서 그 비용을 코드에 그대로 드러내는 쪽(`:With` - 명시)을 택했다는 비교. +7. **왜 `Compute`/`Effect`는 여러 의존성을 편하게 받고 `Observer`만 안 + 받는가** (2026-08-11 세션 원자료 + 2026-08-21 `C-6` 역전 + 2026-08-24 + `H-13` 근거 재작성, `source-state-plan.md` "`:Compute(fn, ...)` — 추가 + 의존성을 trailing args로 직접 받는 sugar" 절) — 겉보기엔 비일관적인 API + 표면이 실은 "sugar가 새 노드 생성 비용을 감추는가"라는 단일 원칙에서 + 갈라져 나온다는 게 소재. `Compute`는 어차피 자기 결과를 담을 노드를 + 만들어야 해서 추가 구독을 얹는 게 공짜다. + **⚠️ [2026-08-24 정정] 이 항목은 원래 `Effect`도 "안 받는" 쪽으로 묶어 + 서술했는데 그 전제가 뒤집혔다** — `Effect(fn, ...deps)`는 **각 dep에 구독을 + 따로 걸어 합치는 노드 자체를 안 만들므로** 감출 비용이 애초에 없다(`C-6`). + 그래서 **기각으로 남은 건 `Observer` 하나**이고, 그 근거도 비용 은폐가 + 아니라 **역할 분담**으로 새로 쓰였다("Observer는 리시버 State 하나에 붙는 + 구독이고, 여럿을 엮는 건 Effect가 대신한다"). + **에세이의 진짜 소재는 그 뒤집힘 자체다** — 같은 원칙을 유지한 채 + *"어디에 비용이 있는가"*라는 사실 판단만 바뀌어 결론이 갈린 사례이고, + `source-state-plan.md`의 그 절이 이 항목을 새 소재로 지목해뒀다. **publish 안 하는 것과의 경계**: 세션별 정정 이력, 조사 원자료(Fusion 반응 그래프 BFS 분석, quad2-try 죽은 코드 조사 등)는 quadnomicon에도 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index e64536a..59b081f 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1789,3 +1789,53 @@ Luau에선 **배열**인데 실제 게이트 배치는 `{[Epoch]: true}` **집 "M3 착수 전 필요" 목록도 절반 넘게 해소 항목이라 실제로 열린 둘만 남겼다. 이관한 히스토리의 원문은 소급해 고치지 않고 **머리에 "그 뒤 이름이 바뀌었다" 경고만** 달았다. + +## 2026-08-24 — 6라운드 손 트레이싱 전량 처리·반영, 그리고 세 번의 재검토 + +원문: `session/2026-08-24-01-handtrace-round6-resolution.md`. +결정과 근거의 소스는 `qa-request/pre-implementation-handtrace-round6-followup.md`. + +발견 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한 순서로** +사용자에게 물어(사용자 요청: *"같이 하나하나 처리해나가보자. 질문 모드로 계속 +물어보며"*) 전량 결정하고 `base/` 24개 문서에 반영했다. **M2/M3를 막던 것이 +전부 닫혔다** — 말단 핸들러 4종의 `setLength`/`setOffsetSource` 미등록(`H-39`), +`New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가 타입에러인 것(`H-25`), +`Effect`의 leaf 사망 cleanup 배선 부재(`H-11`), `:List`의 좌표계 결함 둘. + +**구조가 바뀐 것 넷**: `slot._elemIndex`(역방향 인덱스 맵) 신설로 `keyIndex`가 +단순 키 집합으로 강등 / `_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 +`slot._physicalTarget` 신설(부기는 실체화 시점부터 항상) / `Ref.Callbacks`가 +해시맵 셋 + `:Uncallback` / `blocker:Policy(emit)` 노출로 `Debounce`/`Throttle`이 +자기 Blocker를 조종하는 정책이 됨. 요소 타입 검증은 블랙리스트에서 **주입 술어 +`isInst` 기반 화이트리스트**로 뒤집혔다. + +**사용자가 에이전트의 갈래를 뒤집은 자리가 여럿**이고 그게 결과를 크게 바꿨다 — +`H-1`(제 세 갈래가 전부 차선, 역방향 맵을 raw 층에 두는 역제안), `H-2`(부기와 +물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이 성립 불가임을 지적, +`isInst` 주입 제안), `H-33`(제 중첩 합성안이 디바운스 창을 안 끝나게 만든다는 +지적). `Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 **사용자가 +먼저 발견**했다. + +**반영 후 세 번의 재검토가 19건을 더 잡았고, 그 실패 패턴이 이 세션의 교훈이다.** +`/code-review high` 7건 중 **셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이 +됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 생성자가 크래시하던 것, `native*`를 +`_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐 영구 누수를 만든 것, "`:List`와 +CRUD는 상호배타"라며 승인받은 분기가 **재마운트 경로를 안 봐서** 포탈을 깬 것. +셋 다 코퍼스 정합성 각도로는 구조적으로 안 보이는 종류라 +`conventions.md`의 *"`/code-review`는 감사자를 대체하지 않는다"*가 실측으로 재확인됐다. +`quad-doc-auditor` 감사 루프는 **9라운드에서 새 발견 0건으로 수렴**했고 총 34건을 +고쳤다(라운드별 5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절이 소스). +**라운드마다 각도를 바꾼 게 실제로 작동했다** — 가장 많이 잡은 6라운드(9건)는 +문서 정합성이 아니라 *"이 체크박스로 코드를 짜면 무엇이 나오는가"*를 물은 +라운드였고, `ROADMAP.md`의 미완료 항목이 대거 stale인 게 그때 드러났다 +(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화 계약이 통째로 부재, +`bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순). +**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**: +배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고 +`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중 +6행만(7·8라운드). **셋 다 핸드오버 체크리스트가 명시적으로 경고하는 +항목**(2번·6번)이라, 규율이 없어서가 아니라 **지켰는지 스스로 확인하지 +않아서** 생긴 실패다 — 8라운드를 아예 완결성 축("새 이름 하나당 나타나야 할 +자리 넷을 열거해 세기")으로 잡은 게 그래서고 거기서 4건이 더 나왔다. +감사에서 파생된 새 결정 셋(주입 op `onDestroying` 신설, `EffectHandle`의 필드 +다섯, `quad-types`의 `Quad` 갱신을 M3/M6/M7/M8/M10에도 항목화)도 같이 반영했다. diff --git a/.claude/session/2026-08-24-01-handtrace-round6-resolution.md b/.claude/session/2026-08-24-01-handtrace-round6-resolution.md new file mode 100644 index 0000000..dab30bd --- /dev/null +++ b/.claude/session/2026-08-24-01-handtrace-round6-resolution.md @@ -0,0 +1,152 @@ +# 2026-08-24 — 6라운드 손 트레이싱 전량 처리·반영, 그리고 세 번의 재검토 + +**한 줄**: 발견 보고 `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정하고 +`base/` 24개 문서에 반영했다. 그 뒤 `/code-review high`가 7건(그중 셋은 이번 +반영이 만든 회귀), 감사 1라운드가 5건, 2라운드가 7건을 더 잡았다. + +**결정과 근거의 소스는 `qa-request/pre-implementation-handtrace-round6-followup.md`** +— 여기서 반복하지 않는다. 이 문서는 **어떻게 진행됐고 무엇이 잘못됐는가**의 기록이다. + +## 진행 방식 — 사용자 요청 + +*"`pre-implementation-handtrace-round6.md` 에 대해서 같이 하나하나 처리해나가보자. +질문 모드로 계속 물어보며, 처리할 방법들을 찾거나 택하거나 해보자. 전부 사람이 +직접 읽기엔 양이 많아서 그래. 다만 네 질문과 원 파일은 같이 볼테니, 어디에 원문이 +있는지만 알려줘"* + +54건을 4·5라운드 같은 문항지로 만들지 않고, **갈래 선택이 필요한 것만 추려 +급한 순서로** 물었다(M2/M3를 막는 것 먼저). 단순 정정은 마지막에 한 번에 묶어 +확인받았다("전부 그대로 반영"). 답변마다 원문 줄 번호를 같이 줬다. + +## 사용자가 제 선택지를 뒤집은 자리들 — 이번 세션의 핵심 + +에이전트가 낸 갈래가 **틀렸거나 부족했던** 경우가 여럿이고, 그게 결과를 크게 +바꿨다. 기록해둘 가치가 있는 것만: + +### `H-1` — 제 세 갈래가 전부 차선이었다 + +`:List`의 `keyIndex`가 사이클 도중 stale이라는 발견에 (1) 라이브 인덱스 맵 +(2) `indexOfRaw`를 기본 경로로 (3) reconcile 2패스를 냈는데, 사용자가 **역제안**: + +> *"raw* 가 층위를 알아야할 이유를 모르겠는 상태. 그냥 k->realElem 을 list 에선 +> 저장하고, raw 는 realElem -> index 를 indexOf 로 하거나, 보통 읽기가 더 +> 빈번하므로 realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 +> 이걸 같이 업데이트해주는 편이 나아보이기도. quad-base 의 현 모양에서 더 확장 +> 될 땐, index 를 알아야하게 되는 경우가 많아질것이라, 선제 처리로 해결하는게 +> 맞아보이는데, 어떻게 생각하는지?"* + +제 1번 안은 `raw*`가 `:List`의 클로저를 알아야 해서 층이 섞이는 게 단점이었는데, +**맵을 `_elements`와 같은 층에 두면 그 단점 자체가 없어진다**는 게 요지였다. +채택. 부수로 `keyIndex`가 단순 키 집합으로 강등되며 `H-16`도 같이 사라졌다. + +### `H-2` — "offsetAt으로 구하라"가 초기 경로에서 안 맞았고, 그걸 되물었다 + +사용자 답: +> *"offsetAt 으로 구함 -> 기본적으로 activation 자체는 순차로 일어나므로 length +> 자체는 확정되는게 맞을것임."* + +확인해보니 **최초 population에서만 안 맞았다** — `materializeSlotTree`가 +`_mounted = false`인 채 `activateList`를 부르고 `rawAdd`가 부기까지 얼리리턴해서 +`bk.lengthList`가 비어 있었다. 그걸 보고했더니 사용자가 되물었다: + +> *"1. 안이 맞긴 한데, length 자체는 설정하지 않아? offset 을 넘겨주는건 +> updateFn 기준으로 부기이지, 물리 마운트와는 무관해서 말이야. … 부기 확정에서 +> length 를 확정해도 되는거 아님?"* + +맞는 지적이었다. **`_mounted`가 "물리 인스턴스 유무"만 뜻하도록 좁히고 부기는 +실체화 시점부터 항상** 하는 것으로 정리됐고, `slot._physicalTarget`이 신설됐다. + +### `H-40` — 제가 낸 "브랜드로 판정" 안이 성립 불가였다 + +> *"브랜드를 두는건 못 해, 이제 brand 는 각각 따로 생성되어서 있는지 없는지 +> 보는건 결국 전부 봐야한다는 의미일껄? 난 3. 을 추천해. isInst 같은걸 백엔드 +> 계약에 넣고, quad-roblox 가 채워주는게 맞아보이는데 어떻게 생각해?"* + +2026-08-21의 `Brand` 재작성(인스턴스 브랜드)을 제가 못 따라간 것. 화이트리스트로 +뒤집되 **base는 `T`를 모른 채 주입 술어만 부르는** 형태로 닫혔다. + +### `H-33`/`H-49` — 제 중첩 합성안이 창을 안 끝나게 만들었다 + +`Gate` 정책 합성에서 제가 `blocker:Policy(debounceOnEmit)`(블로커가 바깥) 형태를 +제안했는데: + +> *"맞는 모양 같긴 해. 근데 저러면 unblock 될 때 다시 타이머 셋팅됨. 타이머 +> 끝난건데, 다시 셋팅되잖아. … 사실 debounce 는 emit 핸들 조차 필요 없는거지. +> 그냥 blocker 를 자신이 가지니까, 껐다 켰다로 emit/block 을 하고(내부 구현을 +> 위임). On/Off 할 시기만 본인이 결정한다는 생각이였는데, 이 구조가 불가한거였음?"* + +가능했고 더 나았다. 제 트레이스는 "디바운스도 진짜 `emit`을 쥔다"를 전제했는데, +**디바운스가 emit을 아예 안 쥐면** 순차 호출이 성립한다. + +### 사용자가 먼저 발견한 것 둘 + +- **`Ref.Callbacks`를 해시맵으로** — `H-7`을 "해제 경로 추가"로 정하자마자 + *"그렇다면 Effect 의 callback 들은 해시맵이 되는게 나아보이는데 [callback/루틴]=true + 그렇지 않아?"* 로 자료구조까지 바꿨다. 부수로 `ref-plan.md`의 `#t` border 실측 + 항목이 폐기됐다. +- **`Ref` 콜백의 `canExecute`** — `H-11` 재결정 중에 *"그런데, Effect 또한 + canExecute 를 거쳐서 Ref 의 Callback 이 실제 Effect 의 Callback 을 실행할 지 + 결정해야하는데, 그것도 되어있는지 봐줘요"*. 확인해보니 없었고, 오히려 제가 + `H-7` 처리 때 "그 대안은 채택하지 않았다"고 적어놨었다. **해제는 누수를, + 게이팅은 발화를** 막는 별개 역할이라 둘 다 필요했다. + +## 반영 후 세 번의 재검토에서 나온 것 — 실패 기록 + +### `/code-review high` — 7건, 그중 **셋이 이번 반영이 만든 회귀** + +`conventions.md`가 *"`/code-review`는 감사자를 대체하지 않는다"*고 적어둔 그대로였다. +셋 다 **코퍼스 정합성 각도로는 구조적으로 안 보이는** 종류였다: + +1. **`rawAdd`가 미실체화 가드를 통째로 잃음** — 상태가 셋이 됐다고 산문에 쓰고 + 코드엔 `_mounted` 경계만 남겼다. `Slot { frameA }` 생성자가 즉시 크래시. +2. **`rawRemove`가 마운트 전 창에서 아무도 안 죽임** — `nativeRemove`가 곧 + 파괴였는데 `_mounted`로 가리기만 하고 `else`를 비워뒀다. 영구 누수. +3. **`_listed` 분기가 포탈 재마운트를 깸** — 제가 "`:List`와 CRUD가 + 상호배타이니 안전"이라 말하고 승인받았는데 **재마운트 경로를 안 봤다.** + `activateList`가 멱등 가드로 즉시 리턴하는 경우를 놓친 것. + +그리고 `H-11`의 두 결정이 **서로 모순**이라는 지적(4번)이 나와 재결정했다 — +*"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가 실재하지 +않았다.** 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 직접 처리하는 것으로 +바뀌었다: + +> *"Destroying 자체가 엔진이 아는 요소이기 때문에, 엔진이 처리하는 곳에 두긴 +> 해야합니다. 옵져버는 바로 생성되기 때문에, bind 상 옵져버 목록을 가져와 자신이 +> 재귀하고, bindLifetime 이 처리하는게 나아보입니다."* + +제가 반대 근거로 인용했던 *"게이트는 값 타입을 안 가린다"*는 **인용 자체가 +틀렸다** — `source-state-plan.md`의 그 절은 `canBound` 판정이 두 진입점에서 +같다는 얘기지 부수 배선 얘기가 아니었다. + +### 감사 루프 — 9라운드, 34건 (라운드별 5→7→2→2→3→9→2→4→0) + +라운드별 각도와 발견 목록은 **followup의 E절이 소스**. 여기엔 교훈만 적는다. + +**2라운드**: **`.claude/README.md`를 한 줄도 안 고쳤다.** `base/` 19개 파일 +1300줄을 바꿔놓고 색인을 안 건드렸으니 체크리스트 6번을 통째로 빠뜨린 것. + +**6라운드가 9건으로 가장 많이 잡았고, 그게 각도 전환의 증거다.** 앞선 다섯 +라운드는 전부 "문서가 서로 맞는가"를 물었는데, 6라운드만 **"이 체크박스로 +코드를 짜면 무엇이 나오는가"**를 물었다. 그러자 `ROADMAP.md`의 미완료 항목이 +대거 stale인 게 드러났다 — 폐기된 `pos` 공식이 "확정"으로 남아 있고, 접두합 +캐시 무효화 계약이 통째로 없고, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 +직접 모순이었다. + +**반복된 실패 패턴은 하나 — "N개 중 일부만 고쳤다".** 1라운드(배너를 달고 그 +배너가 부정하는 문장을 안 고침), 2라운드(README를 통째로 안 고침), +7·8라운드(그 README를 고칠 때 11행 중 6행만). 8라운드를 아예 **완결성 축**으로 +잡은 게 그래서고 4건이 더 나왔다. **세 번 다 `conventions.md`의 핸드오버 +체크리스트가 명시적으로 경고하는 항목이었다**(2번·6번) — 규율이 없어서가 +아니라 규율을 지켰는지 스스로 확인하지 않아서 생긴 실패다. + +**감사에서 파생된 새 결정 셋**도 `base/`에 반영했다 — 주입 op +`onDestroying(inst, fn)` 신설(`_bindDestroying` 의사코드를 쓰다 드러났다), +`EffectHandle`의 필드 다섯, `quad-types`의 `Quad` 갱신을 M3/M6/M7/M8/M10에도 +항목화(확정은 "마일스톤마다"였는데 M2에만 있었다). + +## 남은 것 + +- **백로그 1건**: `Fallback`/`Traceback` 중 생성된 부분 트리 회수(`H-26`). + 그 둘이 슈가라 구현 시점에 같이 다룬다. +- **M2 착수를 막는 설계 항목은 없다.** 남은 건 `question.md` 2번(M2↔M3 양방향 + 의존, 마일스톤 순서)뿐이다. diff --git a/.claude/todos.md b/.claude/todos.md index 0c25e71..8f6a800 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -52,60 +52,42 @@ M0 체크박스가 검증했던 스파이크 중 재작성 대기인 것들도 `ROADMAP.md`의 "재검증 대기" 절로 모았다(현황의 소스는 여전히 `luau-test/STATUS.md`). - 그 외 남은 것은 판단이 아니라 구현 시 정할 것들 — `Gate`의 - 생명주기·재진입 계약, 스파이크 `05` 재작성(`luau-test/STATUS.md`). + 그 외 남은 것은 판단이 아니라 구현 시 정할 것 하나 — 스파이크 `05` + 재작성(`luau-test/STATUS.md`). **[2026-08-22 해소]** 여기 있던 "M2에 `Blocker`까지 넣을지"는 같은 날 `ROADMAP.md` 전반 점검에서 **둘 다 M2**로 확정되며 닫혔다. + **[2026-08-24 해소, 6라운드 `H-49`]** 여기 `Gate`의 "생명주기·재진입 계약"을 + 같이 나열했었는데 **두 항목 다 부정확했다** — 재진입은 이미 + `[2026-08-21 정리 — 열린 항목 아님]`으로 닫혀 있었고, 생명주기는 "판단이 + 아니라 구현 시 정할 것"이 아니라 그 항목이 사는 절 제목이 정확히 + "사용자 판단 필요"였다. 그 안의 *"`Gate` 자체에 `Flush`/`Cancel` 같은 + 표면을 둘지"*가 `H-33`이 걸린 바로 그 자리였고, **2026-08-24에 + `blocker:Policy(emit)` 노출 + `Debounce`/`Throttle`이 자기 Blocker를 + 조종하는 구조로 확정되며 닫혔다**(`base/gate-plan.md` 5번). - **⭐⭐ [2026-08-22 신설] 6라운드 — 손 트레이싱, 사용자 회신 대기.** - 사용자 요청으로 최근 확정 5개 영역(`Effect(fn, ...deps)` / `Gate`·`Blocker` / - State 전파(`rawInvalid`·emit 지연) / Slot의 `native*`·offset·length·mount / - `Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려봤고, **의사코드 그대로 - 구현하면 크래시하거나 조용히 어긋나는 것들이 나왔다.** 문항지가 아니라 - 발견 보고이고 **`base/`는 아직 한 줄도 안 고쳤다** — 소스는 - `qa-request/pre-implementation-handtrace-round6.md`(발견 수·심각도·개별 - 항목은 여기서 세지 않는다). **M2/M3 구현에 직접 걸리므로 다음 세션의 첫 - 작업으로 볼 것.** + **⭐⭐ 6라운드(손 트레이싱) — [2026-08-24] 회신·반영 완료.** + 4개 패스에 걸친 발견 보고(`H-1`~`H-54`)를 사용자와 대화형으로 하나씩 + 처리하고 `base/`에 전량 반영했다. 발견 원문은 + `qa-request/pre-implementation-handtrace-round6.md`, **결정과 근거는 + `-followup.md`가 소스**(개수·심각도·개별 항목은 여기서 세지 않는다). - **⭐ [2026-08-23] 같은 파일에 2차 패스가 이어붙었다** — 사용자 요청으로 - 1차가 안 본 영역(디스패치 코어 전체 / 라이프타임 유틸 / `Ref`·`Tag`· - `Attribute`·UI 숏핸드 핸들러 / Slot의 `raw*` 계층)까지 넓혀 다시 돌린 - 것. 여기서도 **`base/`는 한 줄도 안 고쳤다.** 갈래 선택이 필요한 항목이 - 1차보다 늘었고(어느 것인지는 그 문서의 두 "회신 방법" 절이 소스), - 그중 하나는 **`Effect`의 leaf 사망 cleanup을 발화시키는 배선이 어느 - 의사코드에도 없다**는 것이라 `Effect`/`OnDestroyed`/`slot._detached` - 정리가 통째로 걸린다 — M3 `Effect` 구현 전에 반드시 닫을 것. + **M2/M3에 직접 걸리던 것들이 전부 닫혔다** — 말단 핸들러 4종의 + `setLength`/`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 + `quad.Dispatch`가 타입에러인 것(`H-25`, M2 체크리스트에 항목 신설), + `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`), `:List`의 인덱스/좌표계 + 결함 둘(`H-1`/`H-2`). - **⭐⭐ [2026-08-23] 같은 파일에 3차 패스도 이어붙었다 — 발견 번호는 - `H-21`~`H-38`.** 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, - Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, - 타입 계약과 **실제 커밋된 M1 코드**)와 문서 경계를 가로지르는 통합 - 시나리오를 돌렸다. 여기서도 **`base/`는 한 줄도 안 고쳤다.** 1·2차와 - 달리 **추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`로 직접 재현**했고, - 그 부수로 **기존 `H-2`의 크래시 주장이 틀렸다는 것도 드러났다**(결론은 - 유효하지만 결과가 크래시가 아니라 조용한 영구 고아 — 그 문서 3차 패스 - 머리의 정정 절이 소스). **M2 착수 전에 정해야 하는 것이 하나 늘었다** — - `New(): Quad`가 닫힌 타입이라 M2가 붙일 `quad.Dispatch` 접근이 그대로는 - 타입에러라는 것(`H-25`, `luau-analyze`로 재현). 개별 항목·심각도·갈래는 - 여기서 세지 않는다 — 그 문서의 세 "회신 방법" 절이 소스. + **구조가 바뀐 것 넷** — (1) `slot._elemIndex`(물리 요소 → 인덱스) 신설로 + `:List`의 `keyIndex`가 단순 키 집합으로 강등, (2) `_mounted`가 "물리 + 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`이 신설되어 부기는 + 실체화 시점부터 항상 수행, (3) `Ref.Callbacks`가 해시맵 셋 + 해제 경로 + (`:Uncallback`), (4) `blocker:Policy(emit)` 노출로 `Debounce`/`Throttle`이 + 자기 Blocker를 조종하는 정책이 됨. 요소 타입 검증도 블랙리스트에서 + **`isInst` 주입 술어 기반 화이트리스트**로 뒤집혔다(`H-40`). - **⭐⭐ [2026-08-24] 4차 패스도 이어붙었다 — 발견 번호는 `H-39`~`H-54`.** - 이번엔 문서 단위가 아니라 **축을 바꿔서** 훑었다(핸들러 레지스트리 전수 / - `ref-plan.md`·`attribute-plan.md` 심층 / **`luau-test` 스파이크 실제 재실행** / - 프리미티브 조합 매트릭스 / `reference`·`archive`·로드맵 M3~M9 / **엔진·언어 - 사실 주장 전수 검증**). 여기서도 **`base/`는 한 줄도 안 고쳤다.** - **⭐ M2에 직접 걸리는 게 하나 나왔다** — 배열 자리를 차지하는 말단 핸들러 - 4종(`TagHandler`/`AttributeGroupHandler`/`RefLeafHandler`/ - `ObserverEffectLeafHandler`)이 `setLength`/`setOffsetSource`를 **아예 등록하지 - 않아** `Frame { Tag("x"), Child{} }` 같은 흔한 배치가 첫 마운트에 - `recompute`의 명시적 error로 죽는다(`H-39`, **세 축에서 독립 발견** 후 - 전수 grep으로 재확인). 부수로 **`H-21`의 전제가 공식 문서로 확인**됐고, - 반대로 **`PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부**임이 - 드러났다(`H-42` — 신규 템플릿 place는 이미 `Deferred`가 기본). - **스파이크 쪽은 깨끗하다** — `done/` 16개 전원이 `STATUS.md` 주장과 실행 - 결과가 일치했고 GC 스파이크도 반복 실행에서 안 흔들렸으며 설계 드리프트도 - 0건이었다. 개별 항목·심각도·갈래는 여기서 세지 않는다 — 그 문서의 네 - "회신 방법" 절이 소스. + **백로그로 넘긴 것 하나** — `Fallback`/`Traceback` 중 생성된 부분 트리의 + 회수(`H-26`, `base/fallback-plan.md`의 ⚠️ 절). 그 둘이 슈가라 구현 시점에 + 같이 다룬다. **4라운드 — [2026-08-21] 종결.** 문항지는 `.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은 diff --git a/ROADMAP.md b/ROADMAP.md index 1e59d5a..2eb4e65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,6 +171,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 폴더 — 실제 소스는 M5) - [x] 루트 `default.project.json`, `.luaurc`(`architecture.md` "구현 착수: 소스 트리 구조 확정" 절 그대로) +- [x] **[2026-08-24 소급 등재, 6라운드 `H-47`/`H-48`] `quad-base/src/Debug/init.luau`** + — `InitDebug(module)`이 `module.debug = false`를 심는다. **이미 커밋돼 + 있었는데 이 목록에도 `architecture.md` 소스 트리에도 없었다**(`ROADMAP.md` + 전체에 "Debug"가 0건이었다). `ROADMAP.md`가 진행 상황의 소스인데 M1 + 체크박스만 보면 이 모듈의 존재도 완료 여부도 알 수 없던 상태. + 같이 확인된 것: **`Quad.debug`의 스코프는 "인스턴스별"로 이미 확정돼 + 있다**(이 파일이 인스턴스 필드를 심고 `quad-types`의 `Quad`도 + `debug: boolean`을 필드로 갖는다) — `base/module-lifecycle-plan.md`가 + "미정"이라 적어둔 걸 `H-48`이 닫았다 - [x] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수 `luau` CLI, `architecture.md` "테스트 전략" 절 참고) — `quad-base/test/mock.luau` + `smoke.*.luau`, 전부 PASS @@ -233,6 +242,30 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `F-4-1` 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로 못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 `01`도 재작성 대기 (`luau-test/STATUS.md`) +- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-39`] 말단 핸들러는 예외 + 없이 자기 배열 자리의 `setOffsetSource(inst,k,None)` → `setLength(inst,k,0)`을 + 등록한다** — 이 계약을 핸들러 작성 체크리스트에 넣고, 실제로 넷이 + 빠져 있었으니 각 마일스톤에서 확인할 것: `TagHandler`(M10) / + `AttributeGroupHandler`(M10) / `RefLeafHandler`(M8) / + `ObserverEffectLeafHandler`(M3). 안 하면 `bk.N`이 다른 자리 등록으로 + 커질 때 그 구멍이 범위 안에 끼어 `recompute`가 **명시적 error로 죽는다** + (`Frame { Tag("card"), TextLabel{} }`처럼 말단이 앞에 오는 흔한 배치). + **배열 맨 끝이면 안 터지므로 "가끔 되고 가끔 터지는" 형태로 드러난다.** + 소스는 `base/dispatch-core-plan.md`의 등록 책임 절 +- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25`] `quad-types`의 `Quad` + 타입에 `Dispatch` 필드와 그 타입 재수출을 추가** — `Quad`가 5필드 닫힌 + 레코드이고 `RunInit`은 반환값이 없어 타입을 못 넓히므로, 이걸 안 하면 + `module:RunInit(InitDispatch)` 뒤의 `quad.Dispatch` 접근이 **런타임엔 + 되는데 `luau-analyze`에선 타입에러**다(실측 재현). `base/architecture.md`의 + 확정된 결정 13번이 그 접근을 표준 사용법으로 못박아뒀다. + 상세는 `base/quad-types-plan.md`의 "`Quad` 타입 — 확정된 표면" 절. + **⚠️ 이건 M2 한 번으로 끝나는 일이 아니다** — 그 문서가 *"마일스톤마다 + 갱신한다 … 이후 서브시스템도 같은 규칙을 따른다"*로 확정했으므로, + **서브시스템을 붙이는 모든 마일스톤이 같은 항목을 진다**: + M3(`Source`/`State`/`Store`) · M6(`Slot`) · M7(`Modifier`) · + M8(`Ref`) · M10(`Tag`/`Attribute`). 빠뜨리면 그 마일스톤 완료 후 + `quad.Store`/`quad.Slot` 접근이 런타임엔 되는데 `luau-analyze`에선 + 타입에러인, `H-25`가 실측한 그 문제가 **마일스톤마다 반복된다.** - [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/ `process(inst,k,v,index) -> (hintValue)->()` **3종** — `isHandlable`도 `inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는 @@ -352,6 +385,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `setOffsetSource`는 `None`이면 얼리 리턴(그 `None`은 "발행 채널 없음"이지 "참여 안 함"이 아니다 — 참여 여부는 `setLength`가 답), 숫자가 필요한 쪽(예: 물리 삽입 위치)은 `getOffsetAt`으로 pull. + **⭐ [2026-08-24 6라운드 `H-3`/`H-4`/`H-19`] `getOffsetAt`의 접두합 캐시 + 계약을 반드시 같이 구현할 것** — 이게 빠지면 형제 Slot이 커져도 뒤 형제의 + `Offset`이 **영원히 고정**된다(`sum`은 매번 새로 더하므로 위로는 맞고 + 옆으로만 틀려 알아채기 특히 어렵다): + (a) `getBookkeeping`이 `bk.offsetCache = {}`, `bk.invalidAfter = 0`으로 + 초기화(`nil` 시작이면 첫 `setOffsetSource`가 `nil` 비교에서 죽는다), + (b) **캐시를 앞으로 당기는 자리 셋** — `setLength` 본문과 그 자리 length가 + State일 때 다는 Observer 콜백(`math.min(invalidAfter, i)`), + `spliceArraysUp`/`spliceArraysDown`(같은 식), `slot._baseObserver` 콜백 + (베이스가 바뀐 경우라 `0`). 셋 다 `recompute`보다 **먼저**. + (c) **`recompute` 호출은 `setLength`의 단독 책임**이다 — `rawAdd`/ + `rawReplace`의 명시 호출은 삭제됐고, 자리가 없어지는 경로 + (`rawRemove`/`rawUnmount`/`rawDetach`)만 예외로 직접 부른다. `recompute`는 owner의 베이스(Slot이면 자기 `.Offset`, 최상위면 0)에서 시작하고 중첩 Slot은 자기 `Offset`을 관측해 자식 offset을 다시 민다. **이 셋이 하는 일**: array part 형제 순서 보장(Length/Offset 누적합→ @@ -456,9 +502,35 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`" 절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드 자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M3 - 스파이크에서 확인. `Effect`/`Observer`는 대칭 sugar 없이 `:With` 명시 - 유지(의도적 비대칭, 같은 절 참고) -- [ ] trailing deps를 `fn`에 lazy positional 인자로도 노출(`fn(self, + 스파이크에서 확인. **[2026-08-24 정정, 6라운드 `H-13`]** 여기 원래 + *"`Effect`/`Observer`는 대칭 sugar 없이 `:With` 명시 유지"*라고 적혀 + 있었는데 **`Effect`는 `C-6`에서 이미 역전됐다** — 각 dep에 구독을 따로 + 걸면 합치는 노드 자체가 안 생겨 감출 비용이 없다. **기각으로 남은 건 + `Observer` 하나**이고 근거도 새로 쓰였다("Observer는 리시버 State + 하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다") +- [ ] **⭐ [2026-08-24 신설, 6라운드] `Effect` 구현 시 같이 만들 것** — + **`handle._observers`**(배열, 단수 `_observer`에서 바뀜 `H-8`) · + **`handle._cleanup`**(직전 cleanup 보관, `Rerun`과 `Destroying` 클로저가 + 같은 자리를 읽는다) · **`handle._refDeps`/`_refCallbacks`**(`Ref` dep과 + 거기 건 클로저 — 해제 시 값으로 떼야 해서 보관 필요) · + **`handle._destroyConn`** · **`:_bindDestroying(inst)`/`:_unbindDestroying()`** + (`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`). + `fn` 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고 + **`...deps`는 `fn`에 안 넘어간다**(`H-14`). `Ref` 콜백은 본문 맨 앞에서 + **`canExecute(handle)`를 확인**한다(`H-7`). 의사코드는 + `base/effect-plan.md`가 소스 +- [ ] **[2026-08-24 `H-39`]** `ObserverEffectLeafHandler.process`가 자기 배열 + 자리의 `setOffsetSource(inst,k,None)`/`setLength(inst,k,0)`을 등록 — + 빠져 있었다(위 M2의 그 항목) +- [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로 + 스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 + 미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로 + 누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약 +- [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Source`/`State`/ + `Store` 필드 추가(위 M2 항목의 "마일스톤마다" 규칙) +- [ ] trailing deps를 `fn`에 lazy positional 인자로도 노출(**⚠️ [2026-08-24 + `H-14`] 이 항목은 `:Compute` 한정이다** — `Effect`의 `fn`엔 deps가 안 + 넘어간다) — (`fn(self, previous?, dep1, ..., depN)` — 순서는 Luau 값 레벨 `...`가 파라미터 리스트 맨 끝이어야 하는 것과 같은 이유로 `previous?`가 deps 팩 **앞**에 와야 함, 2026-08-11 후속 세션 제안 → 같은 날 세 번째 @@ -559,6 +631,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 > `nativeInsert`/`nativeExtract`/`nativeDispose` 셋이다(나머지는 이득 있을 때만 > 덮어씀 — Roblox는 `nativeRemove`를 "그 자리에서 바로 `Destroy`"로 융합하는 게 > 실익). 상세는 `base/slot-plan.md`의 "물리 조작은 주입 op다" 절. +> +> **⚠️ [2026-08-24 정정, 6라운드 손 트레이싱 `H-34`/`H-44`] 위 "최소 구현 부담은 +> 셋" 서술을 좁힌다 — Roblox 백엔드는 `nativeMove`/`nativeSwap`도 반드시 +> 덮어써야 한다.** 조합 폴백(`nativeMove` = `nativeExtract` + `nativeInsert`)은 +> 여기선 "느린 정답"이 아니라 **관측 가능한 동작 차이**를 만든다: +> `Move`/`Swap`을 공개 CRUD에 추가한 근거 자체가 *"`Extract`+`Add`는 실제 +> Parent 조작이 두 번(detach+reattach) 일어남"*을 피하려는 것이었는데, 폴백은 +> 정확히 그 두 번을 되돌려 `AncestryChanged` 재발화·깜빡임·재바인딩 비용을 +> 다시 만든다. offset을 무시하는 백엔드에서 순서 이동은 애초에 물리 조작이 +> 아니므로 **no-op으로 덮어쓰면 된다.** +> 그리고 **주입 op이 둘 더 늘었다**(6라운드) — **`isInst`**(`H-40`): 요소 타입 +> 검증을 화이트리스트로 뒤집으면서 생긴 판정 술어, 조합 폴백이 불가능해 +> **미주입이면 에러**다(quad-roblox 구현은 `typeof(v) == "Instance"` 한 줄). +> **`onDestroying(inst, fn)`**(`H-11`): `Effect`의 leaf 사망 cleanup을 발화시키는 +> 훅으로, `bindLifetime`이 `isEffect`일 때 부른다(quad-roblox 구현은 +> `inst.Destroying:Connect(fn)`). 이것도 조합으로 못 만들므로 미주입이면 에러다. > **⚠️ 구현 관례**: `quad-roblox`의 공개 타입은 지금부터 단일 파일 > (`src/init.luau` 또는 `types.luau`)에 몰아둘 것 — 나중에 필요해지면 @@ -638,7 +726,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `Get`/`IndexOf`/`Extract`가 돌려주는 값과 `:List`의 `prev`는 전부 **언래핑된 원래 값**이다). base/roblox 경계에 mount/unmount 외 reposition 훅 추가됨. **`Slot()` 제네릭화, 요소 - 타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 + 타입 제약 확정** — **[2026-08-24 6라운드 `H-40`로 전면 정정] 판정은 + 블랙리스트가 아니라 화이트리스트다**: `isSlot` → `isState`(래퍼 Slot으로 + 풀어 재귀) → **주입 술어 `isInst`**, 셋 중 어디에도 안 걸리면 error. + 관문은 `wrapElement` 하나(공개 CRUD와 `:List`의 `settle`이 둘 다 지난다). + 아래 나열은 이제 **그 화이트리스트의 따름정리**다(에러 메시지는 계속 + 구분해 낸다) — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/ Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = @@ -724,14 +817,45 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `State` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시 `prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에 생긴 이유). **부수 발견(사용자)**: `:List`의 `reconcile`이 - nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 `index`를 - 그 결과의 `.Length`만큼 건너뛰도록 `pos` 커밋 공식도 같이 수정 - (`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get() - or 1)`) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가 - 겹침. `base/slot-plan.md` "반응형 raw 요소" 절. + nested-Slot 결과를 반환하는 아이템 다음 형제의 `index`가 그 결과의 + 물리 개수만큼 건너뛰어야 함 — 안 그러면 멀티루트 아이템 다음 형제의 + LayoutOrder가 겹침. `base/slot-plan.md` "반응형 raw 요소" 절. + **⚠️ [2026-08-24 6라운드 `H-2`] 그 의도를 구현하던 옛 공식 + (`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get() or 1)`)은 + 폐기됐다** — 두 좌표계(리프 개수 / `_elements` 자리)를 한 변수에 겹쳐 써서 + `table.insert(t, 0, x)`로 조용한 영구 고아를 만들었고, `.Length`를 읽는 + 시점이 **항상 0**(마운트 전이라 `recompute`가 아직 안 돎)이라 애초에 + 아무것도 반영하지 못했다. 확정된 해법은 배열 자리를 `slotPos`로 따로 세고 + `updateFn`의 `index`는 **`Dispatch.getOffsetAt`에서 뽑는** 것 — 같은 문서의 + `reconcile` 의사코드가 소스. ### 짜야 할 것 +- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-46`] top-level + `quad-base/Slot.luau` — 값 타입 본체가 들어가는 파일.** 생성자, 공개 + CRUD 11종, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`, + `attachSlot` 3형제(`materializeSlotTree`/`mountSlotTree`), + `elementOwner`/`claimOwner`/`releaseOwner`, `reindexFrom`, + `collectLeaves`, `dispose`, `Detach`/`KeyGone`. 아래 불릿들이 전부 이 + 파일의 내용이다. **`Dispatch/Slot.luau`(맨 아래)는 핸들러/부기만** — + 다른 값 타입(`Modifier`/`Tag`/`Tween`/`Ref`/`Effect`)이 전부 top-level + 파일을 갖는 것과 같은 대칭이고, `base/slot-plan.md`의 `attachSlot` + 블록이 이미 머리에 이 파일명을 적어뒀다 +- [ ] **⭐ [2026-08-24, 6라운드] 이 마일스톤에서 새로 생긴 필드·헬퍼** + (구현 항목으로 드러나야 놓치지 않는다): + **`slot._elemIndex`**(물리 요소→`_elements` 인덱스 역방향 맵, + `indexOfRaw`가 이걸 O(1)로 조회하는 **기본 경로**) · + **`reindexFrom(self, from)`**(`_elements`를 시프트하는 **모든** 자리가 + 부른다 — 실체화 여부와 무관하게 항상) · + **`slot._physicalTarget`**(실체화 시점부터의 생명주기 앵커, + `_mountedInst`는 "마운트됨"만 뜻하게 좁혀졌다) · + **`collectLeaves(slot)`**(중첩 Slot의 물리 리프 평탄화 — + `Move`/`Swap`/`Extract`/`Splice`가 `native*`에 넘길 `elements` 배열을 + 만드는 데 필요, 없어서 그 넷이 미작성이었다) · + `:List`의 **`prevKeys`**(옛 `keyIndex`의 강등판, 단순 키 집합). + 전부 `base/slot-plan.md`가 소스 +- [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Slot` 필드 추가 + (위 M2 항목의 "마일스톤마다" 규칙) - [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]** - **`State` 교체 = 파괴가 아니라 언마운트**(`state`와 동일). 비파괴 경로 `unmountSlotTree`를 `destroySlotTree`와 별도로 구현 — @@ -804,6 +928,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 물어 처분을 받고, owner가 죽으면 `activateList`가 건 `Effect`가 `_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절). + **⚠️ [2026-08-24 `H-50`] 단 그 마지막 절반은 아직 "될 예정"이지 "된다"가 + 아니었다** — `Effect`의 leaf 사망 cleanup을 실제로 발화시키는 배선이 + 코퍼스 어디에도 없었다(6라운드 `H-11`). 같은 날 확정으로 + **`bindLifetime`/`unbindLifetime`이 `isEffect(value)`를 보고 `Destroying`을 + 걸고/끊는 것**으로 닫혔고(`base/effect-plan.md` + + `base/lifecycle-pattern.md`의 그 의사코드), 이 문장은 그 배선이 구현된 + 뒤에야 참이 된다 — M3 `Effect` 구현이 M6의 이 항목을 **선행**한다는 뜻이다. + (**[같은 날 재결정]** 처음엔 *"`EffectHandle`이 자기 `bindLifetime` 직후에 + 건다"*였으나 `/code-review high`가 **그 호출부가 실재하지 않는다**는 걸 + 지적했다 — 핸들은 남이 자기를 bind하는 걸 관측할 수 없고, `Effect`가 + 바인드되는 경로가 둘이라 호출부 쪽에 두면 반드시 한쪽이 샌다.) **`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본 `true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가 `state`에 담아 넘긴 요소용, `Slot:Add(state)` sugar가 이걸로 설치). 파라미터 순서는 반환값 순서(`prev`류 먼저, @@ -828,7 +963,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 안 구독한 상태라 무의미한 연산이 됨, `updateFn`만 이 갈래를 정확히 알아 낭비를 피할 수 있음(반환값 두 개는 서로 독립, `result`가 `nil`이어도 `userdata`는 명시적으로 반환 안 하는 한 안 지워짐). 정리 루프는 - `mounted`가 아니라 직전 사이클 `keyIndex` 전체를 순회해야 함 + `mounted`가 아니라 직전 사이클의 키 집합 `prevKeys` 전체를 순회해야 함 + (**[2026-08-24 `H-1`]** 옛 이름은 `keyIndex`였고 인덱스 맵이었으나, + 역방향 맵 `slot._elemIndex`가 생기며 **단순 키 집합으로 강등**됐다) (`userdata`만 살아있는 채로 key가 완전히 사라지는 케이스 커버). `userdata = userdata or {}` lazy-init 패턴이 Luau 제네릭에서 잘 좁혀지는지 실측 필요. **`userdata`는 GC-native 값만 허용, @@ -896,9 +1033,22 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween`로 만들면 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` 10번, 2026-08-10 세션, `base/tween-plan.md`) +- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-35`] + `quad-base/Dispatch/Modifier.luau` — `ProcessedModifierHandler`.** + `flatten`이 배열 자리를 `ProcessedModifier` 센티널로 소진하고, 이 전담 + nop 핸들러가 정상 `Dispatch.process` 경로에서 캐치해 + `setOffsetSource(None)`/`setLength(0)`만 등록한다(`Pre`/`PostRef`의 + `Processed*` 핸들러와 완전히 같은 모양). **`Modifier`가 하나라도 든 + `Frame{...}` 호출은 전부 이 핸들러를 거치는데** 색인 두 곳에서 통째로 + 빠져 있어 구현자가 존재 자체를 놓칠 수 있던 자리다. 의사코드는 + `base/modifier-plan.md`의 flatten 절이 소스 +- [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Modifier` 관련 + 표면이 노출돼야 하면 같이 갱신(위 M2 항목의 "마일스톤마다" 규칙) ## M8 — Ref +- [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Ref` 필드 추가 + (위 M2 항목의 "마일스톤마다" 규칙) - [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/ `:Wait(thread?)`, 전부 self 반환) + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, @@ -960,14 +1110,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 패턴. pre-pass가 소진시킨 자리가 Length/Offset에 "0 기여"를 등록할 책임을 지는 자리 — `base/ref-plan.md` "PreRef" 절 / "`PostRef`" 절, `base/dispatch-core-plan.md` "Length/Offset" 절 -- [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 - `coroutine.resume(v, self)`+`nil`로 소진(2026-08-09 열한 번째 - 세션 최종 정정 — 순서 안 중요 + 슬롯 재사용 위해 `None`이 아닌 - `nil`, `table.insert` 대신 빈 슬롯 선형 탐색 등록), 함수면 - `v(value)` 호출+유지 — 같은 배열 하나로 통합). `:Wait(thread?)`는 - `thread`가 `nil`이면 +- [ ] Ref 콜백/대기자 실행 루프 — **[2026-08-24 6라운드 `H-7`로 재작성]** + `.Callbacks`는 **`{[callback|thread] = true}` 해시맵 셋**이다(배열 아님). + `:Set(value)`는 **`.Value`를 먼저 쓰고**, **순회 전 스냅샷을 뜬 뒤** + (`pairs` 순회 중 새 키 추가가 Lua에서 미정의라 — `H-23`과 같은 처방) + 키 타입으로 분기: `type(k) == "thread"`면 `Callbacks[k] = nil`로 소진 후 + `coroutine.resume(k, self)`(값이 아니라 **Ref 자신**), 함수면 `k(value)` + 호출 + 유지. **중복 등록은 dedup이 계약**이고 해제는 신설된 + **`:Uncallback(fn)`**(`Callbacks[fn] = nil` 한 줄). + 의사코드는 `base/ref-plan.md`가 소스. + **⚠️ 옛 배열 설계(빈 슬롯 선형 탐색 등록, `[i] = nil` 소진, `#t` border + 실측)는 전부 폐기됐다** — 해시맵엔 구멍도 border도 없다. + `:Wait(thread?)`는 그대로 — `thread`가 `nil`이면 `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` 반환(남의 thread를 여기서 대신 정지시킬 수 없어서) +- [ ] **[2026-08-24 신설, 6라운드 `H-7`]** `Ref:Uncallback(fn)` — 해제 경로. + `Effect`가 자기 `Ref` dep 콜백을 뗄 때 쓰고(`_refCallbacks`에 보관해둔 + 바로 그 클로저를 값으로 뗀다), `unbindLifetime`/`:Unsubscribe()`가 부른다 - [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/ `unbindLifetime`/`canBound`/`canExecute` 본체(인터페이스 자체는 M2로 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 @@ -977,7 +1136,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 함수들은 `InstData`에서 찾아 쓰기만 함. `bindLifetime`은 `gchold[value]=true`(강참조로 생존 보장)와 `BindData:SetWeak(value, "gchold"/"gcconn", ...)`(값이 자기 생존 판정 근거를 직접 들고 있게) - 둘만 하고, `unbindLifetime(value)`은 그 셋을 되돌림. **[2026-08-14 + 둘을 하고, `unbindLifetime(value)`은 그걸 되돌림. + **⭐ [2026-08-24 6라운드 `H-11`] 그리고 `isEffect(value)`면 분기가 하나 더 + 붙는다** — `handle._observers` 전부로 cascade하고 + `value:_bindDestroying(inst)` / `:_unbindDestroying()`를 부른다(전자는 + 주입 op `onDestroying`으로 `Destroying`을 연결하고 `Ref` dep 콜백을 + (재)등록, 후자는 그 대칭). 여기 원래 *"둘만 하고"*라고 적혀 있었는데 + 이 분기와 직접 모순이었다. **이게 `Effect`의 leaf 사망 cleanup을 실제로 + 발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다 + (그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md`와 + `base/effect-plan.md`가 소스. **[2026-08-14 열한 번째 세션] `canBound(value)`/`canExecute(value)`는 비공개 헬퍼 `isBoundAlive(value)` 하나(복사된 gcconn의 `.Connected` 또는 `.Subscribed`를 봄)를 공유하는 얇은 진입점 둘로 분리** — `bindLifetime`/ @@ -1016,6 +1184,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 > `archive/tag-attribute-load-time-registration-reversed.md`. +- [ ] **[2026-08-24 `H-39`]** `TagHandler`/`AttributeGroupHandler`가 자기 배열 + 자리의 `setOffsetSource(inst,k,None)`/`setLength(inst,k,0)`을 등록 — + **둘 다 0건이었다**(위 M2의 그 항목). 같이 **`type(k) == "number"` 가드**도 + 추가(`H-52` — `RefLeafHandler`가 2026-08-18에 받은 수정을 이 둘은 못 받았다) +- [ ] **[2026-08-24 `H-41`]** `AttributeGroupHandler.process`에 `groupClaimKeys` + 위치 claim 배선 — 5라운드 `AT-1`에서 `(inst, groupValue) → k`로 확정해놓고 + 의사코드에 안 들어가 있었다. **`nameClaims`보다 먼저** 해야 절반만 기록되는 + 중간 상태가 안 생긴다 +- [ ] **[2026-08-24 `H-27`]** `OnChangeHandler.process`에 `v == nil` 얼리리턴 — + 없으면 `None`으로 콜백을 끄는 게 실제로는 **나중에 터질 Connection을 새로 + 심는** 동작이 된다 +- [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Tag`/`Attribute` + 필드 추가(위 M2 항목의 "마일스톤마다" 규칙) - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) - [ ] `Handlers/OnChange.luau`(`OnChange(name)` 특수 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인