From c6fdf1b348e6e81a495472cdf324a3ae04efdbf7 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 21 Aug 2026 18:19:58 +0900 Subject: [PATCH] =?UTF-8?q?qa:=20=EA=B5=AC=ED=98=84=20=EC=A0=84=20QA=205?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=20=E2=80=94=20=EB=AC=B8=ED=95=AD?= =?UTF-8?q?=EC=A7=80=C2=B7=ED=9A=8C=EC=8B=A0=C2=B7=EC=A0=84=EB=9F=89=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20+=20=EA=B0=90=EC=82=AC=202=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설(205문항). 범위를 셋으로 좁힘 — (1) 4라운드에 문항이 아예 없던 영역(project-setup / quad-types, 그리고 문서가 아니라 실제 커밋된 M1 코드), (2) 그 이후 확정된 것 (Detach/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의 심화. 회신을 4차에 걸쳐 받아 전량 반영했고, 커밋 전 감사를 각도를 바꿔 2라운드 돌렸다. 주요 확정/역전: - slot._detached lazy화, KeyGone엔 새 값 반환도 error, Owned=false에서 Detach는 _detached에 안 들어감(rawUnmount) - Slot:Replace 신설 + rawReplace/rawAdd 의사코드 신설(문서에 정의가 없었음) - raw* 인자를 index로 전부 통일 — 오래 열려 있던 캐비엇 종결. 래핑은 raw* 바깥에서만(공개 표면 + settle), raw*는 물리 요소만 다룸 - 물리 조작을 주입 op로(mountInst/unmountInst/disposeInst, 이름 가칭) — base는 Parent를 모른다는 지적. mountInst는 0-based 절대 offset을 받음 - Dispatch.setLength에 anchor(생략 시 ownerKey) — 부기 키와 생명주기 앵커 분리, 4라운드 D-56 역전(archive로) - Dispatch.getOffsetAt 신설(pull) + 접두합 캐시(offsetDirtyFrom), setOffsetSource(None)은 얼리 리턴, None의 뜻을 "발행 채널 없음"으로 정정 - recompute가 owner 베이스에서 시작(중첩 offset이 부모 베이스를 못 받던 결함), _baseObserver로 깊은 전파, Offset Source identity 재사용(포탈), bk.N or 0(빈 Slot 크래시) - Effect(fn, ...deps) 확정 — Ref도 의존성(옛 "trailing args sugar 안 만듦" 역전), Tween:Mapped, groupClaimKeys 키 = (inst, groupValue) → k - 게이팅 먼저(M2로 앞당김) — 다만 대상이 Blocker가 아니라 공용 Gate 노드로 바뀌었고, 설계는 사용자 지시로 다음 세션(M2 착수를 막는 유일한 항목) 새 research 둘: gate-primitive.md(다음 세션이 이어받을 재료), state-epoch-validation.md(전파 중 Get이 섞인 값을 캐시하는 glitch — 정확성 결정이라 M3 전 결론 필요). 감사가 잡은 것 중 큰 것: 확정한 Owned가 Slot:List 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, effect-plan.md의 역전 배너 없는 자기모순, 그리고 손대지 않은 문서(ROADMAP 백로그·debounce-throttle-plan)가 "Gate는 M3에서"로 남아 있던 사각지대. doc-check.py ERROR 0. 상세는 qa-request/pre-implementation-qa-round5-followup.md (A~K절, 마지막이 최신). Co-authored-by: qwreey --- .claude/README.md | 5 +- .../bindlifetime-slot-owner-reversed.md | 57 + .claude/base/architecture.md | 2 +- .claude/base/attribute-plan.md | 20 +- .claude/base/debounce-throttle-plan.md | 11 +- .claude/base/dispatch-core-plan.md | 257 +++- .claude/base/effect-plan.md | 92 +- .claude/base/lifecycle-pattern.md | 43 +- .claude/base/slot-plan.md | 467 +++++- .claude/base/tween-plan.md | 22 +- .claude/base/ui-shorthand-plan.md | 6 +- .../pre-implementation-qa-round4-followup.md | 7 +- .../pre-implementation-qa-round5-followup.md | 802 ++++++++++ .../pre-implementation-qa-round5-response.md | 124 ++ .../pre-implementation-qa-round5.md | 1302 +++++++++++++++++ .claude/question.md | 115 +- .claude/research/gate-primitive.md | 99 ++ .claude/research/state-epoch-validation.md | 153 ++ .claude/session-summary.md | 32 + ...21-02-qa-round5-and-gate-epoch-research.md | 189 +++ .claude/todos.md | 33 +- ROADMAP.md | 64 +- 22 files changed, 3708 insertions(+), 194 deletions(-) create mode 100644 .claude/archive/bindlifetime-slot-owner-reversed.md create mode 100644 .claude/qa-request/pre-implementation-qa-round5-followup.md create mode 100644 .claude/qa-request/pre-implementation-qa-round5-response.md create mode 100644 .claude/qa-request/pre-implementation-qa-round5.md create mode 100644 .claude/research/gate-primitive.md create mode 100644 .claude/research/state-epoch-validation.md create mode 100644 .claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md diff --git a/.claude/README.md b/.claude/README.md index be60e47..05b6523 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,7 +24,7 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | | `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` 분해). **열린 질문 없음** — 사용자 지시로 **5라운드 문항지는 만들지 않는다**). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | +| `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]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 research 문서 둘(`gate-primitive.md`·`state-epoch-validation.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` | @@ -92,6 +92,8 @@ | `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 | | `slot-attach-decomposition.md` | **[2026-08-21 신설, 같은 날 확정]** `attachSlot`의 책임 분해 근거 기록 — **결론은 후보 (B) 분해 채택**이고 `base/slot-plan.md`에 반영 완료(`materializeSlotTree`+`mountSlotTree`+두 줄짜리 `attachSlot` 래퍼). 아래는 그 논의의 출발점. QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 flush 앞/뒤 어디에 둘지가 갈렸는데, 사용자가 그건 자리 선택 문제가 아니라 *"attachSlot 의 기능이 너무 다양해진게 문제"* 라고 짚어 확장 논의로 넘어간 것. 지금 `attachSlot`이 지고 있는 책임 일곱(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치 게이팅, 자식 배치, 재귀)과 순서 제약 일곱(각각 `RC-1`/`RC-3`/`RC-4` 등 실제로 밟은 버그 출처까지)을 모아두고, **C6(길이 최종값은 flush 뒤에야 정해짐)와 C7(부기가 물리보다 먼저)이 단일 함수로는 동시 만족 불가능**함을 보인 뒤 분해 후보 넷(현행 유지 / `prepare`+`mount` 2단 / 3단 / 문서만)을 대조. 사용자 확정 논거는 *"지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 생각함"*. 정본은 여전히 `base/slot-plan.md` | 하 — **[2026-08-21] 결론 확정·반영 완료**, 이제 근거 기록용 | | `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리(**[2026-08-19]** 그 질문은 `base/debounce-throttle-plan.md`로 전부 해소·승격 완료), `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State|T>` → `State` 평탄화 항목 신설(백로그) — `State>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | +| `gate-primitive.md` | **[2026-08-21 신설]** `Blocker`가 쓰는 게이티드 State 노드를 공용 `Gate`로 일반화 — 상류 emit을 가로채 내려보낼지 정책이 정하는 노드. `Blocker`/`Debounce`/`Throttle`이 그 위의 정책이 됨. **방향은 사용자 확정(게이팅을 M2로 앞당김), 이름(`Gate` vs `Gater`)·표면·`Blocker`와의 결합 방식이 미정** | **상 — M2 착수 전 필요**(`Dispatch.drive`의 배치 등록이 이 게이팅을 전제) | +| `state-epoch-validation.md` | **[2026-08-21 신설]** State 재계산 판정을 `invalid` 플래그에서 **루트 Source 에포크 비교**로 바꾸는 안(사용자 제안) — DFS 전파 도중 `Get()`이 섞인 값을 캐시하는 glitch를 없앰. 성능이 아니라 **정확성** 결정이고 State 내부 표현을 바꾸므로 M3 전에 결론 필요. 에이전트 분석: 진단·방향 모두 타당. **[같은 날 후속 확정] 중복 *통지*도 같은 장치로 접고**(emit이 `(source, count)`를 실어오므로 판정 O(1)), "선언 안 된 의존성 UB 명문화"는 사용자 기각으로 빠짐 — 대신 노드가 `seen`(전파 dedup)/`computedAt`(캐시 검증) 두 카운트를 따로 들어야 한다는 구현 요구가 생김 | **상 — M3(State 구현) 착수 전 필요** | | `quad-recursive-acronym.md` | **[2026-08-14 신설]** GNU/WINE류로 `Quad`를 재귀 약어화하는 카피 브레인스토밍 — 설계 결정도 착수 게이팅도 아니고 나중에 README.md 헤딩 등에 쓸 캐치프레이즈 후보 모음. 자학 개그 방향(기각)과 지연평가/재귀·커링/펑터/클로저를 자랑하는 방향(채택 후보, 미확정) 정리 | 하 — 카피 소재, 설계 상의 필요 없음. 사용자가 최종 문구 고르면 반영 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | | `doc-include-plan.md` | **[2026-08-14 신설]** 문서 stale 감소용 include 도구 `doc-include.py`(가칭) — 원본 파일에 `` 류 마커로 요약 구간을 표시해두면 인용하는 문서가 그 구간을 기계적으로 추출해 붙여넣게 하는 도구. `doc-check.py`(사후 탐지)와 짝을 이루는 사전 차단 장치. AsciiDoc tagged include/markdown-magic이 선례, build vs buy 검토 후 Python 표준 라이브러리로 직접 제작(~100줄) 채택. 파일럿은 `.claude/session-summary.md` ← `.claude/session/*.md` 요약 마커부터(CLAUDE.md 분할로 목적지가 "통째로 생성되는 파일"이 돼 단방향 생성으로 단순화됨) | 하 — M0/설계 게이트와 무관한 메타 도구. **[2026-08-16 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) | @@ -124,6 +126,7 @@ | `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(2026-08-14 다섯 번째 세션에 폐기됐다가 열한 번째 세션에 별도 진입점으로 재도입 — `canExecute`와 판정 로직만 공유)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 | | `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` | | `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 | +| `bindlifetime-slot-owner-reversed.md` | **[역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`]** "`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하고 `isBoundAlive`에 세 번째 분기를 둬라"(4라운드 `D-56`) — `Dispatch.setLength`가 **부기 키와 생명주기 앵커를 따로 받도록** 바뀌면서 요구사항 자체가 사라짐(앵커는 언제나 물리 target, 모든 호출부가 이미 그 값을 안다). 부수로 형태 미정이던 "세 번째 분기" 항목도 같이 닫힘. 현행은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단 | | `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime`이 `.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`(**2026-08-14 열한 번째 세션에 별도 진입점으로 재도입 — 이 문서 하단에 addendum**)와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` | | `tag-attribute-load-time-registration-reversed.md` | **[역전됨, 2026-08-14 열두 번째 세션 신설, 2026-08-18 재역전으로 원래 결론 쪽이 다시 현행]** "`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 quad-base 모듈 로드 시점에 스스로 등록한다"(열한 번째 세션 "네 번째, 최종 정정") — 2026-08-14 열두 번째 세션엔 `base/lifecycle-pattern.md`가 거부한 `InitNamespace`류 top-level 부작용 패턴과 같은 클래스라며 틀렸다고 보고, 등록 주체를 백엔드 팩토리로 정정했었음. **그런데 2026-08-18 구현 전 QA에서 다시 뒤집힘(`D-7`)** — 백엔드 팩토리가 등록 주체면 quad-roblox를 아예 안 붙인 상태에서 "provider가 초기화됐는지" 안내 경로 자체가 안 돌기 때문. **지금 현행은 이 문서가 역전이라 부르던 원래 결론(quad-base 자신이 로드 시점에 등록)과 같은 방향** — 상세는 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절, 재역전 논의 원문은 `qa-request/pre-implementation-qa-round1.md`의 "D-7" 절 | diff --git a/.claude/archive/bindlifetime-slot-owner-reversed.md b/.claude/archive/bindlifetime-slot-owner-reversed.md new file mode 100644 index 0000000..7c1e759 --- /dev/null +++ b/.claude/archive/bindlifetime-slot-owner-reversed.md @@ -0,0 +1,57 @@ +# [역전됨, 2026-08-21] `bindLifetime`의 첫 인자가 Slot일 수 있다는 요구사항 + +**상태**: 역전됨. 2026-08-20 구현 전 QA 4라운드 `D-56`에서 확정돼 +`base/lifecycle-pattern.md`의 `(1-1)` 절로 들어갔다가, **2026-08-21 구현 전 QA +5라운드 `C-4`에서 통째로 뒤집혔다.** + +**왜 뒤집혔나**: 사용자 지적 — *"애초에 Slot 이 effect 나 다른 요소들을 소유할 +수가 없다 … 실제 observer/effect 는 실제 inst 에 불림. slot in slot 에서 slot 을 +유지하는건 이미 slot 의 강참조 배열이 해결해주는데, 우리가 왜 slot 을 소유 대상으로 +둘 수 있게 한거였는지 다시 생각해봐야할 부분인듯?"* 검토 결과 **부기 키와 +생명주기 앵커를 한 인자가 겸하고 있던 게 원인**이었고, `Dispatch.setLength`가 +`anchor`를 따로 받도록 바꾸는 것만으로 이 요구사항 전체가 사라졌다. 지금 유효한 +결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단과 +`base/lifecycle-pattern.md`의 `(1-1)` 포인터. + +**부수 효과**: 이 절이 요구하던 `isBoundAlive`의 "세 번째 분기"(gcconn도 +`.Subscribed`도 없는 Slot-owned 바인딩을 판정하는 분기)는 **형태가 미정인 채로 +열려 있던 항목**이었는데, 역전과 함께 필요 자체가 없어졌다. + +--- + +## 역전 전 원문 + +#### (1-1) ⚠️ 첫 인자가 물리 Instance가 아닐 수도 있다 — 백엔드가 반드시 핸들링할 것 (2026-08-20 구현 전 QA 4라운드 `D-56`) + +**위 구현 스케치는 `inst`가 항상 Roblox Instance라고 가정하고 `InstData`에서 +gcconn/gchold를 찾는데, 실제 호출부 중엔 `inst` 자리에 `Slot`이 오는 경로가 +이미 있다.** `Dispatch.setLength(ownerKey, i, len)`이 그것 — +`base/dispatch-core-plan.md`의 "`setLength` 구현" 절이 `bindLifetime(ownerKey, +observer)`를 부르는데, 그 `ownerKey`는 Slot-in-Slot 중첩에서 **Slot 자신**이다 +(`base/slot-plan.md`의 "재귀 메커니즘" 절 — `attachSlot`이 `ownerKey`로 자기 +자신을 넘겨 최상위/중첩을 같은 함수로 통합한 그 설계). + +**사용자 판정(2026-08-20)**: *"ownerKey 가 Slot일 수도 있음. 각 엔진의 +bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong +으로 gchold 비슷한걸 수행하면 됨."* + +- **계약 두 개(위 절)는 그대로 유지된다** — 바뀌는 건 "그 계약을 무엇으로 + 구현하는가"뿐. 물리 Instance면 gcconn 트릭이 두 계약을 다 만족시키고, + Slot이면 **Slot 자신이 살아있는 동안 `value`를 붙잡는 강참조**(Slot 안의 + 필드든, `Relate(slot)`에 `SetStrong`이든)와 **`value`가 그 Slot의 생존을 + 되물을 수 있는 근거**를 백엔드가 제공하면 된다. +- **왜 gcconn을 못 쓰는가**: gcconn 트릭은 + `inst:GetPropertyChangedSignal("ClassName")`에 의존하므로 엔진 객체가 아닌 + 값(Slot은 평범한 Lua 테이블)엔 걸 수가 없다. Slot은 대신 **자기 자신이 + reachable한가**가 곧 생존이라, `Relate(slot)`가 weak-keyed인 것만으로 + "Slot이 죽으면 기록도 같이 사라진다"가 성립한다. +- **`isBoundAlive`의 판정 분기도 이 경로를 알아야 함** — 지금 코드는 + gcconn이 없으면 곧바로 `.Subscribed` 폴백으로 떨어지는데, Slot-owned + 바인딩은 gcconn도 `.Subscribed`도 없어서 **살아있는데 `canBound`가 참으로 + 잘못 나온다**(= 이중 바인딩 가드가 이 경로에선 안 걸림). 백엔드 구현이 + 세 번째 분기를 추가하거나, Slot 쪽 홀더 존재 자체를 판정 근거로 삼아야 함. +- **⚠️ 정확한 형태는 아직 미확정 — M2/M3 구현 시 확정할 것.** "Slot 안"(필드) + 이냐 "바깥"(`Relate`)이냐, `isBoundAlive`의 세 번째 분기를 어떤 모양으로 + 둘지가 열려 있다. 지금 확정된 건 **"첫 인자가 Instance라고 가정하면 안 + 된다"는 요구사항 자체**뿐. + diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index ae48a33..41076a4 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -291,7 +291,7 @@ quad/ ├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존 └── src/ ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션) - ├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) + ├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드, 이름 가칭] mountInst(target,element,index)/unmountInst(element)**(물리 트리 조작 — base는 `Parent`를 모른다, 확정 시 이 목록에 정식 등재) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) ├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) ├── Handlers/ │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index c1c92dd..59b183d 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -244,9 +244,23 @@ end 바람. 다만 충분하다고 생각하는 이름임."*). `nameClaims`(이름 → 그 이름을 잡은 키 객체)와 나란히 놓이는 두 번째 레지스트리이고, 이름 자체가 "그룹이 claim한 키들"을 가리켜 역할이 드러남. - - **⚠️ 키 설계는 여전히 미정** — 확정된 건 이름뿐이고, `(inst, groupValue) → k` - 인지 `groupKey` 단위인지, 그리고 `nameClaims`와 어떤 순서로 확인하는지는 - M10 구현 전에 정한다. `question.md` 참고. + - **✅ [해소, 2026-08-21 구현 전 QA 5라운드 `AT-1`] 키는 `(inst, groupValue) → k`로 + 확정.** **사용자 판정**: *"(inst, groupValue) → k 이면 충분하다. group 에 따라 + key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 필요가 없고, 그건 key->name + 이 유일성을 검증해준다. {a,a} 는 단순 그룹이 이미 할당된 키가 있나를 보기만 + 위함임."* + - **왜 이걸로 충분한가**: 이 레지스트리가 잡아야 하는 건 **같은 그룹 객체가 + 같은 `inst`의 두 위치에 놓이는 것** 하나뿐이다. 서로 다른 그룹끼리의 충돌은 + `groupKey(v, name)`가 그룹별로 다른 키 객체를 만들고 그 키의 이름 claim을 + `nameClaims`가 검증하므로 이미 걸린다 — 여기서 다시 볼 필요가 없다. + - **판정**: `process` 시점에 `groupClaimKeys[inst][groupValue]`를 보고, 비어 + 있으면 `k`를 기록하고 통과, **이미 다른 `k`가 있으면 error**. retract에서 + 자기 `k`일 때만 지운다(위 "해제 → 재클레임 순서" 항목의 순서 보장 위에서 + 성립). + - **`nameClaims`와의 순서**: 위치 claim이 **먼저**다 — 같은 그룹의 두 번째 + 위치는 이름 claim까지 갈 것 없이 그 자리에서 거부되어야 하고, 그래야 + `nameClaims`에 절반만 기록되는 중간 상태가 안 생긴다. + - 이걸로 `Frame { a, a }` 갭(구 `AT-11`/5라운드 `AT-2`)도 **같이 닫힌다.** - **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게 **정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`) — 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹 diff --git a/.claude/base/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md index ee84c8e..cf64f31 100644 --- a/.claude/base/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -1101,8 +1101,11 @@ additional-primitives-plan.md`가 원래 "안 만들어도 된다"고 판단했 **의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면(`setTimeout`/ `clearTimeout`) + `Blocker`(gated state) + `Ref`. 전부 M3/M8 안에서 -확정되는 것들이라 그 이후 언제든 얹을 수 있다. **`Blocker` 구현 -시점(M3)에 게이트 노드를 공용으로 빼두는 것만은 여전히 그 시점에 해야 -함** — 1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 -설계하게 됨. 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무 +확정되는 것들이라 그 이후 언제든 얹을 수 있다. **[정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를 +공용으로 빼는" 작업은 M3가 아니라 M2로 앞당겨졌다** — 사용자 결정 +"게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에 의존하므로). +1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 설계하게 되는 +것은 그대로이고, 바뀐 건 **언제**뿐이다. 표면/이름은 아직 미정 — +`research/gate-primitive.md`가 소스(이 문서의 1절이 그 일반화를 처음 +권고한 자리로 거기 인용돼 있다). 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무 때나 나중에 얹으면 된다. diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 9fc91fe..6e8d9c6 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -741,6 +741,10 @@ end Dispatch 핸들러가 아니라 독립 탑레벨 유틸이지만, `isSlot`이 아닌 값은 `elementOwner` 같은 순수 부기 판정 뒤에 마지막 한 줄만 `disposeInst(inst: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) — + **[2026-08-21 5라운드] 같은 계열로 `mountInst(target, element, index)`/ + `unmountInst(element)`가 추가될 예정**(base는 `Parent`를 모른다는 지적에서 + 나옴, `base/slot-plan.md`의 "물리 조작은 주입 op다" 절). **이름은 아직 + 가칭**이라 이 목록에 정식 등재하는 건 그 확정 시점에 한다 — 2026-08-14 열 번째 세션에 같은 원칙으로 확정. - **backend 소유**: `Property`/`Event`/`OnChange`(Reflection·시그널 같은 엔진 개념 자체가 로직), `InstanceChild`, `Slot`의 실제 부모 조작 @@ -1259,9 +1263,13 @@ Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드 **`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**: ```lua -Dispatch.setLength(inst, i, len: number | State) -Dispatch.setOffsetSource(inst, i, offset: Source | None) +Dispatch.setLength(ownerKey, i, len: number | State, anchor?) +Dispatch.setOffsetSource(ownerKey, i, offset: Source | None) +Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 자리의 절대 offset ``` +**[2026-08-21 5라운드]** `anchor`는 생명주기 앵커(생략 시 `ownerKey`, 자세한 +건 아래 "`setLength` 구현" 절), `getOffsetAt`은 발행 채널(`Source`) 없이 +숫자만 필요한 쪽(물리 삽입 위치 등)이 쓰는 pull 경로. **[2026-08-11 세션] 첫 인자(`inst`)는 물리 Instance일 필요가 없음 — `Relate`가 weak table 기반이라 아무 테이블이나 키로 가능.** 이 사실을 @@ -1320,8 +1328,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) 강제하지 않음), 반응형이 필요하면 자기 `userdata` 안에 직접 `Source`를 만들어 `Frame { LayoutOrder = layoutOrder:With(offset):Compute(fn) }`처럼 써넣으면 됨 — 새 메커니즘 불필요. 상세는 `base/slot-plan.md`의 - `Slot:List` 절 참고. **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에 - 참여할 게 없다는 명시적 선언. 대상은 일반 `Ref`뿐 아니라 **그 배열 + `Slot:List` 절 참고. **⭐ [정정, 2026-08-21 G절] `None`은 "발행 채널이 없다"는 뜻이다** — + 옛 서술은 "실제 마운트를 하지 않는 위치"였는데, **plain 요소도 `None`을 + 등록**(마운트는 하지만 그 자리의 offset을 반응형으로 받아볼 소비자가 없음)하므로 + 정확하지 않았다. **순서 계산에 참여하는지는 `setLength`가 답한다**(0이면 안 + 차지). 숫자가 필요하면 채널 유무와 무관하게 `Dispatch.getOffsetAt(ownerKey, i)`을 + 부르면 된다. 아래 "짝을 맞춰 `0`" 규칙은 **값이 정말 없는 자리**(Ref/`nil`)에만 + 해당한다 — plain 요소는 `None` + `setLength(1)`이 정상이다. 대상은 일반 `Ref`뿐 아니라 **그 배열 위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로 캐우칭된 미전달 Ref) — `setLength`도 같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의 @@ -1461,6 +1474,13 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이 거는 것처럼 순수하게 사용자 코드가 만드는 경우뿐인데, 이건 이미 확정된 "일반적인 재진입/무한루프는 방어 안 함, provider/사용자 코드 버그로 간주"(2026-08-04) 원칙 그대로 두면 됨 — 별도 가드를 만들 근거가 없음. +**⭐ [2026-08-21 5라운드 `DC-14`] 게다가 그 마지막 경로조차 사용자에겐 +막혀 있다** — `updateFn`이 도는 Slot은 정의상 `_listed`이고, `_crudUsed` ↔ +`_listed` 상호 배타 가드(`base/slot-plan.md`) 때문에 그 Slot의 공개 CRUD가 +이미 error다(사용자 지적: *"외부 입장에서는 그럴 방법이 없어보인다. crud 가 +list 시에는 더이상 불가능해지기 때문"*). 즉 같은 `(ownerKey, bk)` 재진입은 +**정상 API로는 만들 수 없다** — `updateFn` 안에서 *다른* Slot을 건드리는 건 +다른 `bk`라 무관하다. **결론: `recompute`는 off-by-one만 고친 순수 버전으로 유지, 재진입 가드 없음.** @@ -1477,12 +1497,98 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 ` 아니라 이미 있는 단방향 흐름 원칙을 recompute라는 구체 지점에 적용한 것뿐, 그래서 별도 방어 로직도 필요 없음. +**⭐ [2026-08-21 구현 전 QA 5라운드 G절, 사용자 확정] 이 절이 두 번 고쳐졌다.** +`mountInst`에 물리 삽입 위치를 어떻게 주느냐는 질문에서 결함 둘이 드러났고, +사용자가 제시한 방향으로 정리됐다: + +1. **`sourceList`의 `None`은 "발행 채널 없음"만 뜻한다** — 예전엔 "실제 마운트를 + 하지 않는 위치"라고 정의해놓고 정작 plain 요소를 `None` + `setLength(1)`로 + 등록하고 있었다(그래서 그 자리의 offset 숫자가 **계산조차 안 됐고**, DOM류 + 백엔드가 삽입 위치를 알 방법이 없었다). **참여 여부는 `lengthList`가 이미 + 표현하므로** `sourceList`는 "반응형으로 받아볼 채널이 있나"만 답하면 된다. +2. **숫자가 필요한 쪽은 `Dispatch.getOffsetAt(ownerKey, i)`로 직접 뽑는다** + (사용자 제안: *"setOffsetSource 에선 source 를 받으면 그건 set 해주지만, + 아니면 그냥 얼리리턴에 None 으로만 둬주고, getOffsetAt 은 직접 호출하는걸로"*) + — 모든 자리에 숫자를 밀어 넣는 네 번째 병렬 배열을 만들지 않고 **pull로** + 둔다("관측해야 실체화된다" 원칙과 같은 결). +3. **`sum`이 owner의 자기 offset에서 시작한다** — 예전엔 `0`이라 **depth ≥ 2에서 + 중첩 Slot의 자식 offset이 부모 베이스만큼 어긋났다**(depth 1만 쓰던 동안 + 드러나지 않았음). **베이스를 따로 저장하지 않는다** — Slot이면 자기 + `.Offset`이 곧 그 값이고(부모가 먼저 설정해주므로 이미 정확), 최상위 물리 + inst엔 베이스라는 개념이 없어 항상 0이다. 한때 `bk.base` 필드에 복사해두는 + 안을 적었다가 **사용자 지적으로 걷어냈다**: *"bk.base 가 왜 필요한거임? … + 이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데, 부모 slot 의 offset 읽는게 + 이미 정확해 … 최상위에선 애초에 base자체가 없지 않아? 항상 0 일텐데."* + 같은 값을 두 곳에 두면 갈라진다는, 이 코퍼스가 반복해서 물린 패턴 그대로다. + **`isSlot` 분기가 남는 건 타입 분기라서가 아니라 검사할 다른 방법이 없어서다** + — `ownerKey.Offset`을 그냥 인덱싱해 확인하는 duck-typing은 Roblox userdata에서 + 정의 안 된 키 인덱싱이 에러를 던질 수 있어 금지돼 있다(`base/brand-plan.md`의 + duck-typing 기각 근거). +4. **깊은 전파를 위해 중첩 Slot은 자기 `Offset`을 관측한다** — 앞 형제의 길이가 + 변해 자기 베이스가 밀리면 자기 자식들의 offset도 다시 계산돼야 한다(사용자: + *"자식 slot 의 offset 을 다시 설정해주기 위함이구나. offset의 깊은 전파를 + 위한거군"*). + ```lua +-- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환. +-- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — mountInst의 삽입 위치, +-- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다. +function Dispatch.getOffsetAt(ownerKey, i) + local bk = getBookkeeping(ownerKey) + -- [2026-08-21 사용자 제안] **접두합 캐시 + "이 인덱스 초과부터 무효" 마커.** + -- 매번 1..i-1을 다시 더하면 호출당 O(i)라 reconcile 전체가 O(N²)가 된다. + -- 캐시된 접두합(`bk.offsetCache[j]` = j 자리의 절대 offset)은 **그 앞쪽이 + -- 안 바뀐 한 계속 유효**하므로, 무효화는 "바뀐 자리부터 뒤"만 하면 된다. + local from = bk.offsetDirtyFrom or 1 -- 이 인덱스부터가 무효 + if i < from then + return bk.offsetCache[i] -- 앞쪽은 그대로 유효 — O(1) + end + -- 무효 구간만 이어붙여 다시 계산한다(유효한 마지막 자리의 값에서 출발). + local sum = if from > 1 then bk.offsetCache[from - 1] + contribution(bk, from - 1) + else (if isSlot(ownerKey) then ownerKey.Offset:Get() else 0) + for j = from, i - 1 do + bk.offsetCache[j] = sum + sum += contribution(bk, j) -- lengthList[j](State면 :Get()) + end + bk.offsetCache[i] = sum + bk.offsetDirtyFrom = i + 1 -- 여기까지는 이제 유효 + return sum +end + +**⭐ [2026-08-21 사용자 제안] `getOffsetAt`의 접두합 캐시 — 무효화 규칙** + +`offsetCache`/`offsetDirtyFrom`이 성립하려면 **"앞이 안 바뀌면 뒤도 안 바뀐다"**는 +단조성이 지켜져야 한다. 그래서 아래 셋 중 하나라도 일어나면 `offsetDirtyFrom`을 +그 인덱스로 내린다(더 작은 값으로만 갱신): + +1. **`setLength(ownerKey, i, ...)`** — `i` 자리의 기여도가 바뀌므로 `i + 1`부터 + 무효(그 자리 자신의 offset은 안 변한다). State 길이가 나중에 emit할 때도 같다. +2. **`spliceArraysUp`/`spliceArraysDown`(자리 삽입·삭제)** — 그 인덱스부터 전부 + 밀리므로 그 자리부터 무효. +3. **owner의 베이스가 바뀜**(`ownerKey.Offset` 변경 = `_baseObserver`가 도는 + 그 순간) — **전체 무효**(`offsetDirtyFrom = 1`). + +**⚠️ 아직 안 정한 것**: `recompute`가 이미 전체를 순회하며 같은 접두합을 +계산하므로, **그 순회가 캐시를 그대로 채우게 할지**(그러면 `recompute` 직후엔 +캐시가 항상 완전) 아니면 두 경로를 분리해 둘지. 전자가 낭비가 없어 보이지만 +`recompute`는 `Set` 캐스케이드를 유발할 수 있어 호출 빈도가 다르다 — +구현 시 확인. + local function recompute(ownerKey, bk) + -- [2026-08-21 G절] `0`이 아니라 이 owner의 베이스에서 시작한다. + -- 베이스는 별도로 저장하지 않는다 — Slot이면 자기 `.Offset`이 곧 그 값이고 + -- (부모가 먼저 설정해두므로 이미 정확하다), 최상위 물리 inst엔 베이스가 + -- 아예 없어 항상 0이다. 위 `getOffsetAt`과 같은 식. + local base = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0 local sum = 0 - for i = 1, bk.N do + -- [2026-08-21 5라운드 감사] `bk.N or 0` — **빈 Slot 크래시 방어**. + -- `bk.N`은 `setLength`가 처음 불릴 때 생기므로(`bk.N = math.max(bk.N or 0, i)`), + -- 요소가 하나도 없는 Slot(`Slot()` 직후, 데이터가 빈 `:List` 등)은 `N`이 `nil`인 + -- 채로 `materializeSlotTree` 끝의 recompute에 도달한다 — `for i = 1, nil`은 + -- 그 자리에서 터진다. 빈 Slot은 완전히 정상적인 상태라 이건 방어가 아니라 계약. + for i = 1, bk.N or 0 do local offset = bk.sourceList[i] - -- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라 + -- offset은 실제 Source이거나 None(발행 채널 없음) — None은 truthy라 -- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함. -- [전면 정정, 2026-08-20 QA 4라운드 `C-6`] `nil`은 skip이 아니라 error. -- 도달 경로가 없다는 게 재추적 결론이므로(bk.N=실제 개수, 배치 중엔 @@ -1492,14 +1598,14 @@ local function recompute(ownerKey, bk) if offset == nil then error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)") end - if offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set - offset:Set(sum) + if offset ~= None and offset:Get() ~= base + sum then -- 실제로 다를 때만 Set + offset:Set(base + sum) end local v = bk.lengthList[i] sum += (if isState(v) then v:Get() else v) end if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then - ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스 + ownerKey.Length:Set(sum) -- **Length엔 base를 안 더한다** — 길이는 위치와 무관 end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절) end ``` @@ -1536,7 +1642,13 @@ Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지 그건 호출하는 배치 쪽 책임): ```lua -function Dispatch.setLength(ownerKey, i, len) +-- [시그니처 변경, 2026-08-21 구현 전 QA 5라운드 `C-4`] 4번째 인자 `anchor` 신설 — +-- **부기 키(`ownerKey`)와 생명주기 앵커(`anchor`)를 분리**한다. 아래 절 참고. +-- **생략하면 `ownerKey`** — 최상위(물리 inst가 곧 owner)에선 둘이 같은 값이라 +-- 기존 3-인자 호출부가 전부 그대로 맞고, **`ownerKey`가 Slot일 때만** 물리 +-- target을 명시적으로 넘기면 된다(그 경우에만 둘이 갈린다). +function Dispatch.setLength(ownerKey, i, len, anchor) + anchor = anchor or ownerKey local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성 local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고) @@ -1557,7 +1669,8 @@ function Dispatch.setLength(ownerKey, i, len) if isState(len) then local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회 실행도 게이팅됨 - bindLifetime(ownerKey, observer) -- ownerKey 생명주기에 귀속, Subscribe 아님 + bindLifetime(anchor, observer) -- **물리 target**의 생명주기에 귀속, Subscribe 아님 + -- (ownerKey는 부기 키일 뿐 — 아래 절) bk.observers[i] = observer else gatedRecompute() -- 상수 길이도 같은 게이트를 통과 — setLength 자신은 recompute를 직접 안 부름 @@ -1565,6 +1678,39 @@ function Dispatch.setLength(ownerKey, i, len) end ``` +**⭐ [2026-08-21 구현 전 QA 5라운드 `C-4`] 부기 키와 생명주기 앵커는 별개다 — +4라운드 `D-56`의 결론을 되돌린다.** + +4라운드는 "`ownerKey`가 Slot일 수 있으니 **백엔드의 `bindLifetime`이 Slot을 +첫 인자로 받는 경우를 핸들링**하고, `isBoundAlive`에 세 번째 분기를 둬라"로 +결론냈었다. 5라운드에서 사용자가 그 전제 자체에 의문을 제기했고(*"애초에 +Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 observer/effect 는 +실제 inst 에 불림 … 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 +다시 생각해봐야할 부분"*), 검토 결과 **되돌리는 쪽이 맞다**: + +- 이 Observer가 살아야 하는 기간은 "이 Slot이 **그 물리 트리에 마운트돼 + 있는 동안**"이고, 그건 `physicalTarget`이 정확히 표현한다. Slot 자신의 + 생존은 부모의 `_elements` 강참조가 이미 보장한다. +- **`setLength`가 불리는 모든 자리에서 물리 target을 이미 알고 있다** — + `Dispatch.drive`(=`inst`), `materializeSlotTree`(=`physicalTarget`), + 런타임 단건 `rawAdd`/`rawReplace`(=`self._mountedInst`). +- 그래서 **`bindLifetime`의 첫 인자는 항상 물리 Instance**로 되돌아가고, + `base/lifecycle-pattern.md`가 지고 있던 백엔드 요구사항(비-Instance 첫 인자 + 핸들링)과 `isBoundAlive`의 **세 번째 분기가 통째로 불필요**해진다(그건 + 아직 형태가 미정인 채 열려 있던 항목이었다). 옛 결론 원문은 + `archive/bindlifetime-slot-owner-reversed.md`. +- **포탈(언마운트→재마운트)에서도 자연히 맞는다** — `unmountSlotTree`가 + `bk.observers`를 `unbindLifetime`하고, 재마운트 시 `materializeSlotTree`가 + 새 `physicalTarget`을 앵커로 다시 등록한다. +- **`getBookkeeping(ownerKey)`/`getBlocker(ownerKey)`는 그대로 Slot을 키로 + 쓴다** — 그건 `Relate`의 weak 키일 뿐 생명주기 앵커가 아니다. +- **`anchor`는 `len`이 State일 때만 실제로 쓰인다**(상수 길이는 Observer를 + 안 만들므로). **생략 시 `ownerKey`로 폴백**하므로 최상위 호출부 + (`Dispatch.drive`, `ProcessedPreRefHandler`/`NilHandler` 등 `inst`를 owner로 + 쓰는 자리 전부)는 **기존 3-인자 그대로 두면 된다** — 거기선 `ownerKey`가 곧 + 물리 target이다. 4번째 인자를 실제로 넘겨야 하는 건 **`ownerKey`가 Slot인 + 자리**(`materializeSlotTree`의 등록 루프, 런타임 `rawAdd`/`rawReplace`)뿐이다. + `:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는 본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst 또는 Slot 자신)가 죽을 때 같이 죽어야 함 — `:Subscribe()`는 명시적 @@ -1616,24 +1762,36 @@ end 호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고, `ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위 재귀 케이스)도 같이 확정시킨다. + - **⭐ [2026-08-21 5라운드 `DC-11`] 이 마지막 호출이 실제로 하는 일은 + "offset 채우기"가 아니다.** offset은 3번의 즉시 계산이 등록 시점마다 + 이미 정확히 넣어뒀고(position `i`의 offset은 `1..i-1`의 길이 합인데 + 그것들은 `i`보다 먼저 등록되므로), 이 호출에서 `offset:Get() ~= sum` + 가드에 걸려 대부분 아무것도 안 쓴다. 실제 역할은 둘 — + **(a) `ownerKey`가 Slot이면 `ownerKey.Length`(= 기여도 합) 확정** + (사용자 추측대로 이게 주 목적), **(b) 등록된 뒤에 값이 바뀐 길이가 + 있으면 그 뒤 형제들의 offset 교정**. 그래서 `ownerKey`가 물리 `inst`인 + `Dispatch.drive` 경로에선 (a)가 없어 사실상 검증 패스에 가깝지만, + O(N) 순회에 `Set`이 거의 없으므로 분기해서 빼지 않고 그냥 항상 부른다. -**`setOffsetSource`의 즉시 계산(2026-08-18 신설)** — 등록되는 그 자리에서 -`bk.lengthList[1..i-1]`을 합산해 곧바로 `:Set`한다(단 `source == None`이면 -스킵 — 참여 안 하는 자리는 계산할 게 없음): +**`setOffsetSource`의 즉시 계산(2026-08-18 신설, 2026-08-21 G절에 정리)** — +등록되는 그 자리에서 **`Dispatch.getOffsetAt(ownerKey, i)`**(= 베이스 + +`bk.lengthList[1..i-1]` 합)를 구해 곧바로 `:Set`한다. `source == None`이면 +**얼리 리턴** — 발행할 채널이 없으니 계산할 이유가 없고, 그 자리 숫자가 +필요한 쪽(예: `mountInst`의 삽입 위치)은 `getOffsetAt`을 직접 부른다: ```lua +-- [정리, 2026-08-21 G절] 합산 루프가 `Dispatch.getOffsetAt`으로 빠지면서 +-- 이 함수는 "등록 + (채널이 있으면) 즉시 1회 발행"만 남는다. function Dispatch.setOffsetSource(ownerKey, i, source) local bk = getBookkeeping(ownerKey) bk.sourceList[i] = source - if source ~= None then - local sum = 0 - for j = 1, i - 1 do - local v = bk.lengthList[j] -- 배치가 순서대로 처리되므로 1..i-1은 항상 이미 등록돼 있음 - sum += (if isState(v) then v:Get() else v) - end - if source:Get() ~= sum then - source:Set(sum) - end + if source == None then + return -- 발행 채널이 없는 자리 — 계산할 이유가 없다. 숫자가 필요하면 + -- 그때 `Dispatch.getOffsetAt(ownerKey, i)`을 직접 부른다. + end + local offset = Dispatch.getOffsetAt(ownerKey, i) -- 배치가 순서대로 처리되므로 + if source:Get() ~= offset then -- 1..i-1은 항상 이미 등록돼 있음 + source:Set(offset) end end ``` @@ -1698,8 +1856,9 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체 전제할 수 있어야 자기 일을 할 수 있다. 연산마다 선후가 다르면 백엔드 작성자가 매번 다시 확인해야 한다. - **각 연산에 적용하면**: - - `rawAdd` — `Length:Set(newCount)`(→ 뒤 형제 offset 갱신 동기 완료) → - `element.Parent = target`. 이미 이렇게 확정돼 있음(아래 문단). + - `rawAdd` — 부기(`setOffsetSource`/`setLength` 등록 → `recompute`) 완료 → + 물리 마운트(`mountInst`). **[정정, 2026-08-21 5라운드 `C-2`]** 예전엔 여기 + `Length:Set(newCount)`라고 적혀 있었으나 **틀렸다** — 아래 문단 참고. - `rawRemove`/`rawUnmount`/**`rawDetach`**(**[2026-08-21]** `Detach` 경로용으로 신설된 세 번째 형제 — 소유권을 **유지**한 채 언마운트만 한다는 점만 다르고 순서는 같음, `base/slot-plan.md`) — 파괴/언마운트 → @@ -1721,11 +1880,30 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체 **동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: -Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder -갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제 -트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set` -자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위 -`Get` 가드와 같은 원칙을 호출부에서도 적용). +Slot의 `rawAdd`는 **부기를 먼저 완결**하고(그 자리의 `setOffsetSource`/ +`setLength` 등록 → `recompute` → 뒤 형제 offset 갱신이 동기적으로 여기서 끝남) +그 다음에 물리 마운트(`mountInst`)를 호출한다 — 트리에 보이는 시점엔 +다운스트림이 이미 정합적. + +**⭐ [전면 정정, 2026-08-21 구현 전 QA 5라운드 `C-2`] `rawAdd`가 +`self.Length:Set(newCount)`를 직접 부른다는 옛 서술은 틀렸다.** 사용자 +지적(*"rawAdd 에서도 필요한가는 모르겠음. 목적이 다르지 않나?"*)을 파고들다 +확인된 것 셋: + +1. **`newCount`(개수)는 더 이상 `Length`의 정의가 아니다** — `Length`는 + "요소별 기여도의 합"(plain=1, nested Slot=그 `.Length`)이라, 중첩이 있는 + 순간 개수로 `Set`하면 틀린 값이 된다. +2. **쓰는 주체가 둘이 되면 안 된다** — `recompute`가 이미 + `ownerKey.Length:Set(sum)`으로 확정 기록을 한다. +3. **그 자리의 `Get() ~= newCount` 가드는 아무것도 안 거른다** — `rawAdd`에선 + 카운트가 **항상** 달라지기 때문. 가드가 값을 하는 건 `recompute`의 전체 + 순회 쪽뿐이다(위 `Get` 가드 문단). + +**확정**: `Length`는 **`recompute`만 쓴다.** `rawAdd`/`rawReplace`는 자기 자리 +부기를 등록하고 `recompute`를 한 번 부를 뿐이고, 그게 `Parent` 대입 앞에 +오므로 "부기가 물리보다 먼저"는 그대로 지켜진다(사용자 확인: *"recompute 가 +length 를 잘 처리해놓고 나서 빈 공간에 들어가므로 해당 동작은 완결하다"*). +의사코드는 `base/slot-plan.md`의 `rawAdd`/`rawReplace`가 소스. **`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클 @@ -1734,7 +1912,11 @@ Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/Layou **웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/ `recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로, -`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 — +`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 +(**[2026-08-21 5라운드 재확인]** `mountInst`가 삽입 위치를 받게 된 뒤에도 이 +결론은 그대로다 — 사용자 확정: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고 +빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"*. 단 그게 성립하려면 +백엔드 op이 **아토믹한 최소 단위**여야 한다) — quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고, `offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느 물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히 @@ -1760,7 +1942,13 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n 읽을 수 있는 `Source`이고 마운트 전/언마운트 후엔 잠정값(`0` 또는 마지막 값)을 들고 있을 뿐이다. `nil`로 갈아치우면 그 Source를 이미 구독 중인 다운스트림이 끊겨 포탈이 깨진다 — 근거는 `base/slot-plan.md`의 "추가 방어 조치" -항목. 위 정정대로 이 값을 +항목. **⭐ [2026-08-21 5라운드 `DC-6`, 사용자 정밀화] 더 정확히는, 언마운트 +시점에 그 Slot이 이미 렌더해둔 요소들이 `LayoutOrder` 등을 위해 이 Source를 +**계속 구독한 채로 함께 딸려 나간다**는 게 핵심이다. 그 상태에서 재마운트 +때 `slot.Offset`에 **다른 Source 객체**를 넣으면, 딸려 나갔던 요소들은 여전히 +옛 객체를 보고 있어 새 위치가 반영되지 않는다 — 포탈이 그 지점에서 깨진다. +그래서 언마운트는 값이 stale하게 남는 걸 감수하고 **객체 identity를 유지**하고, +재마운트 시 `setOffsetSource`의 즉시 계산이 같은 객체에 새 값을 `Set`한다. 위 정정대로 이 값을 `LayoutOrder` 등에 실제로 반영하는 건 Slot 자신이 하지 않으므로, `:List`의 `updateFn`이 이 값을 받아 쓰거나(아래 `base/slot-plan.md` 참고) 수동 CRUD 사용자가 직접 `slot.Offset`을 읽어 자기 원소 프로퍼티를 @@ -1774,9 +1962,10 @@ store-bind — 그 외 방식은 UB로 확정(2026-08-10 세션).** `Length`/`Of 카운팅은 그 위치를 담당하는 Handler(`Dispatch/Slot.luau`, store-bind 프로퍼티 핸들러)가 `Dispatch.setLength`/`Dispatch.setOffsetSource`를 호출해줘야만 정합적으로 유지됨 — 이 두 API를 부르지 않고 quad가 관리하는 -부모 Instance에 자식을 끼워 넣는 경로(예: 사용자 코드가 `newInst.Parent = +부모 Instance에 자식을 끼워 넣는 경로(예: **사용자 코드**가 `newInst.Parent = parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을 몰래 -추가/제거하는 것)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게 +추가/제거하는 것 — base 의사코드 쪽의 `Parent` 직접 조작은 2026-08-21에 +전부 주입 op로 정정됐다, `base/slot-plan.md`의 "물리 조작은 주입 op다" 절)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게 만들어 카운트·형제 순서 계산이 조용히 어긋남 — 별도 방어 로직 없는 UB. `Slot`이든 `state`이든 둘 다 이미 이 두 API를 정확히 호출하는 유일한 정당 경로로 확정돼 있음(위 `setLength`/`setOffsetSource` 절 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 9640db0..5a94d95 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -22,8 +22,10 @@ mount/unmount 전용 유스케이스가 있고, 실제 leaf 생명주기 바인 합의됨. ``` -Effect(fn, state?) -> EffectHandle +Effect(fn, ...deps) -> EffectHandle ``` +**[2026-08-21 5라운드 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`(의존성 하나)였다 — +아래 "`Effect(fn, ...deps)`" 절이 소스. **`state` 생략 시**: `fn()`을 즉시 1회 실행, 리턴값(`nil | () -> ()`)은 이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출. 재실행 없음 @@ -45,17 +47,17 @@ leaf가 죽을 때 **마지막 cleanup을 한 번 더 호출**. 결과적으로 `useEffect(fn, [dep])`와 동형(설치+재실행 사이/최종 cleanup 전부 같은 반환 계약 하나로 처리). -- **다수 의존성은 `:With(...)`로 먼저 하나의 State로 묶어서 넘길 것** — - React식 별도 deps 배열을 새로 만들지 않음, quad가 이미 가진 다중 의존성 - 결합 관용구(`base/source-state-plan.md` "`:With` + `:Compute`" 절)를 - 그대로 재사용해 같은 일 하는 두 번째 경로를 안 만듦. **`Effect(fn, a, b, - c)`처럼 trailing args로 바로 받는 sugar는 의도적으로 안 만듦**(2026-08-11 - 세션, `source-state-plan.md` "`:Compute(fn, ...)` — 추가 의존성을 trailing - args로 직접 받는 sugar" 절 참고) — `Compute`와 달리 Effect/Observer는 - 자기 자신이 결과를 담는 State 노드가 아니라서, 의존성이 둘 이상이면 그걸 - 합칠 **새 노드**(`:With`가 만드는 것)가 실제로 필요함. 그 비용을 sugar로 - 감추지 않고 `Effect(fn, state:With(a,b,c))`처럼 코드에 그대로 드러내는 - 게 의도된 선택. +- **🔄 [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-6`] "다수 의존성은 `:With`로 + 묶어서 넘길 것 / trailing args sugar는 안 만듦"(2026-08-11 세션) — 뒤집혔다.** + 지금은 `Effect(fn, ...deps)`가 의존성을 **여러 개 직접 받고 각각에 구독을 + 건다**(아래 "`Effect(fn, ...deps)`" 절이 소스). 옛 근거("의존성이 둘 이상이면 + 합칠 새 노드가 실제로 필요하니 그 비용을 sugar로 감추지 말자")가 무너진 + 이유는 둘 — (1) **`Ref`는 State가 아니라 `:With`로 합칠 수가 없어서**, 그 + 모델에선 `Ref`가 Effect의 의존성이 될 방법이 **아예 없었다**(실제 갭), + (2) 각 의존성에 구독을 따로 걸면 **합치는 노드 자체가 안 생긴다** — 감출 + 비용이 애초에 없다. 옛 서술이 인용하던 + `source-state-plan.md`의 "`:Compute(fn, ...)`" 선례는 이제 **따르는 쪽**의 + 근거가 됐다(인자 모양을 그 관용구 그대로 맞춤). - **`fn`은 커링 스타일도 권장(2026-08-07 여섯 번째 세션, 사용자 제안)** — `Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn(state)`를 만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링 관용구(`modifier-plan.md` @@ -176,15 +178,28 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 dedup" 절의 `old ~= v`). 그런데 `:Unsubscribe()`가 cleanup을 미리 실행해버리면 뒤이은 재-dispatch에서 **dedup 때문에 재바인딩이 안 일어나** 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님. - - **⚠️ 같이 확인해야 할 별건(미해결)**: 그 dedup 경로에서 **retract가 - 아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안 하는지** 대칭이 - 실제로 성립하는지 확인 필요(사용자가 괄호로 남긴 것). - `ObserverEffectLeafHandler` 의사코드 기준으론 `process`의 - `if old ~= v then bindLifetime(...) end`와 클로저의 - `if nextValue ~= v then unbindLifetime(...) end`가 짝을 이루지만, - **`EffectHandle`은 내부 Observer로 cascade까지 해야 하므로** 그 - cascade가 dedup 분기 안에 제대로 들어가 있는지는 별도 확인 대상이다. - M3 착수 전 확인할 것. + - **✅ [해소, 2026-08-21 — 구현 전 QA 4라운드 `E-10` 결론을 5라운드 `EF-3`에서 + 실제로 반영] dedup 경로의 process/retract 대칭은 성립한다.** 4라운드에 + 결론이 났는데 **이 문서에 반영이 누락돼 "미해결"로 남아 있던 것**을 + 5라운드가 잡아냈다(그 자체가 followup의 "반영 완료" 표를 신뢰 소스로 + 쓰면 안 된다는 사례 — 소스는 항상 `base/` 본문). + - **성립하는 이유**: 핸들러가 **이전 값(`old`)을 `Relate`로 직접 들고 + 있고**, `process`의 `if old ~= v then ... end`와 클로저의 + `if nextValue ~= v then ... end` **두 분기 안에서만** bind/unbind가 + 일어난다. 값이 같으면 retract도 아무것도 안 하고(= `old`를 지우지 + 않는다) `process`도 조회해서 같으면 그대로 넘어간다 — 양쪽이 같은 + 비교식을 쓰므로 한쪽만 도는 상태가 안 생긴다. **사용자 서술**(2026-08-20): + *"relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup + 이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면 + dedup 되어야하는듯."* + - **⭐ 단, 내부 Observer cascade도 그 분기 *안*에 있어야 한다** + (5라운드 `EF-5`, 확인됨) — `EffectHandle`은 자기 자신뿐 아니라 + `handle._observer`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup + 분기 **밖**에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다 + (handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시 + 같은 `if` 안에 둘 것. + - **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린 + 항목이 아니다. - **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥 위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만 @@ -221,6 +236,41 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 여전히 `:Subscribe()`(전역 경로)와 `bindLifetime`(leaf 부착 포함, inst-scoped 경로)을 **같이** 쓰는 것뿐. +## ⭐ `Effect(fn, ...deps)` — 여러 의존성을 직접 받는다, `Ref`도 포함 (2026-08-21 구현 전 QA 5라운드 `C-6` 확정) + +**갭이 실재했다**: 지금 `Effect`는 `state` 하나만 받고, 여럿을 엮으려면 +`:With`로 합쳐 하나의 State로 만들어야 한다. 그런데 **`Ref`는 State가 아니라** +(`:Callback`만 있고 emit이 없다) `:With`로 합칠 수가 없어서, **오늘은 `Ref`가 +Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect 가 지금은 Ref에 +대해서 수행될 수가 없다. 단순히 Effect(, ...) 를 만들고 ... 요소를 With 으로 +합치는게 아니라 여러 요소에 대해서 Observe/Callback 하는게 어떻겠냐."* + +**확정된 계약**(전부 사용자 확인, 2026-08-21): + +- **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** — + State/Source면 `Observer`, `Ref`면 `:Callback`. `:With`로 합치지 않는다. +- **인자 모양은 `:Compute(fn, ...deps)`의 선례 그대로** — trailing deps를 + **lazy 위치 인자**로 콜백에 넘긴다(`base/source-state-plan.md`의 "trailing + deps를 `fn`에 lazy positional 인자로도 노출" 절). 새 규칙이 아니다. +- **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`가 + 섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게 + 될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다. +- **`Ref` 의존성의 발화 시점은 `Set`될 때뿐**이다(Ref는 반복 재설정이 + 가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다. +- **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록 + 즉시 1회 실행"이 N번 발화하므로, 설치 구간 동안 발화를 눌러뒀다가 마지막에 + 한 번만 실행한다 — **`Blocker`의 "`state:Block()` 없이 직접 쓰는" 용례**를 + 그대로 재사용(`base/blocker-plan.md`). **⚠️ 이 억제 장치의 정확한 모양은 + `Gate`(공용 게이트 노드) 설계에 딸려 있다** — `research/gate-primitive.md`가 + 닫힌 뒤에 확정할 것. +- **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도 + N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를 + 같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피 + 모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."* + +**우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M3의 +`Effect` 구현과 같이 간다 — 다만 위 ⚠️(억제 장치) 때문에 `Gate`보다 뒤다. + ## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체) **과거 미해결이었던 두 질문 모두 확정**: diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index cc30f26..52969a8 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -356,39 +356,20 @@ function canExecute(value) end ``` -#### (1-1) ⚠️ 첫 인자가 물리 Instance가 아닐 수도 있다 — 백엔드가 반드시 핸들링할 것 (2026-08-20 구현 전 QA 4라운드 `D-56`) +#### (1-1) ✅ [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`] 첫 인자는 **항상 물리 Instance**다 -**위 구현 스케치는 `inst`가 항상 Roblox Instance라고 가정하고 `InstData`에서 -gcconn/gchold를 찾는데, 실제 호출부 중엔 `inst` 자리에 `Slot`이 오는 경로가 -이미 있다.** `Dispatch.setLength(ownerKey, i, len)`이 그것 — -`base/dispatch-core-plan.md`의 "`setLength` 구현" 절이 `bindLifetime(ownerKey, -observer)`를 부르는데, 그 `ownerKey`는 Slot-in-Slot 중첩에서 **Slot 자신**이다 -(`base/slot-plan.md`의 "재귀 메커니즘" 절 — `attachSlot`이 `ownerKey`로 자기 -자신을 넘겨 최상위/중첩을 같은 함수로 통합한 그 설계). +여기 있던 절(4라운드 `D-56`)은 *"`Dispatch.setLength`의 `ownerKey`가 Slot일 수 +있으니 백엔드의 `bindLifetime`이 비-Instance 첫 인자를 핸들링하고, +`isBoundAlive`에 세 번째 분기를 둬야 한다"*였다. **5라운드에서 뒤집혔다** — +`setLength`가 **부기 키(`ownerKey`)와 생명주기 앵커(`anchor`)를 따로 받도록** +바뀌면서, 앵커는 언제나 물리 target이 된다(모든 호출부가 이미 그 값을 알고 +있다). 그래서: -**사용자 판정(2026-08-20)**: *"ownerKey 가 Slot일 수도 있음. 각 엔진의 -bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong -으로 gchold 비슷한걸 수행하면 됨."* - -- **계약 두 개(위 절)는 그대로 유지된다** — 바뀌는 건 "그 계약을 무엇으로 - 구현하는가"뿐. 물리 Instance면 gcconn 트릭이 두 계약을 다 만족시키고, - Slot이면 **Slot 자신이 살아있는 동안 `value`를 붙잡는 강참조**(Slot 안의 - 필드든, `Relate(slot)`에 `SetStrong`이든)와 **`value`가 그 Slot의 생존을 - 되물을 수 있는 근거**를 백엔드가 제공하면 된다. -- **왜 gcconn을 못 쓰는가**: gcconn 트릭은 - `inst:GetPropertyChangedSignal("ClassName")`에 의존하므로 엔진 객체가 아닌 - 값(Slot은 평범한 Lua 테이블)엔 걸 수가 없다. Slot은 대신 **자기 자신이 - reachable한가**가 곧 생존이라, `Relate(slot)`가 weak-keyed인 것만으로 - "Slot이 죽으면 기록도 같이 사라진다"가 성립한다. -- **`isBoundAlive`의 판정 분기도 이 경로를 알아야 함** — 지금 코드는 - gcconn이 없으면 곧바로 `.Subscribed` 폴백으로 떨어지는데, Slot-owned - 바인딩은 gcconn도 `.Subscribed`도 없어서 **살아있는데 `canBound`가 참으로 - 잘못 나온다**(= 이중 바인딩 가드가 이 경로에선 안 걸림). 백엔드 구현이 - 세 번째 분기를 추가하거나, Slot 쪽 홀더 존재 자체를 판정 근거로 삼아야 함. -- **⚠️ 정확한 형태는 아직 미확정 — M2/M3 구현 시 확정할 것.** "Slot 안"(필드) - 이냐 "바깥"(`Relate`)이냐, `isBoundAlive`의 세 번째 분기를 어떤 모양으로 - 둘지가 열려 있다. 지금 확정된 건 **"첫 인자가 Instance라고 가정하면 안 - 된다"는 요구사항 자체**뿐. +- **`bindLifetime`/`unbindLifetime`/`isBoundAlive`는 예전처럼 물리 Instance만 + 상대한다** — 백엔드에 추가 요구사항이 없고, `isBoundAlive`의 **세 번째 + 분기도 필요 없다**(형태 미정인 채 열려 있던 항목이 이걸로 닫혔다). +- 근거와 트레이싱은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 + 바로 뒤 문단, 역전 전 원문은 `archive/bindlifetime-slot-owner-reversed.md`. **`bindLifetime`이 `value`와 맺는 계약은 정확히 둘**(이 둘이 위 구현의 전부): diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 6dc3ecf..e44d7cb 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -49,6 +49,40 @@ inst, inst, k)` 한 줄로 축약됨** — `Dispatch.setLength`/`activateList` 호출은 `attachSlot` 내부로 옮겨감(로직은 그대로, 재귀 가능하도록만 일반화됨), 상세는 아래 "Slot-in-Slot 중첩" 절 참고. +### ⭐ 물리 조작은 주입 op다 — base는 `Parent`를 모른다 (2026-08-21 구현 전 QA 5라운드 `C-1`) + +**사용자 지적**: *"element.Parent = self._mountedInst 부분은 실제 엔진이 +구현하게 되는 crud 셋을 사용하게 되어야할것이다. slot 의 해당 동작은 base +이므로 parent 를 모른다."* 맞다 — 위 경계 문단이 "실제 트리 조작은 백엔드"라고 +이미 말해뒀는데, **의사코드는 계속 `element.Parent = ...` / `element:Destroy()`로 +Roblox 어휘를 직접 쓰고 있었다.** 이 문서의 의사코드를 전부 주입 op 호출로 +바꿨다(로직은 그대로, 표기만 정정): + +| op | 언제 | 대응하는 경계 훅 | +|---|---|---| +| `mountInst(target, element, index)` | 요소를 물리 트리에 붙일 때(`index` = 0-based 절대 offset, 아래 항목) | mount(`Add`) | +| `unmountInst(element)` | 물리 트리에서만 떼어낼 때(파괴 아님) | unmount(`Remove`/`Extract`) | +| `disposeInst(element)` | 요소를 실제로 파괴할 때 | 이미 확정된 주입 op(아래 `dispose` 절) | + +- **reposition(`Move`/`Swap`)은 base가 "Parent를 안 건드린다"는 계약만 + 강제**하므로 별도 op이 없다(위 문단 그대로) — 백엔드가 `LayoutOrder` 정렬에 + 기대 no-op으로 둘 수도 있다. +- **⭐ [확정, 2026-08-21 5라운드 G절] `mountInst(target, element, index)` — + index는 절대 offset(0-based)이다.** 원래는 "형제 순서는 `Offset`/`LayoutOrder` + 부기가 담당하니 안 넘긴다"였는데, 사용자 지적 — *"웹에서는 어떻게 되냐가 + 모호함. 어디 둘지 어떻게 아느냐는것"* — 대로 **DOM류 백엔드는 삽입 위치를 + 알아야 하고, 정작 plain 요소는 `setOffsetSource`에 `None`을 등록해 그 숫자가 + 계산조차 안 되고 있었다.** 지금은 `Dispatch.getOffsetAt(ownerKey, i)`가 발행 + 채널 유무와 무관하게 그 숫자를 준다(`base/dispatch-core-plan.md`의 + "Length/Offset" 절). Roblox 백엔드는 이 인자를 그냥 무시한다 — `LayoutOrder`가 + 물리 순서와 분리돼 있으므로. + - **`unmountInst(element)`는 그대로 인자 없음** — 뺄 때는 자기 자신만 알면 된다. + - **0-based인 이유**: `offset`/`sum`이 이미 0-based 개수라 그쪽과 일관되고, + `updateFn`의 `index`(1-based 지역 위치)와 헷갈리지 않게 하기 위함. +- **⚠️ 이름은 `disposeInst` 선례를 따른 가칭** — 주입 op 목록에 정식으로 + 추가할 때 확정할 것(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 + 주입되는 엔진 op" 절). + **추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미 만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예: `Frame { Frame {} }`)를 위한 핸들러도 필요 — `quad-roblox/src/Handlers/ @@ -595,6 +629,7 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. ` |---|---|---|---| | `Add` | `Slot:Add(element, index?): number` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 — **실제로 삽입된 인덱스를 반환** | | `Remove` | `Slot:Remove(index)` | O(n) | 제거 **+ 파괴**(retract/Destroy) — `Extract(index):Destroy()`와 동치, 흔한 경로라 별도 이름으로 유지 | +| `Replace` | `Slot:Replace(index, newElement)` | **O(1)** | 그 자리 요소를 교체하고 **이전 것을 파괴** — `Extract(index, newElement)`의 파괴 짝(`Remove` ↔ `Extract` 관계와 동형). **[2026-08-21 5라운드 `B-5` 신설]** | | `Extract` | `Slot:Extract(index, newElement?)` | O(n) 또는 O(1) | `newElement` 생략 — 제거만(파괴 안 함), 뒤 요소가 당겨져 빈 자리를 메움(O(n)). `newElement` 지정 — 그 자리를 즉시 교체(뒤 요소 안 건드림, O(1)), 이전 element를 반환 | | `ExtractAll` | `Slot:ExtractAll(): {T}` | O(n) | 전체 추출(파괴 안 함) — `Clear`의 비파괴 버전, 추출된 element 배열(순서 보존)을 반환 | | `Splice` | `Slot:Splice(index, removeCount, ...newElements): {T}` | O(n) | 한 위치에서 `removeCount`개를 비파괴 추출(반환)하고 그 자리에 `newElements`를 삽입 — shift+recompute 1회로 통합 | @@ -623,6 +658,28 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. ` 정확히 같아서(교체도 "그 자리 걸 빼내고 새 걸 넣는" 것의 원자적 버전일 뿐). `newElement`에도 `Add`와 같은 검증(이미 마운트/타입 제약)이 똑같이 적용됨. +- **⭐ `Replace` 신설(2026-08-21 구현 전 QA 5라운드 `B-5`)** — `Extract(index, + newElement)`가 이미 "그 자리 교체 + 이전 것 반환"을 O(1)로 해주는데, **흔한 + 경우는 이전 것을 그냥 버리는 것**이다. 그때 `Extract`로 받아서 직접 + `dispose`하게 하면 `Remove`가 있는데도 `Extract(index):Destroy()`를 쓰게 + 하는 것과 같은 불편이라, 파괴 짝을 이름 하나로 준다. + **사용자 판정**: *"차라리, replace 를 제공하는게 나아보임. 해당 요소 자리에 + 교체분을 넣고, 이전건 파기해주는 것. extract 가 뽑아내는것과 다르게 이건 + 제거해준다."* + - **진짜 동기는 `:List`의 reconcile 쪽이다** — 교체를 "제거 후 삽입"으로 + 하면 `spliceArraysDown`(뒤 요소 당김) + `spliceArraysUp`(다시 밀어냄)이 + 쌍으로 돌아 **O(n) 시프트가 두 번, `recompute`도 두 번** 난다. 자리 수가 + 안 변하는 교체엔 둘 다 불필요하다(사용자: *"list 에서도 교체 작업이 제거 + 다음 붙여넣기일텐데, 밀고 당기는게 많아짐"*). 그래서 `settle`의 교체 + 분기가 `releaseElement` + `rawAdd`가 아니라 **`rawReplace` 하나**를 + 쓴다(아래 의사코드). + - **`Extract`와의 관계**: `Remove` ↔ `Extract`가 "파괴 / 비파괴"로 갈리는 + 것과 정확히 같은 축이다. 새 능력이 아니라 이름 하나. + - **[2026-08-21 감사] `rawReplace`의 `destroyOld`가 갈리는 지점**: 공개 + `Replace`는 **항상 `true`**(사용자가 명시적으로 "이건 지워라"라고 부른 + 것이므로), `:List`의 `settle`만 `self._owned ~= false`를 넘긴다(남의 + 요소면 언마운트만). `_listed` Slot은 공개 CRUD가 막혀 있어 두 경로가 + 한 Slot에서 섞이지 않는다. - **`Splice` 신설(2026-08-12 열다섯 번째 세션)** — 한 구간을 제거하고 동시에 새 요소들을 그 자리에 넣는 흔한 배치 갱신(`:List` 없이 수동 CRUD로 큰 구간을 통째로 교체하는 경우)을 `Extract`/`Add`를 요소 수만큼 @@ -1132,12 +1189,17 @@ GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에 패턴(마운트 시점까지 미뤘다가 그 자리에서 `bindLifetime`). ```lua -function Slot:List(data, updateFn, keyFn) +function Slot:List(data, updateFn, keyFn, opts) assert(not self._listed, "Slot already has :List installed") self._listed = true self._listData = data self._updateFn = updateFn self._keyFn = keyFn or function(_, index) return index end + -- [2026-08-21 감사] `Owned`를 실제로 심는 유일한 자리 — 이 줄이 없으면 + -- `settle`/`destroySlotTree`/`releaseElement`가 읽는 `self._owned`가 + -- 영원히 nil(=owned)이라 `Owned = false` 확정이 코드에 도달하지 못한다. + -- 기본이 true이므로 **false일 때만** 필드를 세운다(nil = owned). + if opts and opts.Owned == false then self._owned = false end if self._mounted then activateList(self, self._mountedInst) -- 이미 마운트돼 있으면 즉시 활성화 @@ -1186,35 +1248,66 @@ function activateList(self, physicalTarget) -- 소멸 루프가 같은 걸 쓴다(분기가 두 군데로 갈리면 반드시 어긋남). local function settle(key, result, detach, pos) local wasMounted = mounted[key] - local wasDetached = self._detached[key] -- Slot 필드(아래 "Detach된 요소는 `slot._detached`가 보유한다" 절) + -- [2026-08-21 5라운드 `DE-7`] `_detached`는 **lazy** — 없을 수 있다. + -- 읽기는 항상 nil 체크, 쓰기는 `getDetached(self)`(getOrCreate)로. + local detached = self._detached + local wasDetached = detached and detached[key] local prev = wasMounted or wasDetached if detach then - -- "이 자리를 비우되 죽이지 마라". 이미 detach 상태면 **nop**. + -- "이 자리를 비우되 죽이지 마라." + -- **prev가 아예 없어도(마운트도 detach도 아님) nop** — 사용자가 "지금 + -- prev가 있는지"를 추적할 의무가 없다. 이미 detach 상태여도 nop. if wasMounted ~= nil then - rawDetach(self, wasMounted) -- 언마운트하되 **소유권은 유지** - self._detached[key], mounted[key] = wasMounted, nil + local idx = keyIndex[key] -- 이 키가 지금 차지한 _elements 인덱스 + if self._owned == false then + -- [2026-08-21 5라운드 `DE-13`] **내 것이 아니면 붙잡지 않는다** — + -- 언마운트 + 소유권 반납으로 끝내고 `_detached`에 넣지 않는다. + -- 다음 사이클의 `prev`는 `nil`이 된다(아래 "`Owned = false`에서 + -- `Detach`" 절). + rawUnmount(self, idx) + mounted[key] = nil + else + rawDetach(self, idx) -- 언마운트하되 **소유권은 유지** + getDetached(self)[key], mounted[key] = wasMounted, nil + end end elseif result == nil then -- "지워라". Owned=false면 파괴 대신 언마운트만(아래 "Owned" 절). - if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end - mounted[key], self._detached[key] = nil, nil - elseif result == prev then + if prev ~= nil then releaseElement(self, keyIndex[key], prev, wasDetached ~= nil) end + mounted[key] = nil + if detached then detached[key] = nil end + elseif result == unwrapElement(prev) then + -- [2026-08-21 5라운드 `C-3`] 비교 대상은 **사용자가 받은 값**이다 — + -- `prev`는 물리 요소(State를 반환했으면 래퍼 Slot)라 그대로 비교하면 + -- 같은 State를 다시 반환해도 "다른 값"으로 오인한다. if wasDetached ~= nil then - self._detached[key] = nil -- **재마운트** — detach된 걸 되살림 + detached[key] = nil -- **재마운트** — detach된 걸 되살림 -- 4번째 인자 = fromDetached. 소유권을 놓은 적이 없으므로 -- `claimOwner`가 재클레임을 허용해야 한다(위 그 함수 주석). - rawAdd(self, result, pos, true) - mounted[key] = result + rawAdd(self, prev, pos, true) -- 물리 요소(=래퍼)를 그대로 되돌림 + mounted[key] = prev elseif keyIndex[key] ~= pos then - rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동 + rawMove(self, keyIndex[key], pos) -- 그대로 쓰되 위치만 이동 end else - -- 교체 — 밀려난 prev 처분은 Owned가 정한다 - if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end - self._detached[key] = nil - rawAdd(self, result, pos) - mounted[key] = result + -- 교체. + -- [2026-08-21 5라운드 `B-5`] 마운트 중이던 자리는 **`rawReplace`** — + -- "제거 후 삽입"으로 하면 `spliceArraysDown` + `spliceArraysUp`이 쌍으로 + -- 돌아 O(n) 시프트와 `recompute`가 두 번씩 난다. 자리 수가 안 바뀌는 + -- 교체엔 둘 다 불필요. + local element = wrapElement(result) -- State면 래퍼 Slot, 아니면 그대로 + if wasDetached ~= nil then + -- detach 중이던 건 이미 `_elements` 밖이라 "자리 교체"가 성립 안 함 + releaseElement(self, nil, prev, true) -- index 없음(트리 밖) + detached[key] = nil + rawAdd(self, element, pos) + elseif wasMounted ~= nil then + rawReplace(self, keyIndex[key], element, self._owned ~= false) + else + rawAdd(self, element, pos) + end + mounted[key] = element -- **물리 요소**를 기억한다(래퍼일 수 있음) end end @@ -1231,7 +1324,9 @@ function activateList(self, physicalTarget) -- [2026-08-21] prev는 "이 키의 요소" — 마운트돼 있든 detach돼 있든. -- 그래서 detach된 걸 그대로 반환하면 재마운트가 된다(settle 참고). - local prev = mounted[key] or self._detached[key] + -- [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]) if result == None then result = nil end -- 편의: None도 nil과 동일 취급 @@ -1258,14 +1353,17 @@ function activateList(self, physicalTarget) -- 아래 "`KeyGone`" 절이 소스. for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key if not seen[key] then - local prev = mounted[key] or self._detached[key] + local prev = unwrapElement(mounted[key] or (self._detached and self._detached[key])) local result, ud = updateFn(KeyGone, 0, offset, prev, userdata[key]) if result == None then result = nil end local detach = (result == Detach) if detach then result = nil end - if result ~= nil and result == prev then - error("Slot:List — KeyGone에 prev를 그대로 반환할 수 없음(키가 없는데 마운트 유지는 모순). " - .. "계속 들고 있으려면 Detach를 반환할 것") + -- [2026-08-21 5라운드 `DE-9`] prev든 **새 값이든** 전부 error. + -- KeyGone을 받은 자리는 "데이터가 다시 나타날 때를 위한 캐싱" + -- (= Detach) 아니면 파괴뿐이고, **새 마운트/생성은 거부**한다. + if result ~= nil then + error("Slot:List — KeyGone에는 nil/None(파괴) 또는 Detach(홀드)만 반환할 수 있음 " + .. "(자리가 없어진 키에 새 요소를 마운트할 수 없고, prev 유지도 모순)") end settle(key, result, detach, 0) -- pos는 의미 없음(자리를 안 차지함) userdata[key] = ud -- 유저가 nil을 반환해야 지워짐 @@ -1287,6 +1385,7 @@ function activateList(self, physicalTarget) -- 게이팅할 뿐 죽는 순간의 콜백을 안 준다(`base/effect-plan.md`). self._detachCleanup = Effect(function() return function() + if not self._detached then return end -- [2026-08-21] lazy — 한 번도 detach 안 했으면 끝 for key, element in pairs(self._detached) do -- releaseOwner를 **먼저, 두 분기 공통으로**. 빠뜨리면 -- `_owned == false` 요소가 죽은 Slot을 owner로 달고 남아, @@ -1294,9 +1393,10 @@ function activateList(self, physicalTarget) -- "이미 마운트돼 있음" error가 난다 — 위 "소유권 반납은 -- GC에 맡기면 안 됨" 절이 경계하는 실패 모드. releaseOwner(element, self) - if self._owned ~= false then - if isSlot(element) then destroySlotTree(element) else element:Destroy() end - end + -- [2026-08-21 5라운드 `DE-13`] `_owned == false` 분기가 여기 있었으나 + -- 제거했다 — unowned Slot은 `_detached`를 **애초에 채우지 않으므로** + -- (위 `settle`의 detach 분기) 여기 오는 건 전부 내 것이다. + if isSlot(element) then destroySlotTree(element) else disposeInst(element) end self._detached[key] = nil end end @@ -1454,10 +1554,33 @@ GC 폴백이 아예 없으므로 명시적 정리 경로가 **필수**다. **확정된 형태**: -- **`slot._detached: {[key]: T}` — Slot 필드.** 클로저 업밸류가 아니라 - 필드여야 `destroySlotTree`/`dispose`의 walk가 닿는다. (`mounted`/`userdata`/ - `keyIndex`가 `activateList`의 업밸류로 남는 것과 다른 이유 — - **마운트된 요소는 `_elements`를 통해 walk가 이미 닿기 때문**이다.) +- **`slot._detached: {[key]: T}?` — Slot 필드, 단 lazy(`nil` 허용).** 클로저 + 업밸류가 아니라 필드여야 `destroySlotTree`/`dispose`의 walk가 닿는다. + (`mounted`/`userdata`/`keyIndex`가 `activateList`의 업밸류로 남는 것과 다른 + 이유 — **마운트된 요소는 `_elements`를 통해 walk가 이미 닿기 때문**이다.) + - **⭐ [2026-08-21 5라운드 `DE-7`] 모든 Slot이 이 테이블을 미리 갖지 + 않는다** — `_detached`를 채우는 건 `:List`의 `settle`뿐인데 Slot 대부분은 + `:List`도, detach도 없다. **사용자 판정**: *"테이블 생성 비용을 모든 + slot 이 가져야하나는 의문 … if 확인으로 nil 이면 스킵이 훨씬 싸게 + 먹히지 않는가? … 최적화에 드는 비용이 거의 없는데, 안 할 이유가 보이진 + 않음."* 그래서 **읽기는 항상 `if slot._detached then`, 쓰기는 + `getDetached(slot)`(getOrCreate 유틸)**로 통일한다 — `Relate`의 + `StrongMap`/`WeakMap`이 첫 `Set`에서야 만들어지는 것과 같은 관례 + (`base/relate-plan.md`의 "실제 구조" 절). +- **⭐ [2026-08-21 5라운드] `Detach`의 nop 조건은 둘이다** — (a) 이미 + detach 중, (b) **`prev`가 아예 없음**(그 키에 마운트된 것도 detach된 것도 + 없음). 후자도 조용히 무시하므로 **`updateFn`이 "지금 prev가 있는지"를 + 추적할 의무가 없다**(사용자 질문에 대한 확정) — `if not shouldShow(item) + then return Detach end`처럼 조건만 보고 반환해도 안전하다. +- **⭐ [2026-08-21 5라운드 `DE-13`] `Owned = false`에서 `Detach`는 + `_detached`에 안 들어간다.** unowned 요소는 애초에 남의 것이라 "잠깐 + 빼두고 내가 계속 들고 있는다"가 성립하지 않는다 — 그래서 `rawDetach`가 + 아니라 **`rawUnmount`(언마운트 + 소유권 반납)**로 처리하고, 다음 사이클의 + `prev`는 `nil`이 된다. **사용자 판정**: *"애초에 unowned 의 state 로 받은것은 + 더이상 가지고 있지 않는다. detach 에 들어가있지도 않는다. 외부로 반출된 + 것이라 다시 들고와서 자기 자신에 붙이지 않음."* 부수 결과 — unowned + `:List`에서는 `Detach`와 `nil`이 **같은 동작**이 되고(둘 다 언마운트+반납), + `_detached`가 영원히 `nil`이라 `_detachCleanup`도 할 일이 없다. - **소유권은 유지된다** — `Detach`는 `rawUnmount`(소유권 반납)가 아니라 `rawDetach`(**소유권 유지**)를 쓴다. 아래 "raw 3형제" 참고. - **`prev`로 그대로 돌려준다** — reconcile이 `mounted[key] or self._detached[key]`를 @@ -1469,6 +1592,12 @@ GC 폴백이 아예 없으므로 명시적 정리 경로가 **필수**다. - **`ud`에 담는 것 자체는 여전히 자유** — 다만 **보존을 위해 담을 필요가 없어졌고**, 담더라도 그건 사용자 몫이라 `:List`가 정리해주지 않는다 (아래 "`userdata`의 생명주기 제약" 절). +- **⚠️ [2026-08-21 5라운드 `DE-11`] 문서화 시 반드시 같이 적을 것 — 홀드된 + 요소는 owner가 죽을 때까지 쌓인다.** `Detach`는 **삽입/삭제가 빈번한 + 경우를 위한 재사용 최적화일 뿐 그 이상을 돕지 않는다**(사용자 확정). + 키가 다시 안 나타나면 `_detachCleanup`이 도는 시점(owner 사망)까지 + `_detached`에 남으므로, churn이 큰 리스트에서 이걸 무한정 쓰면 메모리가 + 는다 — 잘라내는 정책(LRU/최대 개수)은 **만들지 않는다**. ```lua -- filter에서 걸러진 아이템 — 그냥 Detach만 반환하면 된다 @@ -1497,9 +1626,15 @@ updateFn(item: T | KeyGone, index, offset, prev, ud) - **`updateFn`은 `if item == KeyGone then ... end`로 분기**해 처분을 고른다 — 반환값 의미는 정상 사이클과 **완전히 같다**(`nil`=파괴, `Detach`=계속 홀드, 새 값=교체). 그래서 reconcile의 처분 로직(`settle`)이 한 벌로 공유된다. -- **`prev`를 그대로 반환하는 것만 `error`** — 키가 없는데 마운트를 유지하라는 - 건 모순이다. 계속 들고 있으려면 `Detach`를 반환해야 한다. (다른 CRUD 에러 - 조건들과 같은 fail-fast 톤.) +- **⭐ [정정, 2026-08-21 5라운드 `DE-9`] `nil`/`None`/`Detach` 외의 반환은 + 전부 `error`** — `prev`를 그대로 반환하는 것뿐 아니라 **새 요소를 반환하는 + 것도** 거부한다. **사용자 판정**: *"KeyGone 을 받은 요소는 오직 데이터의 + 파괴 또는 detach 를 통해 다시 나오는 경우를 위한 캐싱 이외의 새로운 + 마운트나 생성을 거부한다."* 자리가 없어진 키에 새 요소를 마운트하면 + 넣을 위치 자체가 없다(옛 의사코드는 `pos = 0`으로 `rawAdd`를 불러 범위 밖 + 인덱스로 터졌을 것). 그래서 소멸 루프의 `settle` 호출은 **항상 `result == + nil`**이고, 교체 분기에 도달하는 경로가 없다. (다른 CRUD 에러 조건들과 같은 + fail-fast 톤.) - **`index`는 `0`** — 사라진 키는 자리를 안 차지한다. `offset`/`sum`이 이미 0-based 개수라 "아무 자리도 없음"이 `0`으로 자연스럽고, `index: number`라는 시그니처도 안 바뀐다(`nil`을 넣으면 타입이 바뀜). `offset`은 Slot의 것을 @@ -1563,6 +1698,7 @@ Slot:Single(state, updateFn?, opts?) | `updateFn`이 새 값 반환 | 밀려난 `prev` **파괴** | **언마운트만** | | `updateFn`이 `nil`/`None` 반환 | **파괴** | **언마운트만** | | 키가 데이터에서 사라짐 | **파괴** | **언마운트만** | +| `updateFn`이 `Detach` 반환 | `_detached`가 보유(소유권 유지) | **언마운트 + 소유권 반납**(`_detached`에 안 들어감, 다음 `prev`는 `nil`) — **[2026-08-21 5라운드 `DE-13`]** | | owner가 죽음(`destroySlotTree`/`dispose`) | **파괴**(재귀) | **언마운트만** | | `Slot:Add(state)` sugar | — | **이걸로 설치됨** | @@ -1735,7 +1871,7 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 ```lua local function identityUpdateFn(item) return item end -function Slot:Single(state, updateFn) +function Slot:Single(state, updateFn, opts) updateFn = updateFn or identityUpdateFn -- [2026-08-11 일곱 번째 세션] 기본값 추가 local data = if isState(state) @@ -1744,7 +1880,7 @@ function Slot:Single(state, updateFn) return self:List(data, function(item, index, offset, prev, ud) return updateFn(item, offset, prev, ud) -- index는 항상 상수라 안 넘김 - end, function() return true end) -- 고정 key + end, function() return true end, opts) -- 고정 key, opts는 그대로 전달 end ``` @@ -1854,10 +1990,18 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 -- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다. local function materializeSlotTree(slot, physicalTarget, ownerKey, position) -- offset 먼저 — activateList가 updateFn에 이 값을 넘겨야 하므로(C1) - local offsetSource = Source(0) + -- [2026-08-21 5라운드 G절 검토 중 발견] **기존 Source가 있으면 재사용한다.** + -- 매번 `Source(0)`을 새로 만들면, 언마운트가 `slot.Offset`을 일부러 보존해둔 + -- 이유(이미 렌더된 요소들이 그 Source를 **구독한 채 함께 딸려 나간다**, + -- `SL-75`/`DC-6`)가 재마운트에서 그대로 무너진다 — 그 구독자들은 옛 객체를 + -- 계속 보고 있어 새 위치가 영원히 반영 안 됨. identity 유지가 포탈의 전제. + local offsetSource = slot.Offset or Source(0) Dispatch.setOffsetSource(ownerKey, position, offsetSource) slot.Offset = offsetSource - + -- [2026-08-21 5라운드 G절] `recompute(slot, ...)`은 `0`이 아니라 **이 + -- `slot.Offset`**에서 시작한다 — 그래야 자식 offset이 절대값이 된다(안 그러면 + -- depth ≥ 2에서 부모 베이스만큼 어긋남). 베이스를 부기에 따로 복사해두지 + -- 않는다(같은 값이 두 곳에 생김) — `base/dispatch-core-plan.md`의 `recompute`. -- `_mounted`는 여전히 false — reconcile의 rawAdd가 "아직 마운트 전" -- 경로(= `_elements`에만 넣고 끝)를 타야 함(RC-3/RC-4). 이제 이 조건이 -- **함수 경계로 강제**된다: `_mounted`를 켜는 코드가 이 함수엔 아예 없음. @@ -1868,6 +2012,23 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) -- 정의와 범위가 정확히 일치함(옛 코드는 물리 마운트까지 같이 감쌌음). local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용 blocker:On() + + -- [2026-08-21 5라운드 G절] **깊은 전파** — 앞 형제의 길이가 변해 내 베이스가 + -- 밀리면 내 자식들의 offset도 다시 계산돼야 한다. + -- `_listObserver`/`_detachCleanup`과 **같은 취급**: 생성 1회, 재마운트 땐 + -- 앵커만 새 target으로(앵커가 물리 target인 근거는 위 `C-4`). + -- **생성이 `blocker:On()` *뒤*인 게 중요하다** — Observer의 "등록 즉시 1회 + -- 실행"이 여기서 곧바로 `recompute`를 태우면 아직 자식 등록이 하나도 안 된 + -- 상태를 훑게 된다. 게이트 안에서 만들면 그 1회가 그냥 삼켜지고, 아래 + -- 마지막 `recompute`가 어차피 정확한 값을 계산한다. + if slot._baseObserver then + bindLifetime(physicalTarget, slot._baseObserver) + else + slot._baseObserver = offsetSource:Observer(function() + if not getBlocker(slot):IsOn() then recompute(slot, getBookkeeping(slot)) end + end) + bindLifetime(physicalTarget, slot._baseObserver) + end for i, element in ipairs(slot._elements) do if isSlot(element) then -- 재귀 — 자식이 자기 끝에서 setLength(slot, i, 자기Length)까지 하고 옴 @@ -1876,7 +2037,7 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) -- 평범한 요소: 자기 자리의 offset은 아무도 안 읽으므로 None, -- length는 상수 1. 순서는 늘 offsetSource → setLength(C4). Dispatch.setOffsetSource(slot, i, None) - Dispatch.setLength(slot, i, 1) + Dispatch.setLength(slot, i, 1, physicalTarget) -- 4번째 = 생명주기 앵커(5라운드 `C-4`) end end -- [2026-08-21 감사] 위 재귀가 **예외를 던지면 이 줄에 도달하지 못해 @@ -1896,20 +2057,34 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position) -- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에 -- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에 -- 만족시킬 수 없었던 지점. - Dispatch.setLength(ownerKey, position, slot.Length) + Dispatch.setLength(ownerKey, position, slot.Length, physicalTarget) end -- (2) 물리만 붙인다. 부기를 단 한 줄도 안 건드린다 → Blocker 불필요. +-- **⚠️ [2026-08-21 감사] 전제: 반드시 `materializeSlotTree` 이후에만 부를 것.** +-- 아래 `acc`가 `slot.Offset:Get()`에서 시작하는데, 그 값이 최종값이 되는 건 +-- materialize의 마지막 `recompute`가 끝난 뒤다. 순서를 뒤집거나 materialize를 +-- 건너뛰고 부르면 물리 삽입 위치가 조용히 어긋난다(공개 `attachSlot`이 둘을 +-- 붙여 부르는 것이 이 계약의 전부 — `research/slot-attach-decomposition.md`의 +-- "prepare만 하고 mount 안 한 중간 상태" 항목이 아직 열려 있는 이유이기도 하다). local function mountSlotTree(slot, physicalTarget) slot._mounted = true slot._mountedInst = physicalTarget + -- [2026-08-21 5라운드 G절] 물리 삽입 위치(절대 offset, 0-based)를 같이 넘긴다. + -- 러닝 누적이라 O(n) — 자리마다 getOffsetAt을 부르면 O(n²)가 된다. + local acc = slot.Offset:Get() -- [이관, 2026-08-21] `_detachCleanup` Effect 설치가 여기 있었으나 -- `activateList`로 옮겼다 — `_detached`를 채우는 건 `:List`의 `settle`뿐이라 -- List 없는 Slot마다 no-op Effect를 심고 있었다(위 그 함수의 주석이 소스). -- 그래서 이 함수는 이제 **정말로 물리 대입만** 한다. for i, element in ipairs(slot._elements) do - if isSlot(element) then mountSlotTree(element, physicalTarget) - else element.Parent = physicalTarget end -- quad-roblox 글루가 실제 수행 + if isSlot(element) then + mountSlotTree(element, physicalTarget) -- 자식은 자기 Offset에서 다시 시작 + acc += element.Length:Get() + else + mountInst(physicalTarget, element, acc) -- 주입 op(위 "물리 조작은 주입 op" 절) + acc += 1 + end end end @@ -2005,7 +2180,7 @@ local function unmountSlotTree(slot) if isSlot(element) then unmountSlotTree(element) -- 재귀 — 중첩 Slot도 똑같이 비파괴 else - element.Parent = nil -- Destroy 아님(quad-roblox 글루가 수행) + unmountInst(element) -- 파괴 아님(주입 op — 위 "물리 조작은 주입 op" 절) end -- releaseOwner를 **안 부름** — 자식들은 여전히 이 slot의 소유. 이게 -- destroySlotTree와의 핵심 차이(파괴는 소유권까지 반납, 언마운트는 유지). @@ -2026,6 +2201,7 @@ local function unmountSlotTree(slot) -- 멱등 가드다. 파괴 쪽(`destroySlotTree`)만 핸들까지 `nil`로 지운다. if slot._listObserver then unbindLifetime(slot._listObserver) end if slot._detachCleanup then unbindLifetime(slot._detachCleanup) end + if slot._baseObserver then unbindLifetime(slot._baseObserver) end -- [2026-08-21 G절] -- `slot._detached`는 **안 건드린다** — 언마운트는 파괴가 아니고, -- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유). slot._mounted, slot._mountedInst = false, nil @@ -2050,15 +2226,17 @@ local function destroySlotTree(slot) if isSlot(element) then destroySlotTree(element) -- 재귀는 "파괴"에만, choreography 없음 else - element:Destroy() + disposeInst(element) end end -- [2026-08-21] Detach로 홀드 중인 요소도 같이 파괴 — 이것들은 `_elements`에 -- 없으므로(rawDetach가 뺐음) 위 루프가 못 닿는다. **`dispose`가 재귀적으로 -- 잘 죽이는가**의 답이 정확히 이 줄이다(아래 "`dispose`" 절). - for key, element in pairs(slot._detached) do - if isSlot(element) then destroySlotTree(element) else element:Destroy() end - slot._detached[key] = nil + if slot._detached then -- [2026-08-21 5라운드 `DE-7`] lazy — 없으면 통째로 스킵 + for key, element in pairs(slot._detached) do + if isSlot(element) then destroySlotTree(element) else disposeInst(element) end + slot._detached[key] = nil + end end if slot._detachCleanup then unbindLifetime(slot._detachCleanup) -- 이미 손으로 비웠으니 Effect는 할 일 없음 @@ -2077,6 +2255,10 @@ local function destroySlotTree(slot) unbindLifetime(slot._listObserver) slot._listObserver, slot._listActivated = nil, nil end + if slot._baseObserver then -- [2026-08-21 G절] 파괴는 핸들까지 + unbindLifetime(slot._baseObserver) + slot._baseObserver = nil + end local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들 if bk then for i, observer in pairs(bk.observers) do @@ -2094,11 +2276,19 @@ local function destroySlotTree(slot) end -- [명확화, 2026-08-13 감사에서 index/element 불일치 발견해 보강] 아래 --- 시그니처는 index 기준 예시 — 위 "raw* 내부 호출 규약" 절이 이미 --- 못박았듯 reconcile(위 "여러 Slot이 섞일 때" 절 근처)은 element 기준으로 --- rawRemove(self, prev)를 부름. **같은 불일치가 rawUnmount에도 그대로 --- 있음** — 아래 reconcile 예시의 rawUnmount(self, prev) 호출도 prev가 --- element(mounted[key])이지 index가 아님. 둘 중 하나로 통일할지 얇은 +-- **✅ [해소, 2026-08-21 5라운드 — index 기준으로 통일 확정]** 오래 열려 있던 +-- "raw*가 index 기준과 element 기준으로 섞여 있다"는 캐비엇은 **전부 index로 +-- 통일**하는 것으로 닫혔다(**사용자 확정**: *"index 로 전부 처리되면 될듯. +-- 애초에 안에서 다시 element -> index 를 찾아야하던걸로 앎"*). +-- * `rawRemove`/`rawUnmount`/`rawDetach`/`rawMove`/`rawSwap`/`rawExtract`/ +-- `rawSplice`/`rawReplace` — **전부 index를 받는다.** +-- * 예외는 `rawAdd(self, element, index, fromDetached?)` 하나 — 새로 넣는 +-- 대상이라 element가 인자인 게 당연하다(그 element는 **이미 래핑된 물리 +-- 요소**여야 한다, 아래 "래핑은 raw 바깥에서" 항목). +-- * 그래서 element만 손에 쥔 호출부(`:List`의 `settle`)가 index를 구해야 +-- 하는데, **그 값은 이미 `keyIndex`가 들고 있다**(그 키가 지금 차지한 +-- 압축 위치 = `_elements` 인덱스). `indexOfRaw`(O(n) 선형 탐색)는 그게 +-- 없는 예외 경로용 폴백이지 기본 경로가 아니다. 둘 중 하나로 통일할지 얇은 -- 변환 계층을 둘지는 아직 M6 구현 세부로 열려 있음 — 이 블록/아래 -- reconcile 블록 둘 다 그 결정 전 illustrative 예시. -- [신설, 2026-08-13 여섯 번째 세션] rawRemove의 비파괴 짝 — `:List`의 @@ -2110,7 +2300,7 @@ function rawUnmount(self, index) unbindLifetime(bk.observers[index]) end releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음) - if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end + if isSlot(element) then unmountSlotTree(element) else unmountInst(element) end spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고 recompute(self, bk) @@ -2127,7 +2317,7 @@ function rawDetach(self, index) unbindLifetime(bk.observers[index]) end -- releaseOwner를 **안 부름** — 이게 rawUnmount와의 유일한 차이 - if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end + if isSlot(element) then unmountSlotTree(element) else unmountInst(element) end spliceArraysDown(self, index) recompute(self, bk) @@ -2136,21 +2326,90 @@ end -- [신설, 2026-08-21] 밀려나거나 지워지는 요소의 처분 — `Owned`가 정한다 -- (위 "`Owned` 옵션" 절). detach 중이던 요소는 이미 `_elements`에 없으므로 -- spliceArraysDown/recompute가 필요 없어 경로가 갈린다. -function releaseElement(self, element, wasDetached) +-- [시그니처 정리, 2026-08-21] index 우선 — detach 중이던 요소만 index가 없다 +-- (트리 밖이라 자리 자체가 없음), 그 경우 `index = nil`로 부른다. +function releaseElement(self, index, element, wasDetached) if wasDetached then if self._owned ~= false then - if isSlot(element) then destroySlotTree(element) else element:Destroy() end + if isSlot(element) then destroySlotTree(element) else disposeInst(element) end end return -- 이미 물리 트리 밖 + 부기 밖이라 더 할 일 없음 end - if self._owned ~= false then rawRemove(self, element) -- 파괴 - else rawUnmount(self, element) end -- 언마운트만(사용자 것) + if self._owned ~= false then rawRemove(self, index) -- 파괴 + else rawUnmount(self, index) end -- 언마운트만(사용자 것) end -- **raw 3형제 — 갈리는 축이 둘(파괴하는가 / 소유권을 놓는가)**: -- rawRemove : 소유권 반납 + **파괴** -- rawUnmount: 소유권 반납 + 파괴 안 함 ← 요소를 살려서 내보내는 경로 -- rawDetach : **소유권 유지** + 파괴 안 함 ← 내가 계속 들고 있는 경로 +-- 셋 다 **자리를 없애는** 연산이라 spliceArraysDown이 따라붙는다. 자리를 +-- 유지한 채 내용만 바꾸는 rawReplace(아래)는 그래서 별개 축이다. + +-- [신설, 2026-08-21 5라운드 `C-1`] rawAdd — 이 문서에서 가장 많이 참조되는데 +-- 정의가 없어서 `_mounted` 분기가 다른 함수 주석에만 흩어져 있었다. 새 결정은 +-- 없고 기존 서술을 모은 것(사용자 확인, 5라운드). +function rawAdd(self, element, index, fromDetached) + claimOwner(element, self, fromDetached) -- 이미 누가 갖고 있으면 error(detach 재마운트만 예외) + index = index or (#self._elements + 1) + table.insert(self._elements, index, element) + + if not self._mounted then + -- **아직 마운트 전: 부기도 물리도 없다.** 이 분기가 `materializeSlotTree`가 + -- `activateList`를 Blocker **밖**에서 불러도 안전한 이유다(5라운드 `AS-5`) + -- — 게이팅할 recompute 자체가 안 일어난다. 나중에 attachSlot이 통째로 처리. + return index + end + + local bk = getBookkeeping(self) + spliceArraysUp(self, index) -- _elements 외 배열들을 한 칸 밀고 bk.N 증가 + + if isSlot(element) then + attachSlot(element, self._mountedInst, self, index) -- 자식이 자기 부기+물리를 다 함 + else + Dispatch.setOffsetSource(self, index, None) -- 순서는 늘 offsetSource → setLength(C4) + Dispatch.setLength(self, index, 1, self._mountedInst) + recompute(self, bk) -- 부기 완결(C7: 물리보다 먼저) + mountInst(self._mountedInst, element, Dispatch.getOffsetAt(self, index)) + -- 그 다음에야 물리 마운트(주입 op, 위 그 절) + 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* 인자 규약). +function rawReplace(self, index, newElement, destroyOld) + local oldElement = self._elements[index] + claimOwner(newElement, self) -- 새 요소 먼저 클레임(실패하면 아무것도 안 바뀜) + releaseOwner(oldElement, self) + + if self._mounted then + -- 빼기는 물리 먼저(C7의 "좁은 쪽이 먼저") + if isSlot(oldElement) then unmountSlotTree(oldElement) else unmountInst(oldElement) end + end + if destroyOld then + if isSlot(oldElement) then destroySlotTree(oldElement) else disposeInst(oldElement) end + end + + self._elements[index] = newElement -- **시프트 없음** — 자리 수가 안 변한다 + if not self._mounted then return end + + local bk = getBookkeeping(self) + if isSlot(newElement) then + attachSlot(newElement, self._mountedInst, self, index) + else + Dispatch.setOffsetSource(self, index, None) + Dispatch.setLength(self, index, 1, self._mountedInst) + recompute(self, bk) + mountInst(self._mountedInst, newElement, Dispatch.getOffsetAt(self, index)) + end +end + function rawRemove(self, index) local element = self._elements[index] local bk = getBookkeeping(self) @@ -2162,7 +2421,7 @@ function rawRemove(self, index) -- rawExtract가 releaseOwner를 부른다고 이미 명시하고 -- 있었는데 코드만 불일치) — 엄격 releaseOwner가 -- 들어온 뒤로는 이 누락이 실동작 차이를 만듦 - if isSlot(element) then destroySlotTree(element) else element:Destroy() end + if isSlot(element) then destroySlotTree(element) else disposeInst(element) end spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고 recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만 @@ -2288,16 +2547,70 @@ Single(element)`을 대신 삽입** — raw 반응형 요소는 전부 Slot-in-S 중첩(위 절) 위에 얹힌 `:Single`의 얇은 sugar일 뿐이다: ```lua +-- [정정, 2026-08-21 5라운드 `C-3`] 래핑을 공개 `Add` 안에 인라인으로 두지 않고 +-- **공용 헬퍼 하나**로 뺀다 — `Replace`/`Extract(index, new)`/`Splice`/`:List`의 +-- reconcile까지 전부 같은 래핑이 필요하기 때문(아래 절). function Slot:Add(element, index) - if isState(element) then - local sub = Slot() - sub:Single(element) -- updateFn 생략 시 identity 기본값(아래 참고) - element = sub - end - return rawAdd(self, element, index) + return rawAdd(self, wrapElement(element), index) end ``` +### ⭐ 래핑/언래핑은 Slot 전체에 걸린 연산이다 (2026-08-21 구현 전 QA 5라운드 `C-3`) + +**사용자 지적**: *"replace, extract 등에서도 래핑해야하므로 래핑이 하나의 +함수로 나와야한다. 그리고, 또, IndexOf 와 Get 등은 래핑 전 객체를 주어야할텐데 +… 입력을 isState 인지 확인하고 래핑하거나 안 하거나 하는 래퍼와 반대로 get 등을 +위해 언랩하는 도구가 필요하다."* 그대로 확정한다. + +```lua +-- Slot 내부 비공개 헬퍼 둘. 이 둘만이 래핑을 아는 자리다. +local function wrapElement(v) + if not isState(v) then return v end + local sub = Slot() + sub:Single(v, nil, { Owned = false }) -- updateFn은 identity 기본값. + -- **`Owned = false`를 여기서 실제로 넘긴다** — 안쪽 + -- 요소는 사용자 것이라 래퍼가 죽어도 파괴하면 안 됨. + sub._wrapped = v -- ← 역참조. 언래핑이 O(1)이 되는 근거 + return sub +end + +local function unwrapElement(el) + if el == nil then return nil end + return el._wrapped or el -- 래퍼면 원래 값, 아니면 자기 자신 +end +``` + +- **⭐ [확정, 2026-08-21] 래핑은 `raw*` **바깥**에서 한다 — `raw*`는 언제나 + 이미 래핑된 물리 요소만 다룬다.** 래핑 지점은 정확히 둘: 공개 표면 + (`Slot:Add`/`Replace`/`Extract(index, new)`/`Splice`)과 `:List`의 `settle`. + (**사용자 확인**: *"raw들은 모두 래핑된거 그대로 넣고 빼도록 할까?"* — 그렇다.) + - **왜 `rawAdd` 안이 아닌가**: `raw*`가 래핑까지 하면 "이 함수가 받은 게 + 사용자 값인가 물리 요소인가"가 호출부마다 달라져, 지금 막 index로 통일한 + 인자 규약이 다시 흐려진다. 래핑을 바깥에 두면 `raw*`의 세계는 **물리 + 요소 하나로 균일**하다. + - 그래서 `mounted[key]`도, `_elements`도, `_detached`도 전부 **물리 요소**를 + 담는다. 언래핑은 **사용자에게 나갈 때만** 일어난다. +- **`_elements`에는 항상 물리 요소(래퍼일 수 있음)가 들어간다** — 부기/물리 + 조작(`rawAdd`/`rawReplace`/`rawMove`/`rawRemove`/`attachSlot`)은 전부 이쪽을 + 본다. +- **사용자에게 나가는 값은 전부 `unwrapElement`를 거친다** — `Get`/`IndexOf`/ + `Extract`/`ExtractAll`/`Splice`의 반환값, 그리고 `:List`의 `updateFn`이 받는 + `prev`. 사용자는 자기가 넣은 State를 그대로 돌려받지, quad가 만든 래퍼 Slot을 + 보지 않는다. +- **`IndexOf(element)`는 언래핑 기준으로 비교**한다 — 사용자가 넣은 State를 + 그대로 넘겨도 그 자리를 찾아준다(비교가 O(n)인 건 원래 계약 그대로). +- **⭐ 이 한 쌍이 있으면 "반환값 맵"과 "물리 요소 맵"을 따로 둘 필요가 없다.** + `:List`의 `mounted[key]`는 **물리 요소 하나만** 들고, 사용자 관점의 값이 + 필요할 때(=`prev` 전달, 멱등 비교) `unwrapElement`를 부르면 된다 — 사용자가 + 예상한 그대로다(*"이 래핑 언래핑이 구현되면 자연스럽게 wrappers[key] | + mounted[key] 분리가 필요하지 않아질 수도 있긴하다"*). +- **래퍼 Slot의 소유 관계**: 래퍼는 quad가 만든 것이라 **부모가 파괴할 수 있고**, + 래퍼 안의 요소는 사용자 것이라 `Owned = false`로 설치된다 — `destroySlotTree(래퍼)`가 + `_owned == false`를 보고 안쪽은 언마운트만 하고 빠진다. 두 층이 정확히 이 + 구분으로 갈린다. +- **`Extract`가 돌려주는 것도 언래핑된 값**이고, 그 시점에 래퍼는 할 일이 + 없어져 그냥 버려진다(언마운트로 자기 구독이 풀리므로 GC-native). + **`:Single`의 `updateFn`을 선택 인자로 완화 — 기본값은 identity.** 이 sugar가 성립하려면 `:Single(state)`(updateFn 생략)이 유효해야 함 — `Slot:Single(state, updateFn?)`, 생략 시 `function(item) return item end`. @@ -2429,6 +2742,34 @@ return Slot { (GC-native, 이 프로젝트의 기본 원칙 그대로): 아무도 참조를 안 들고 있으면 그냥 GC됨. 명시적으로 지금 죽이고 싶으면 `dispose`(아래). +**⭐ [2026-08-21 5라운드 신설] 단, 아직 트리에 붙어 있는 동안 조상이 죽으면 +그 요소도 같이 죽는다 — `Owned = false`여도 마찬가지다.** 사용자 질문에서 +나온 확인 사항: + +```lua +Frame { + Slot { + State(Frame), -- unowned: quad는 이 Frame을 절대 파괴하지 않는다 + }, +} +``` + +여기서 바깥 `Frame`이 `Destroy`되면 **엔진이 서브트리를 재귀적으로 파괴**하므로 +안쪽 `State(Frame)`의 값도 같이 죽는다. **이건 의도된 동작이고 quad가 개입할 +자리가 아니다**(사용자: *"엔진 자체가 recursive 호출로 전부 죽이는게 +일반적이기에 우리가 빼줄 수 있는 요소도 아니고, 같이 죽는게 의도 동작"*). + +- **`Owned = false`가 약속하는 건 "quad가 안 죽인다"뿐**이지 "무슨 일이 있어도 + 살아남는다"가 아니다. 언마운트(`Parent = nil`)가 조상 파괴보다 **먼저** + 일어난 요소만 살아남는다. +- **그래서 `state`에서 값을 빼내 재사용하려면 조상이 살아있는 동안 + 꺼내야 한다** — 조상이 이미 죽은 뒤에 `state:Get()`으로 얻은 값은 **이미 + 죽은 Instance**다. 그 값에 다시 마운트를 시도하면 `bindLifetime`/`canExecute` + 게이트에 걸린다(바로 아래 "부수 효과" 문단이 서술하는 그 경로). +- 같은 이유로 `_detached`가 들고 있던 요소도 조상이 죽으면 같이 죽는다 — + detach는 `Parent = nil`이므로 **조상 트리에서 이미 빠져 있어** 이 경우엔 + 해당 없음(그쪽 정리는 `_detachCleanup`이 담당). + **부수 효과 — 이미 파괴된 대상에 재마운트하려는 시도가 자연히 막힘.** Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 물리 target에 묶고, 실제 동작 전에 `canExecute`를 확인**하도록 하면(`base/lifecycle-pattern.md`), diff --git a/.claude/base/tween-plan.md b/.claude/base/tween-plan.md index 149b90c..f033f9a 100644 --- a/.claude/base/tween-plan.md +++ b/.claude/base/tween-plan.md @@ -449,36 +449,36 @@ Tween(opts: { }) -> Tween ``` -## `Tween:Map(fn)` — 값만 갈아끼운 새 `Tween`을 반환 (2026-08-20 구현 전 QA 4라운드 `UI-8` 신설) +## `Tween:Mapped(fn)` — 값만 갈아끼운 새 `Tween`을 반환 (2026-08-20 구현 전 QA 4라운드 `UI-8` 신설, 이름은 2026-08-21 5라운드 확정) **동기**: `base/ui-shorthand-plan.md`의 숏핸드가 스칼라를 자식 프로퍼티 타입으로 감싸야 하는데(`UICorner = 8` → `CornerRadius = UDim.new(0, 8)`), 값이 `Tween`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만** 적용해야 한다. 그 문서가 `mapTweenValue(v, wrap)`라는 로컬 헬퍼로 적어뒀던 것을, 사용자 판정으로 **`Tween` 자신의 공개 메소드로 승격**한다: *"그냥 펑터 구조를 그대로 줘도 -무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성."* +무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성."*(이름은 이후 +`Mapped`로 확정 — 아래 마지막 항목) ```lua -tween:Map(fn: (T) -> U): Tween -- opts를 clone하고 Value만 fn(Value)로 교체해 새 Tween 반환 +tween:Mapped(fn: (T) -> U): Tween -- opts를 clone하고 Value만 fn(Value)로 교체해 새 Tween 반환 ``` - **타입이 안전하게 성립한다** — `Tween`는 immutable raw 값이고 `Value` 외의 필드는 값 타입과 무관한 옵션(`Time`/`Style`/…)이라, `Value`만 `U`로 바꾼 `Tween`를 만드는 건 타입 레벨에서 깨끗하다. -- **`Tween`가 immutable이라는 기존 확정과 일관** — `:Map`은 원본을 안 건드리고 +- **`Tween`가 immutable이라는 기존 확정과 일관** — `:Mapped`는 원본을 안 건드리고 `table.clone` 후 `Value`만 교체해 `Tween(opts)`로 다시 만든다(`Tag`/`Modifier`의 clone 체이닝과 같은 계열). - **부수 효과 — 기본 `Tween` 정의를 만들어두고 재사용하는 패턴이 열린다.** `local FAST = Tween{Value = 0, Time = 0.15}` 같은 상수를 두고 - `FAST:Map(function() return targetPos end)`처럼 옵션만 재사용할 수 있다. 다만 + `FAST:Mapped(function() return targetPos end)`처럼 옵션만 재사용할 수 있다. 다만 **사용 케이스가 넓지는 않을 것**으로 봄 — 어차피 내부 구현에 필요해서 만드는 것이고, 외부에 보이는 게 무해하니 같이 공개하는 것뿐(사용자 판단). -- **이름 — `Map` 또는 `Mapped`.** 코퍼스의 `-ed` 관례("clone 후 즉시 확정된 값"은 - `Added`/`Removed`/`Overridden`처럼 과거분사, `base/source-state-plan.md`의 - "네이밍 — `Compute`가 `-ed`가 아닌 이유" 절)를 그대로 적용하면 **`Mapped`가 - 더 일관적**이다 — `Tween`은 lazy가 아니라 즉시 확정되는 raw 값이므로. - 사용자도 둘 다 열어둠(*"혹은 Mapped 의 immutable 의 ed 형태를 써도 좋아보임"*) - — **⚠️ 최종 이름은 미확정**, 코퍼스 일관성만 보면 `Mapped` 쪽이 맞다. +- **✅ 이름 — `Mapped`로 확정(2026-08-21 구현 전 QA 5라운드 `TW-2`).** + 코퍼스의 `-ed` 관례("clone 후 즉시 확정된 값"은 `Added`/`Removed`/`Overridden`처럼 + 과거분사, `base/source-state-plan.md`의 "네이밍 — `Compute`가 `-ed`가 아닌 이유" + 절)를 그대로 적용한 결과 — `Tween`은 lazy가 아니라 즉시 확정되는 raw 값이므로 + `Mapped`가 맞다(**사용자 확정**: *"Mapped 로 확정"*). ## 네임스페이스드 객체 (더 이상 유효한 관심사 아님) diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 15dcc08..886d25e 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -191,11 +191,11 @@ end **한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.** **[승격, 2026-08-20 구현 전 QA 4라운드 `UI-8`] 아래 로컬 헬퍼 `mapTweenValue`는 -`Tween` 자신의 공개 메소드 `:Map(fn)`(이름 후보 `:Mapped`)으로 올라갔다** — -`base/tween-plan.md`의 "`Tween:Map(fn)`" 절이 소스. 이 문서에 로컬 헬퍼로 +`Tween` 자신의 공개 메소드 `:Mapped(fn)`으로 올라갔다**(이름은 2026-08-21 5라운드 `TW-2`에서 확정) — +`base/tween-plan.md`의 "`Tween:Mapped(fn)`" 절이 소스. 이 문서에 로컬 헬퍼로 두면 같은 변환이 다른 숏핸드/백엔드에서 또 복제되므로, 값 타입 자신이 제공하는 게 맞다는 사용자 판단. 아래 스케치는 그 메소드가 하는 일을 풀어 쓴 것으로만 -읽을 것(`isTween(v)` 분기는 호출부에 남고, `Tween`이면 `v:Map(wrap)`, 아니면 +읽을 것(`isTween(v)` 분기는 호출부에 남고, `Tween`이면 `v:Mapped(wrap)`, 아니면 `wrap(v)`): 숏핸드는 "스칼라를 받아 자식 프로퍼티 타입으로 감싸는" 변환을 갖고 있음(`UICorner = 8` → `CornerRadius = UDim.new(0, 8)`, 열린 질문 절의 룩업 테이블 `wrap=fn`). diff --git a/.claude/qa-request/pre-implementation-qa-round4-followup.md b/.claude/qa-request/pre-implementation-qa-round4-followup.md index 7adc630..7e3a806 100644 --- a/.claude/qa-request/pre-implementation-qa-round4-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round4-followup.md @@ -1181,8 +1181,11 @@ M6의 `Detach` 항목 둘이 옛 설계(userdata 보존, 키 소멸 처분 ⚠ ## H-7. 남은 것 - **`question.md` 3번의 관련 항목**은 이 처리로 전부 닫혔다. -- 사용자 지시대로 **5라운드 문항지는 만들지 않는다** — "이후 stale 만 잡는 - 것으로 끝낼 수 있어보임"(D절 회신). +- ~~사용자 지시대로 **5라운드 문항지는 만들지 않는다** — "이후 stale 만 잡는 + 것으로 끝낼 수 있어보임"(D절 회신).~~ **[2026-08-21 뒤집힘]** 같은 날 사용자가 + 5라운드를 요청해 `pre-implementation-qa-round5.md`를 만들었다 — 4라운드에서 + "예"로 넘어간 자리는 건너뛰고 **문항이 아예 없던 영역 + 이 followup이 새로 + 확정한 것 + 큰 문서의 심화**만 묻는 범위다. - 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`), `table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는 `luau-test/STATUS.md`. diff --git a/.claude/qa-request/pre-implementation-qa-round5-followup.md b/.claude/qa-request/pre-implementation-qa-round5-followup.md new file mode 100644 index 0000000..4bc3ae1 --- /dev/null +++ b/.claude/qa-request/pre-implementation-qa-round5-followup.md @@ -0,0 +1,802 @@ +# 구현 전 QA **5라운드 followup** — 회신 처리 결과 + 재질문 + +**상태**: **[2026-08-21] 4차까지 처리. 열린 항목은 `Gate` 하나뿐.** +A~E절은 1차 처리(문항지 회신), F절은 2차, G절은 3차(`mountInst` 삽입 위치 질문), +**최신은 H절**(그 결론 — `getOffsetAt` 신설로 확정, `base/` 반영 완료). 회신 원문은 +`pre-implementation-qa-round5-response.md`, 문항지 원본은 +`pre-implementation-qa-round5.md`. **처리 결과의 소스는 이 파일**이고, +지금 유효한 설계는 언제나 `base/`가 소스다(4라운드에서 이 구분이 실제로 +어긋난 사례가 있었다 — 아래 `A-8` 참고). + +**이번 라운드에서 언급 안 된 문항은 전부 "예"로 간주**했다(4라운드와 같은 +규약 — 회신은 아니오/보류만). + +--- + +## A. 반영 완료 — 바로 고친 것 + +전부 이번에 `base/`(+`ROADMAP.md`/`question.md`/`README.md`)에 반영했고 +`doc-check.py` ERROR 0을 유지했다. + +| # | 항목 | 무엇을 고쳤나 | 대상 | +|---|---|---|---| +| A-1 | `DE-7` | **`slot._detached`를 lazy(nilable)로** — 모든 Slot이 빈 테이블을 미리 갖지 않는다. 읽기는 `if slot._detached then`, 쓰기는 `getDetached(slot)`(getOrCreate). `settle`/`destroySlotTree`/`_detachCleanup` 세 자리 전부 nil 가드 | `slot-plan.md` | +| A-2 | `DE-7`(추가 질문) | **`prev`가 없는데 `Detach`를 반환해도 nop** — 사용자가 "지금 prev가 있는지"를 추적할 의무가 없다는 걸 계약으로 명시 | `slot-plan.md` | +| A-3 | `DE-9` | **`KeyGone`에 `nil`/`None`/`Detach` 외 반환은 전부 `error`** — `prev`뿐 아니라 **새 값도** 거부. 그래서 소멸 루프의 `settle` 호출은 항상 `result == nil`이고 교체 분기에 도달할 경로가 없어졌다(옛 코드는 `pos = 0`으로 `rawAdd`를 불러 범위 밖 인덱스로 터졌을 것) | `slot-plan.md` | +| A-4 | `DE-13` | **`Owned = false`에서 `Detach`는 `_detached`에 안 들어간다** — `rawDetach`가 아니라 `rawUnmount`(언마운트+소유권 반납)로 처리, 다음 사이클 `prev`는 `nil`. `Owned` 대조표에 `Detach` 행 추가, `_detachCleanup`의 `_owned` 분기는 **삭제**(도달 불가가 됨) | `slot-plan.md` | +| A-5 | `DE-11` | "홀드된 요소는 owner가 죽을 때까지 쌓인다 / 삽입·삭제 최적화일 뿐 그 이상을 돕지 않는다"를 **문서화 유의사항으로 명시**, 잘라내기 정책은 안 만든다 | `slot-plan.md` | +| A-6 | 회신 마지막 "+" | **조상이 죽으면 `Owned = false` 요소도 엔진 재귀 파괴로 같이 죽는다**를 신설 — "`Owned = false`가 약속하는 건 quad가 안 죽인다는 것뿐"이라는 경계까지 | `slot-plan.md` | +| A-7 | `AT-1`/`AT-2` | `groupClaimKeys`의 키를 **`(inst, groupValue) → k`로 확정**, `nameClaims`보다 **위치 claim을 먼저** 본다는 순서까지. `Frame { a, a }` 갭도 같이 닫힘 | `attribute-plan.md`, `question.md` | +| A-8 | `EF-3` | 4라운드가 반영했다고 적고 실제로는 누락됐던 **`E-10` dedup 대칭 결론을 실제로 반영** + `EF-5`(내부 Observer cascade도 dedup 분기 **안**) 명시. "미해결" 표시 제거 | `effect-plan.md` | +| A-9 | `TW-2`/`CR-2` | **`Tween:Mapped(fn)`으로 확정** — 문서 전체 표기 통일(`tween-plan.md`/`ui-shorthand-plan.md`) | `tween-plan.md`, `ui-shorthand-plan.md` | +| A-10 | `DC-6` | `Offset` Source의 identity를 유지하는 진짜 이유를 사용자 서술로 정밀화 — "언마운트 때 이미 렌더된 요소들이 그 Source를 **구독한 채 함께 딸려 나간다**" | `dispatch-core-plan.md` | +| A-11 | `DC-11` | 배치 끝 `recompute`가 실제로 하는 일을 명시 — offset은 즉시 계산이 이미 채웠고, **(a) Slot owner의 `.Length` 확정**(사용자 추측대로 이게 주 목적)과 (b) 등록 후 바뀐 길이 교정이 역할 | `dispatch-core-plan.md` | +| A-12 | `DC-14` | 재진입 경로가 **정상 API로는 아예 만들 수 없다**를 명시(`_crudUsed` ↔ `_listed` 가드 때문) | `dispatch-core-plan.md` | +| A-13 | `CR-3`/`DT-4` | **"게이팅 먼저" 결정 반영** — M2 각주를 해소로 갱신하되, 앞당기는 대상이 `Blocker`가 아니라 공용 `Gate` 노드라는 것과 표면이 미정이라는 것까지 | `ROADMAP.md`, `question.md` | +| A-14 | 새 문서 2개 | `research/gate-primitive.md`, `research/state-epoch-validation.md` 신설(아래 D절) + `README.md` 색인 | `research/`, `README.md` | + +--- + +## B. 답변 — 물어보신 것 + +### B-1. `DE-13` — `_detachCleanup`이 뭔가, 그리고 unowned 판단이 맞나 + +**먼저 `_detachCleanup`이 뭔지**: **"detach해둔 요소들을 나중에 청소하는 쪽"**이 +맞다. 정확히는 — `activateList`가 Slot마다 하나 설치하는 **`Effect`이고, 그 +cleanup이 `slot._detached`를 전부 비운다.** 언제 도느냐가 핵심인데, +`bindLifetime(physicalTarget, self._detachCleanup)`으로 **물리 target에 +앵커**돼 있어서 **그 물리 Instance가 죽을 때** cleanup이 돈다. + +왜 `Effect`가 유일한 도구냐면, `bindLifetime`은 "지금 실행해도 되는가"만 +게이팅할 뿐 **죽는 순간의 콜백을 안 준다** — 죽을 때 뭔가를 하려면 `Effect`의 +cleanup 계약이 필요하다(`base/effect-plan.md`). + +**그리고 unowned에 대한 판단 — 맞다. 잘못 흐른 게 아니다.** 검토 결과: + +- `Owned = false`는 "이 요소는 애초에 내 게 아니다"이므로, **"잠깐 빼두고 + 내가 계속 들고 있는다"(= `Detach`)가 성립할 수 없다.** 들고 있으려면 + 소유권을 유지해야 하는데(`rawDetach`가 `releaseOwner`를 안 부르는 게 + 그 핵심), 남의 것에 소유권을 유지하는 건 모순이다. +- 그래서 `Owned = false` + `Detach`는 **`rawUnmount`(언마운트 + 소유권 + 반납)**로 처리하고 `_detached`에 안 넣는다 → 다음 사이클 `prev`는 `nil`. + **말씀하신 그대로 반영했다.** +- **부수 결과 둘**: (1) unowned `:List`에서는 `Detach`와 `nil` 반환이 + **완전히 같은 동작**이 된다(둘 다 언마운트+반납). (2) unowned Slot은 + `_detached`가 영원히 비어 있으므로 `_detachCleanup`의 `_owned ~= false` + 분기가 **도달 불가**가 된다 — 그래서 그 분기를 지웠다(이제 cleanup에 + 오는 건 전부 내 것). +- **`state` 의미론과의 정합도 그대로 지켜진다** — 값 교체 시 + `releaseElement`가 `_owned == false`를 보고 `rawUnmount`로 빠지므로 이전 + 요소는 파괴되지 않는다. 말씀하신 논거와 같다. + +### B-2. `DE-7` 추가 질문 두 개 + +**(a) 아무것도 없는데 `Detach`를 보내면?** → **무시(nop)한다.** 지금 +의사코드가 이미 `if wasMounted ~= nil then ... end`로 감싸고 있어서 `prev`가 +없으면 아무 일도 안 일어난다. **사용자가 "지금 prev가 있는지"를 추적할 의무가 +없다**는 걸 계약으로 명시해뒀다(A-2) — `if not shouldShow(item) then return +Detach end`처럼 조건만 보고 반환해도 안전하다. 이렇게 둔 이유는 "이미 detach +중이면 nop"과 같은 결이기 때문이다(둘 다 "이 자리를 비워라"인데 이미 비어 +있는 상태). + +**(b) 마운트 상태의 `prev`를 다시 반환하는 건 여전히 잘 도는가?** → **그렇다.** +`settle`의 `result == prev` 분기가 `wasDetached == nil`이므로 재마운트 쪽으로 +안 가고, `keyIndex[key] ~= pos`일 때만 `rawMove`를 부른다. 값도 위치도 그대로인 +가장 흔한 경로는 **테이블 조회 몇 번이 전부**이고 물리 트리에 손을 안 댄다. +이번 변경(lazy `_detached`, unowned 분기)은 전부 `detach == true` 경로나 +`wasDetached ~= nil` 경로만 건드려서 이 경로에는 닿지 않는다. + +### B-3. `AS-5` — `activateList`가 상위에 `setLength`를 하는가 + +**안 한다. 상위 등록(`Dispatch.setLength(ownerKey, position, slot.Length)`)은 +`materializeSlotTree`의 마지막 줄이 유일한 자리**이고, 그건 Blocker를 이미 +`OffWithoutEmit()`한 뒤다. `activateList`는 자기 `:List`를 실체화하면서 +`rawAdd`만 부른다. + +**그럼 그 `rawAdd`가 부기를 건드리지 않는다는 보장은 어디서 오는가** — 그 +Slot이 아직 `_mounted == false`이기 때문이다. 이 상태의 `rawAdd`는 +"`_elements`에만 넣고 끝"이라 `setLength`/`setOffsetSource`/`recompute`를 +아예 안 부르므로, **게이팅할 대상 자체가 없다.** 초기 population이 만든 +요소들의 부기는 그 직후 `materializeSlotTree`의 `_elements` 등록 루프가 +(이번엔 Blocker를 켜고) 한꺼번에 처리한다. + +**⚠️ 다만 확인 중에 실제 갭을 하나 찾았다 — 그 보장을 담은 `rawAdd` 의사코드가 +문서에 없다.** `rawRemove`/`rawUnmount`/`rawDetach`/`releaseElement`는 전부 +코드 블록이 있는데 **가장 많이 참조되는 `rawAdd`만 정의가 없고**, `_mounted` +분기는 다른 함수의 주석에만 흩어져 있다. 아래 `C-1`에서 초안을 제안한다. + +### B-4. `DC-11` — 배치 끝 `recompute`가 왜 필요한가 + +**추측하신 대로 `Length` 때문이 맞다.** 정리하면: + +- **offset은 이미 채워져 있다** — `setOffsetSource`의 즉시 계산이 등록 시점마다 + `1..i-1` 길이 합을 넣어두고, 그것들은 `i`보다 먼저 등록되므로 항상 정확하다. + 그래서 이 마지막 `recompute`는 offset에 대해선 `Get() ~= sum` 가드에 걸려 + 대부분 아무것도 안 쓴다. +- **실제 역할은 (a) `ownerKey`가 Slot이면 `ownerKey.Length`(= 기여도 합) 확정, + (b) 등록된 뒤에 값이 바뀐 길이가 있으면 그 뒤 형제 offset 교정.** +- `ownerKey`가 물리 `inst`인 `Dispatch.drive` 경로에선 (a)가 없어 사실상 + 검증 패스지만, `Set`이 거의 없는 O(N) 순회라 분기해서 빼지 않고 그냥 항상 + 부른다. **문서에 이대로 반영했다**(A-11). + +### B-5. `+` — `state` → `Slot:Single { Slot }`에서 부모가 Length를 따라가는가 + +**따라간다.** 한 단계씩 트레이싱하면: + +1. `Slot:Add(state)`가 래퍼 `sub = Slot(); sub:Single(state)`를 만들어 + 부모 `_elements[i]`에 넣는다. +2. 부모의 `materializeSlotTree` 루프가 `isSlot(sub)`을 보고 재귀 → + `sub`가 자기 실체화를 끝내고 **마지막에 + `Dispatch.setLength(parent, i, sub.Length)`** 를 부른다. 여기서 넘기는 건 + **값이 아니라 `Source` 객체 자신**이라, 부모는 그 객체를 구독해둔다. +3. 나중에 state가 다른 Slot을 emit하면 `sub`의 reconcile이 교체를 수행하고, + 그 안에서 `recompute(sub, bk)`가 `sub.Length`를 새 합으로 `Set`한다. +4. 부모가 2번에서 걸어둔 Observer가 그 `Set`을 받아 `gatedRecompute(parent)` → + 부모 `recompute`가 자기 `Length`와 뒤 형제 offset을 갱신한다. + +**한 가지 캐비엇**: 3번에서 교체는 `rawUnmount`(→ `spliceArraysDown` + +`recompute`) **다음** `rawAdd`(→ 등록 + `recompute`) 순서라, `sub.Length`가 +**잠깐 줄었다가 다시 늘어난다.** 부모 쪽 `recompute`도 그만큼 두 번 돈다. +크래시나 오작동은 아니고(그 사이에 프레임 경계가 없다 — yield 금지 계약), +뒤 형제 offset이 두 번 계산되는 낭비다. `:Single`처럼 항상 0/1개인 경우엔 +`Detach`/재마운트 경로가 아니라면 피하기 어렵다 — **지금은 그대로 두는 게 +맞다고 보는데, 아니면 알려주시라**(교체를 "먼저 넣고 나중에 빼는" 순서로 +바꾸면 없앨 수 있으나 `C-7`("빼기는 물리 먼저")과 부딪힌다). + +### B-6. `+` — 조상이 죽을 때 안에 있던 요소도 같이 죽는 것 + +**언급이 없었다 — 이번에 신설했다**(A-6). 요지는 문서에 이렇게 적었다: +`Owned = false`가 약속하는 건 **"quad가 안 죽인다"뿐**이지 "무슨 일이 있어도 +살아남는다"가 아니고, 언마운트가 조상 파괴보다 **먼저** 일어난 요소만 +살아남는다. 그래서 조상이 이미 죽은 뒤에 `state:Get()`으로 꺼낸 값은 **이미 +죽은 Instance**이고, 재마운트를 시도하면 `bindLifetime`/`canExecute` 게이트에 +걸린다. `_detached`는 이미 `Parent = nil`이라 이 경우에 해당하지 않는다(그쪽 +정리는 `_detachCleanup`). + +### B-7. `SS-2`/`SS-3` — 에포크 제안에 대한 답 + +**"선제 최적화가 아니라 확정 동작으로의 승격"이라는 판단에 동의한다.** +길어서 `research/state-epoch-validation.md`로 뺐고(D절), 요지만: + +- **지목하신 glitch는 실재한다.** 지금 `base/source-state-plan.md`의 다이아몬드 + 절은 "중복 재계산이 없다"만 말하는데, 그 논증은 *`Get()`이 전파 파동이 끝난 + 뒤에 온다*고 암묵 가정한다 — Observer가 전파 도중 발화한다는 사실과 겹치면 + 그 가정이 깨진다. **문서 어디에도 이 현상이 서술돼 있지 않다.** +- **제안한 방식이 고치는 것**: 섞인 값(정확성)과 중복 재계산. 아직 신호를 못 + 받은 가지도 `Get()` 때 자기 `sourceList`의 카운트 불일치를 보고 스스로 + 재계산하므로, `Get()`이 "지금 이 순간의 일관된 값"을 준다는 보장이 **처음으로** + 성립한다. +- **안 고쳐지는 것**: 중복 **통지**. Observer는 여전히 두 번 운다(값은 두 번 다 + 옳다). "에포크를 넣으면 다이아몬드가 완전히 해결된다"고 적으면 틀린 서술이 된다. +- **선례가 있다** — 값이 아니라 **버전/에포크를 비교해 lazy하게 검증**하는 건 + MobX·Adapton류가 쓰는 표준 기법이고, quad가 이미 택한 pull 모델과 결이 같다 + (Fusion식 eager 위상정렬의 대안). +- **비용 추산에 동의**한다(`rawInvalid`가 false면 훑지도 않으므로 흔한 경로는 + 지금과 같은 비용). +- **⭐ 채택한다면 같이 못 박아야 하는 것 하나** — `sourceList`는 `:With`/trailing + deps로 **선언된** 상류에서만 합성되므로, `:Compute` 콜백이 클로저로 잡은 + **선언 안 한 Source**를 `Get()`하면 에포크 비교가 못 잡는다. 지금도 stale이지만 + 새 모델은 "`Get`은 항상 일관"이라는 **더 강한 약속**을 하므로, 그 예외를 + UB로 명문화해야 한다. + +**`Get`이 최신을 준다는 말이 무력화되는 것 아니냐**는 우려에 대해선 방향이 +반대라고 본다 — **지금 모델이 그 약속을 못 지키고 있었고**, 이 제안이 지키게 +만든다. + +--- + +## C. 사용자 판단 필요 — 임의로 처리하지 않은 것 + +### C-1. ⭐ `rawAdd` 의사코드가 문서에 없다 — 초안 승인 요청 (`AS-5`에서 발견) + +`rawAdd`는 이 문서에서 가장 많이 참조되는 함수인데 **정의 블록이 없고**, +`_mounted` 분기·부기 순서·nested 재귀가 전부 다른 함수의 주석에 흩어져 있다. +`AS-5`의 보장("게이트 없이 `recompute`가 돌 일이 없다")이 정확히 그 분기에 +달려 있으므로 명시적으로 적어두는 게 맞다고 본다. 초안: + +```lua +-- [초안, 5라운드 C-1] 기존 서술을 모은 것 — 새 결정은 없음. +function rawAdd(self, element, index, fromDetached) + claimOwner(element, self, fromDetached) -- 이미 누가 갖고 있으면 error(detach 재마운트만 예외) + index = index or (#self._elements + 1) + table.insert(self._elements, index, element) + + if not self._mounted then + return index -- 아직 마운트 전: 부기도 물리도 없음. attachSlot이 나중에 통째로 처리 + end + + local bk = getBookkeeping(self) + spliceArraysUp(self, index) -- _elements 외 배열들을 한 칸씩 밀고 bk.N 증가 + + if isSlot(element) then + -- 자식 Slot은 자기 부기를 자기가 등록한다(setOffsetSource → setLength 순서 포함) + attachSlot(element, self._mountedInst, self, index) + else + Dispatch.setOffsetSource(self, index, None) -- 순서: 항상 offsetSource 먼저(C4) + Dispatch.setLength(self, index, 1) + recompute(self, bk) -- 부기 완결(C7: 물리보다 먼저) + element.Parent = self._mountedInst -- 그 다음에야 물리 마운트 + end + return index +end +``` + +**확인이 필요한 지점 둘**: +1. **`element.Parent` 대입이 `attachSlot` 쪽 분기엔 없다** — 자식이 Slot이면 + `mountSlotTree`가 대신 해주기 때문. 맞나? +2. **`recompute` 호출 위치** — plain 요소는 위처럼 `setLength` 직후 1회면 + 충분하지만, Slot 자식은 `attachSlot`(정확히는 그 안의 `materializeSlotTree`)이 + 자기 끝에서 `setLength(self, index, ...)`를 부르고 그게 `gatedRecompute`를 + 태우므로 여기서 또 부를 필요가 없다고 봤다. 맞나? + +### C-2. ⭐⭐ `DC-19` — `rawAdd`가 `Length`를 직접 `Set`하는 서술이 지금 계약과 충돌한다 + +말씀하신 "목적이 다르지 않나"를 파고들다 더 큰 걸 찾았다. +`dispatch-core-plan.md`는 두 자리에서 **`rawAdd`가 `self.Length:Set(newCount)`를 +부른다**고 적어두는데: + +1. **`newCount`(개수)는 이제 `Length`의 정의가 아니다** — `Length`는 + "요소별 기여도의 합"(plain=1, nested Slot=그 `.Length`)으로 바뀌었다. + 개수로 `Set`하면 중첩이 있는 순간 틀린 값이 된다. +2. **쓰는 주체가 둘이 된다** — `recompute`가 이미 + `ownerKey.Length:Set(sum)`으로 확정 기록을 한다. 같은 Source에 두 곳이 + 쓰면 어느 쪽이 진실인지가 갈린다. +3. 지적하신 "가드가 필요한가"도 여기서 답이 나온다 — `rawAdd` 자리에서는 + 카운트가 **항상** 달라지므로 `Get() ~= sum` 가드가 아무것도 안 걸러서 + 실제로 무의미하다(가드가 값을 하는 건 `recompute`의 전체 순회 쪽뿐). + +**제안**: `rawAdd`에서 `Length:Set`를 **빼고**, `Length`는 `recompute`만 쓴다 +(위 `C-1` 초안이 그렇게 돼 있다). "부기가 물리보다 먼저"(C-7)는 `recompute`가 +`Parent` 대입 앞에 오는 것으로 그대로 지켜진다. **이대로 정정할까?** + +### C-3. ⭐⭐ `DE-17` — `updateFn`이 State를 반환할 때의 래핑/`prev` 규칙이 지금 문서엔 없다 + +말씀하신 *"updateFn 이 state 를 던져도 싱글 slot화 된다"*를 확인하러 갔더니 +**지금 문서는 그렇게 안 돼 있다**: + +- State → nested Slot 래핑은 **공개 `Slot:Add`에만** 있다 + (`if isState(element) then sub = Slot(); sub:Single(element); element = sub end`). +- 그런데 `:List`의 reconcile은 공개 `Add`가 아니라 **`rawAdd`를 직접** 부른다 + (가드/에러 체크가 중복이라 의도적으로 그렇게 확정돼 있다). +- 그래서 지금 문서대로면 **`updateFn`이 State를 반환하면 래핑 없이 `_elements`에 + raw State가 들어간다** — 요소 타입 제약 위반. + +그리고 래핑을 하기로 하면 **`prev` identity 문제가 따라온다.** 말씀하신 +*"prev 는 이전에 던진 그대로"* + *"이전과 같은 state 를 던지면 멱등으로써 +새로운 slot 을 만들지 않고 그대로 두는것"*을 둘 다 만족시키려면, `settle`의 +`result == prev` 비교가 **래퍼가 아니라 사용자가 반환한 값**을 봐야 한다. + +**선택지**: + +1. **(권고) 래핑을 `rawAdd`로 내리고, `:List`는 "반환값"과 "물리 요소"를 따로 + 기억한다.** `mounted[key]`엔 사용자가 반환한 값(raw State)을 넣고, 래퍼 Slot은 + `wrappers[key]`(또는 `mounted[key] = {returned, element}`)에 둔다. + `rawMove`/`rawDetach`/`releaseElement`는 래퍼에, `prev`/멱등 비교는 반환값에 + 적용. → 사용자가 원하는 의미론 그대로, 대신 `:List` 내부 상태가 하나 는다. +2. **래핑을 `settle`에서 한다** — 효과는 (1)과 같고 `rawAdd`는 안 건드린다. + 대신 `Slot:Add`와 `:List` 두 곳에 같은 래핑 코드가 생긴다. +3. **`:List`의 `updateFn`이 State를 반환하는 걸 금지(error)** — 사용자가 직접 + `Slot():Single(state)`을 만들어 반환하게 한다. 가장 단순하지만 말씀하신 + 방향과 반대다. + +**부수 확인**: 그 래퍼의 `:Single` 설치가 `Owned = false`가 되는 것 +(*"owned = false 가 되는건 이 싱글 슬롯 안 1번째 객체에 대해서 적용"*)은 +맞다고 보고, 래퍼 Slot **자신**은 quad가 만든 것이라 부모가 파괴할 수 있다 +(`destroySlotTree(wrapper)` → `_owned == false`라 안쪽은 언마운트만) — +이 조합도 확인 부탁드린다. + +### C-4. ⭐⭐ `LC-3`/`LC-4` — `setLength`의 Observer를 Slot에 앵커하는 걸 되돌리자는 제안 + +지적이 맞다고 본다. 지금 확정(4라운드 `D-56`)은 *"`bindLifetime`의 첫 인자가 +Slot일 수 있으니 백엔드가 그 경우를 핸들링하라 + `isBoundAlive`에 세 번째 +분기를 둬라"*인데, 물으신 대로 **왜 Slot이 소유자여야 하는지**가 실제로는 +근거가 약하다: + +- `Dispatch.setLength(ownerKey, i, len)`이 만드는 Observer는 `ownerKey`가 + **부기 키**라는 이유로 `bindLifetime(ownerKey, observer)`를 부르고 있는데, + **부기 키와 생명주기 앵커는 별개 개념**이다. +- 실제로 그 Observer가 살아야 하는 기간은 "이 Slot이 그 물리 트리에 마운트돼 + 있는 동안"이고, 그건 **`physicalTarget`이 정확히 표현한다.** Slot 자신의 + 생존은 부모의 `_elements` 강참조가 이미 보장한다(말씀하신 그대로). +- 그리고 `setLength`가 불리는 모든 자리에서 **물리 target을 이미 알고 있다** — + `Dispatch.drive`(=`inst`), `materializeSlotTree`(=`physicalTarget`), 런타임 + 단건 `rawAdd`(=`self._mountedInst`). + +**제안**: `setLength`가 **부기 키(`ownerKey`)와 앵커(`physicalTarget`)를 따로 +받게** 하고, `bindLifetime`은 **항상 물리 Instance만** 받는다. + +- `base/lifecycle-pattern.md`의 `(1-1)` 절(첫 인자가 Instance가 아닐 수 있다는 + 백엔드 요구사항)이 **통째로 불필요**해진다. +- `isBoundAlive`의 **세 번째 분기도 필요 없어진다**(지금 ⚠️ 미정으로 열려 있는 것). +- 포탈(언마운트→재마운트)에서도 앵커가 자연히 새 target으로 옮겨간다 — + `unmountSlotTree`가 `bk.observers`를 `unbindLifetime`하고 재마운트 시 + `materializeSlotTree`가 다시 등록하는 지금 흐름 그대로. +- `getBlocker(slot)`/`getBookkeeping(slot)`은 그대로 Slot을 `Relate` 키로 + 쓴다(그건 weak 키일 뿐 생명주기 앵커가 아니라 문제없다). + +**이 방향으로 `D-56`을 되돌릴까?** 되돌린다면 `base/lifecycle-pattern.md`, +`base/dispatch-core-plan.md`(`setLength` 시그니처), `base/slot-plan.md`를 같이 +고쳐야 한다. + +### C-5. `Gate` — 이름과 표면 (`CR-3`/`DT-4`) + +`research/gate-primitive.md`로 정리했고(D절), 결정이 필요한 건 넷: +**(a) 이름**(권고: `Gate` 그대로 — `blocker`/`modifier`와 달리 `gate`는 이미 +행위자가 아니라 **장치**를 가리키는 명사라 `-er`가 필요 없다. 대안: +`Valve`/`Relay`), **(b) `:Apply` 팩토리인지 독립 생성자인지**, **(c) `Blocker`가 +그 위에 어떻게 얹히는지**(공유 외부 객체라 모양이 다름), **(d) M2에 `Gate`만 +넣을지 `Blocker`까지 넣을지**. + +### C-6. `+` — `Effect`가 여러 의존성(특히 `Ref`)을 직접 받는 안 + +제안하신 방향(`:With`로 합치지 말고 `Effect(fn, ...)`가 여러 요소를 각각 +Observe/Callback하고, 최초 발화는 `Blocker`의 다른 사용법으로 억제)은 +**타당해 보이고, 지금 실제로 갭이 맞다** — `Ref`는 State가 아니라 +(`:Callback`만 있고 emit이 없다) `:With`로 합칠 수가 없어서 **오늘은 Effect의 +의존성이 될 방법이 아예 없다.** + +정하고 갈 것들: +1. **`fn`이 받는 인자 모양** — `:Compute(fn, ...deps)`가 이미 + `fn(self, previous?, ...deps)`로 **trailing deps를 lazy 핸들로 위치 인자화** + 하는 선례가 있으니 그대로 따르는 게 맞아 보인다(새 규칙 없음). +2. **`Ref` 의존성의 발화 시점** — Ref는 "한 번 채워지는" 값이라 State처럼 + 반복 emit이 없다. 채워질 때 1회 발화 + 이후 재설정 시 발화(Ref는 반복 + 재설정 가능)로 보면 되나? +3. **최초 1회 실행** — 지금 `Effect`는 설치 시 1회 실행이 계약인데, 의존성이 + 여럿이면 "아직 안 채워진 Ref"가 있는 채로 도는 게 정상인가(=`nil`을 보고 + 각자 판단), 아니면 전부 채워질 때까지 미루나? 제안하신 Blocker 억제는 + 전자에 가깝게 들린다. +4. **leaf dedup/cascade와의 관계** — 의존성이 N개면 내부 Observer도 N개라, + `EffectHandle`의 bind/unbind cascade가 전부를 덮어야 한다(`EF-5`와 같은 함정). + +**방향에 동의하시면 `research/`에 문서를 하나 만들어 위 넷을 정리하겠다** — +지금은 이 항목만 남기고 아무것도 안 만들었다. + +### C-7. `B-5`의 캐비엇 — `:Single` 교체 시 Length가 잠깐 줄었다 느는 것 + +위 B-5 마지막 문단. 낭비만 있고 오작동은 아니라 **그대로 두는 쪽**을 권하는데, +확인 부탁드린다. + +--- + +## D. 새로 만든 research 문서 둘 + +| 문서 | 무엇 | 왜 base가 아니라 research인가 | +|---|---|---| +| `research/gate-primitive.md` | `Blocker`/`Debounce`/`Throttle` 아래의 공용 게이트 노드. 사용자 스케치(2단 `setup(emit) -> onUpstreamEmit`), `DT-4`의 "Blocker+Observer로는 순서를 못 지킨다" 논거, 열린 질문 6개 | **방향은 확정, 표면·이름이 미정** — M2 착수 전에 닫아야 함 | +| `research/state-epoch-validation.md` | 소스 에포크 비교로 재계산을 판정하는 안. 문제(glitch) 재현 시나리오, 제안 정리, 에이전트 분석(고쳐지는 것/안 고쳐지는 것/선례), 비용, 열린 질문 6개, 권고 | **아무것도 확정 안 함** — `base/source-state-plan.md`가 여전히 정본, M3 전에 결론 필요 | + +--- + +## E. 회신 방법 + +- **B절** — "이 이해가 맞나"만 봐주시면 된다. 어긋나는 지점만 알려주시면 + base까지 같이 고친다. +- **C절** — `C-2`(`rawAdd`의 `Length:Set` 제거), `C-3`(State 반환 래핑/`prev`), + `C-4`(`setLength` 앵커 되돌리기)가 **파급이 가장 크고 나머지를 막는다.** + `C-1`은 초안 승인, `C-5`/`C-6`은 방향 확인. +- 언급 안 하신 문항은 전부 "예"로 간주하고 넘어간다. + + +--- + +# F. 2차 회신 처리 (2026-08-21) — C절 전량 확정, `Gate`만 다음 세션으로 + +**입력**: `B-5`/`B-7`/`C-1`~`C-7`에 대한 사용자 회신(원문은 +`-response.md`에 이어붙이지 않고 이 절에 인용). **`C-5`(`Gate`)를 뺀 전부가 +확정됐고 `base/`에 반영 완료.** + +## F-1. 반영 완료 + +| 항목 | 회신 | 반영 | +|---|---|---| +| `B-5` | *"차라리, replace 를 제공하는게 나아보임. 해당 요소 자리에 교체분을 넣고, 이전건 파기해주는 것."* | **`Slot:Replace(index, newElement)` 공개 CRUD 신설**(O(1), `Extract`의 파괴 짝) + **`rawReplace` 의사코드 신설**. `:List`의 `settle` 교체 분기가 `releaseElement`+`rawAdd`(시프트 2회·`recompute` 2회)에서 **`rawReplace` 하나**(시프트 0)로 바뀜 — `C-7`의 "Length가 잠깐 줄었다 느는 것"도 이걸로 사라진다 | +| `B-7` (1) | *"중복 통지는 … 이미 모든 소스가 최신이면 무시하는게 맞아보인다 … emit 은 자신 소스를 주게 되므로 자신 소스 카운트만 빠르게 비교가 쉽다"* | `state-epoch-validation.md`에 **중복 통지도 접는다**로 반영. 판정이 O(1)이라는 것, **2026-08-14에 폐기된 옛 dedup과 다른 장치**라는 것(영구 침묵 모드가 없는 이유), 그리고 **노드가 `seen`/`computedAt` 두 카운트를 따로 들어야 한다**는 구현 요구까지 | +| `B-7` (2) | *"그건 아니다 … 상류의 상태를 물어보므로 … 이전과 다른게 없다"* | 에이전트가 붙였던 "선언 안 된 의존성을 UB로 명문화" 조건 **철회**, 같은 문서 §5·§7에 기각 사유와 함께 기록 | +| `C-1` | 확인 + *"element.Parent … 는 실제 엔진이 구현하게 되는 crud 셋을 사용하게 되어야 … slot 의 해당 동작은 base 이므로 parent 를 모른다"* | **`rawAdd` 의사코드 확정 반영** + **"물리 조작은 주입 op다" 절 신설** — 이 문서 의사코드 전체의 `element.Parent = ...`/`element:Destroy()`를 `mountInst`/`unmountInst`/`disposeInst` 호출로 정정(9곳) | +| `C-2` | *"확인. recompute 가 length 를 잘 처리해놓고 나서 빈 공간에 들어가므로 해당 동작은 완결하다"* | `dispatch-core-plan.md`의 **`rawAdd`가 `Length:Set(newCount)`를 부른다는 서술 2곳 전면 정정** — `Length`는 이제 `recompute`만 쓴다(개수 ≠ 기여도 합 / 이중 기록 방지 / 그 자리 가드는 무의미) | +| `C-3` | *"권고를 따르고자 한다 … 래핑이 하나의 함수로 나와야한다 … IndexOf 와 Get 등은 래핑 전 객체를 주어야"* | **"래핑/언래핑은 Slot 전체에 걸린 연산" 절 신설** — `wrapElement`/`unwrapElement` 비공개 헬퍼 한 쌍, 래퍼에 `_wrapped` 역참조(언래핑 O(1)), `_elements`는 물리 요소·사용자에게 나가는 값은 전부 언래핑, `IndexOf`도 언래핑 기준. **예상하신 대로 `wrappers[key]`/`mounted[key]` 분리가 불필요해졌다** | +| `C-4` | *"제안에 동의한다"* | **`Dispatch.setLength(ownerKey, i, len, anchor)`로 시그니처 변경** — 부기 키와 생명주기 앵커 분리. `bindLifetime`은 다시 물리 Instance 전용이 되고, `lifecycle-pattern.md`의 `(1-1)` 절은 **`archive/bindlifetime-slot-owner-reversed.md`로 이전**(포인터만 남김). **`isBoundAlive`의 세 번째 분기 항목도 같이 닫힘** | +| `C-6` | *"최소 1회 실행이 맞음 useEffect 와 동일. 인자 모양도 동의하고, 의존성 발화도 set 될때만임 … 전부 동의"* | `effect-plan.md`에 **`Effect(fn, ...deps)` 절 신설**(확정) — `Ref`도 의존성이 될 수 있음, trailing lazy 위치 인자, 최소 1회 실행, `Ref`는 `Set`될 때만 발화, cascade/dedup이 N개 Observer 전부를 덮어야 함. 최초 1회 억제 장치만 `Gate`에 딸려 있어 ⚠️로 표시 | +| `C-7` | *"위 응답에서 해소되었다 생각함"* | `B-5`의 `rawReplace`로 해소 — 별도 조치 없음 | + +## F-2. 다음 세션으로 넘긴 것 — `Gate` 하나 + +**회신**: *"고칠것이 많으므로 Gate 는 다음 세션에 다루겠음. 해당 부분은 정정이 +아니고 추가이고, 새 인터페이스를 고민해야하므로 해결해야할 일로 남겨두길 바람. +단지 지금 세션 상 지식만 이전될 수 있게 두세요."* + +→ `research/gate-primitive.md` 상단에 그 지시를 명시하고 **"다음 세션이 바로 +이어받을 수 있게 재료만 모아둔 상태"**로 표시했다. `question.md` 3번과 +`todos.md`에도 **M2 착수를 막는 유일한 항목**으로 남겼다. 이 세션에서 +`Gate`에 대해 새로 정한 건 없다. + +## F-3. 이번 처리로 파생된 것 (확인 부탁) + +1. **`mountInst`/`unmountInst`는 가칭이다** — `disposeInst` 선례를 따라 붙였고, + `base/dispatch-core-plan.md`의 주입 op 목록에 정식 등록할 때 이름을 확정해야 + 한다(`slot-plan.md`의 그 절에 ⚠️로 표시해둠). `mountInst`에 **index를 안 + 넘기는** 것도 같이 확정된 셈인데(형제 순서는 `Offset` 부기가 담당), 이건 + 맞다고 보고 그렇게 적었다. +2. **`rawReplace`가 `indexOfRaw(self, oldElement)`로 자리를 찾는다** — 지금 + `raw*`가 index 기준과 element 기준으로 섞여 있는 알려진 불일치 + (`slot-plan.md`의 "raw* 내부 호출 규약" 캐비엇)를 그대로 따랐다. M6 구현 때 + 둘 중 하나로 통일할 것. +3. **공개 `Replace`는 항상 파괴, `:List` 경로만 `_owned`를 본다** — + `rawReplace(self, old, new, destroyOld)`의 4번째 인자로 갈린다. `_listed` + Slot은 공개 CRUD가 막혀 있어 두 경로가 섞이지 않는다. + + +--- + +# G. 3차 — `mountInst`의 삽입 위치 (그리고 그 과정에 발견한 중첩 offset 결함) + +**입력**: *"mountInst/unmountInst 가 인덱스를 안 받는다는 문제가 있을수도. +웹에서는 어떻게 되냐가 모호함. 어디 둘지 어떻게 아느냐는것. 문제는 +`Dispatch.setOffsetSource(self, index, None)` 가 실제 오프셋을 안 주는데, 그냥 +None 이면 number 로 넘겨주는게 어떻겠냐는 생각."* + +**결론부터: 지적이 맞고, 제안 방향도 맞다. 그리고 그 자리를 파다 별개 결함을 +하나 더 찾았다.** 아직 `base/`를 고치지 않았고(⚠️ 마커만 달아둠), 아래 셋에 +대한 판단을 받고 싶다. + +## G-1. 지금 뭐가 문제인가 — `None`이 두 뜻을 겸하고 있다 + +`base/dispatch-core-plan.md`는 `setOffsetSource`의 `None`을 **"실제 마운트를 +하지 않는 위치"**로 정의하고, **"`setLength`도 같은 위치엔 짝을 맞춰 `0`으로 +등록해야 한다(둘 중 하나만 반영되면 어긋남)"**는 규칙까지 못 박아뒀다. + +그런데 **plain 요소는 `None` + `setLength(1)`로 등록된다**(`materializeSlotTree`의 +등록 루프, 그리고 이번에 쓴 `rawAdd`/`rawReplace`). 즉 **마운트는 하는데 +`None`을 쓰는** 자리라 위 규칙과 정면으로 어긋나 있었고, 아무도 못 보고 +있었다. 실제로 `None`이 뜻하는 건 두 가지가 섞여 있다: + +1. **아무것도 안 차지한다**(`Ref` 자리, `nil`/`None` 값) — 길이 `0`. +2. **차지는 하는데 아무도 그 offset을 안 읽는다**(plain 요소) — 길이 `1`. + +지적하신 문제는 정확히 2번에서 나온다 — **`None`이라 숫자가 계산조차 안 되니, +DOM 백엔드가 "이걸 어디에 넣어야 하나"를 물을 곳이 없다.** + +## G-2. ⭐⭐ 파다가 나온 별개 결함 — 중첩 Slot의 offset이 부모 베이스를 못 받는다 + +`recompute`는 `local sum = 0`으로 시작하고, **`ownerKey.Offset`을 읽는 자리가 +한 군데도 없다.** 그래서 각 offset은 **그 owner 안에서의 로컬 누적합**이다. +depth 1(물리 inst 바로 아래)에선 로컬 == 절대라 아무 문제가 없어서 지금까지 +안 드러났는데, **depth 2부터 어긋난다**: + +``` +Frame { + header, -- 정적 1개 + Slot { -- A + plainX, + Slot { :List 3개 }, -- B + }, +} +``` + +| 계산 | 값 | 맞는 값 | +|---|---|---| +| `recompute(Frame)`: `A.Offset` | `1`(header 기여) | 1 ✓ | +| `recompute(A)`: `B.Offset` | **`1`**(plainX 기여만) | **`2`**(= `A.Offset` 1 + plainX 1) | +| B의 `updateFn`이 쓰는 `index + offset` | 2,3,4 | 3,4,5 | + +즉 **`A.Offset`만큼 통째로 밀려 있다.** `updateFn`에 넘기는 `offset`을 +"형제로 섞인 다른 Slot/정적 자식이 기여한 개수의 누적합"이라고 서술해온 것과도 +안 맞는다(그 서술은 절대값을 뜻한다). + +이게 지금 질문과 한 덩어리인 이유: **DOM에 넘길 삽입 위치는 절대값이어야 +하므로, 숫자를 주기로 하면 이 결함부터 고쳐야 한다.** + +## G-3. 제안 (셋 다 같이 가야 함) + +### P1 — `sourceList`의 의미를 "참여 여부"에서 "발행 채널"로 좁힌다 + +`recompute`가 **모든 position에 대해 숫자를 계산해 `bk.offsetList[i]`에 +기록**하고, `sourceList[i]`에 Source가 있을 때만 거기에 `:Set`한다. + +- **참여 여부는 이미 `lengthList[i]`가 표현한다**(0이면 아무것도 안 차지). + 그래서 `None`은 이제 **"발행 채널 없음"** 하나만 뜻하게 되고, plain 요소가 + `None` + `setLength(1)`을 등록하던 모순이 그대로 해소된다. +- **`None` ↔ `setLength(0)` 페어링 규칙은 "값이 없는 자리"에만 남는다** — + 그건 여전히 유효하지만, 이유가 "짝을 맞춰야 어긋나지 않아서"가 아니라 + 그냥 그 자리가 정말 0개를 기여해서다. +- 비용은 배열 하나에 숫자 쓰기뿐이고, Source가 없는 자리엔 다운스트림 + 캐스케이드가 없으니 `Get()` 가드도 필요 없다. +- **⚠️ `bk.offsetList`는 네 번째 병렬 배열이 되므로 `spliceArraysUp`/ + `spliceArraysDown`이 같이 밀고 당겨야 한다**(그 목록에 빠진 게 있어서 + 3라운드에 이미 한 번 물린 적 있음 — `bk.observers`). + +### P2 — `mountInst(target, element, index)` + +`index`는 **그 자리의 절대 offset(0-based)**. Roblox 백엔드는 그냥 무시하고, +DOM류는 그 숫자로 삽입 위치를 정한다. `unmountInst(element)`는 그대로 인자 +없음(뺄 때는 자기 자신만 알면 된다). + +- 부수 효과로 `slot-plan.md`의 **"위치 이전 기억은 base 책임 아님 — backend가 + 필요하면 `Relate`로"** 절이 완화된다: 백엔드가 "직전 형제가 누구였는지"를 + 따로 기억할 필요 없이 **base가 숫자를 준다.** (그 절의 결론 자체는 유지 — + base는 여전히 백엔드 종속 위치 정보를 안 들고 있다.) + +### P3 — `recompute`가 절대값을 계산하도록 베이스를 넣는다 (G-2 수정) + +```lua +local function recompute(ownerKey, bk) + local base = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0 + local sum = 0 + for i = 1, bk.N do + bk.offsetList[i] = base + sum -- P1: 항상 숫자로 기록 + local src = bk.sourceList[i] + if src ~= None and src:Get() ~= base + sum then + src:Set(base + sum) -- 발행 채널이 있을 때만 + end + sum += contribution(i) + end + if isSlot(ownerKey) then ownerKey.Length:Set(sum) end -- Length는 base를 안 더한 로컬 합 +end +``` + +- **`Length`에는 `base`를 안 더한다** — 길이는 "내가 몇 개를 차지하나"라 + 위치와 무관하다. 이 둘이 갈리는 게 `base`를 별도 변수로 두는 이유. +- **추가로 구독 하나가 필요하다** — 중첩 Slot은 **자기 `Offset`이 바뀌면 자기 + `recompute`를 다시 돌려야** 한다(앞 형제의 길이가 변해 베이스가 밀리는 경우). + `materializeSlotTree`에서 `slot.Offset`에 Observer 하나를 걸고 + `gatedRecompute(slot)`을 연결, 앵커는 `physicalTarget`(`C-4` 규칙 그대로). +- **순환은 안 생긴다** — offset은 top-down, length는 bottom-up으로 방향이 + 갈린다. offset 변경이 자식 offset을 밀 뿐 길이를 바꾸지 않고, 길이 변경이 + 부모 recompute를 태울 뿐 자기 offset을 안 바꾼다. + +## G-4. 판단이 필요한 것 + +1. **P1~P3을 이대로 반영할까?**(셋이 한 덩어리다 — P2만 하면 숫자가 틀리고, + P3만 하면 plain 요소 숫자가 여전히 없다.) +2. **`mountInst`의 `index`를 0-based 절대 offset으로 두는 게 맞나** — + `offset`/`sum`이 이미 "0-based 개수"라 그쪽과는 일관된다. 1-based로 주면 + `updateFn`의 `index`(1-based 지역 위치)와 헷갈릴 수 있어 0-based를 권한다. +3. **`bk.offsetList`를 새로 두는 대신 `sourceList`가 `Source | number`를 + 담게 하는 안**도 가능하다(사용자 표현에 더 가까움). 다만 그러면 "이 자리에 + 발행 채널이 있나"와 "지금 값이 얼마인가"가 한 슬롯에 섞이고, 등록 시점에 + `None`이던 자리가 계산 후 숫자로 덮이면 **등록 정보가 사라진다**(해제 시 + `None` 재등록으로 판정하는 지금 계약과 충돌). 그래서 **배열을 하나 더 두는 + 쪽을 권한다.** + + +--- + +# H. 4차 — `getOffsetAt`으로 확정 (G절 결론, `base/` 반영 완료) + +**입력**: *"isSlot 인지 아닌지와 상관 없이 부모에서의 Offset 을 알아야하는거 +아님? … 아. 구독 이유는 알겠음, 자식 slot 의 offset 을 다시 설정해주기 +위함이구나. offset의 깊은 전파를 위한거군. 단순 그러고선 setOffsetSource(None) +은 그냥 리턴해버리고, getOffsetAt 같은걸 넣어서 length 추적을 그냥 뽑아놓자. +setOffsetSource 에선 source 를 받으면 그건 set 해주지만, 아니면 그냥 얼리리턴에 +None 으로만 둬주고, getOffsetAt 은 직접 호출하는걸로?"* + +**에이전트 제안(G-3의 P1)보다 이쪽이 낫다** — 채택하고 반영했다. + +## H-1. 무엇이 바뀌었나 + +| | 에이전트 제안(P1) | **채택된 것(사용자안)** | +|---|---|---| +| 숫자를 어떻게 얻나 | `recompute`가 **모든 자리에 push**해 `bk.offsetList[i]`에 기록 | **`Dispatch.getOffsetAt(ownerKey, i)`로 pull** | +| 병렬 배열 | 네 번째(`offsetList`) 신설 → `spliceArraysUp/Down`이 같이 밀어야 함 | **안 늘어남** | +| `setOffsetSource(None)` | 등록만 하고 계산은 recompute가 | **얼리 리턴**(계산할 이유 자체가 없음) | + +**pull이 나은 이유 세 가지**(반영하며 확인): + +1. **부기 배열이 안 는다** — 3라운드에 `spliceArraysDown`이 밀어야 할 배열 + 목록에서 `bk.observers`가 빠져 있던 게 실제 결함이었는데, 배열을 하나 더 + 만들면 그 실수 표면이 또 넓어진다. +2. **"관측해야 실체화된다"와 같은 결** — 아무도 안 물어보는 자리의 숫자를 + 미리 계산해 들고 있을 이유가 없다. +3. **중복이 사라진다** — `setOffsetSource`의 즉시 계산이 하던 합산 루프가 + 그대로 `getOffsetAt`이 되어, 두 곳에 있던 같은 로직이 한 곳으로 합쳐졌다. + +## H-2. 베이스는 어디서 오나 — `slot.Offset` 그대로 (`bk.base`는 폐기) + +**1차 반영 때 `bk.base` 필드에 복사해뒀다가, 같은 세션에 사용자 지적으로 +걷어냈다**: *"bk.base 가 왜 필요한거임? … 이건 slot 안의 slot.offset 이랑 기능이 +겹칠텐데, 부모 slot 의 offset 읽는게 이미 정확해. 미리 offset을 받아 설정해두니까. +최상위에선 애초에 base자체가 없지 않아? 항상 0 일텐데."* 맞다 — + +- **Slot의 베이스 = 자기 `.Offset`**이고, 그 값은 **부모가 먼저 설정해준다** + (`materializeSlotTree`의 첫 줄이 `setOffsetSource(ownerKey, position, ...)`). + 즉 이미 정확한 값이 있는데 `bk.base`는 그걸 한 번 더 들고 있는 **중복 + 상태**였다 — 같은 값을 두 곳에 두면 갈라진다는, 이 코퍼스가 반복해서 물린 + 패턴 그대로다. +- **최상위(물리 inst)는 베이스라는 개념 자체가 없어 항상 0.** + +```lua +local base = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0 +``` + +- **`isSlot` 분기가 남는 건 타입 분기여서가 아니라 다른 방법이 없어서다** — + `ownerKey.Offset`을 그냥 인덱싱해 있나 보는 duck-typing은 **Roblox userdata에서 + 정의 안 된 키 인덱싱이 에러를 던질 수 있어** 코퍼스가 이미 금지한 방식이다 + (`base/brand-plan.md`의 duck-typing 기각 근거 (b)). +- `Length`에는 베이스를 안 더한다(길이는 위치와 무관) — 그래서 `base`와 `sum`이 + 별도 변수로 남는다. + +## H-3. 깊은 전파 — 자기 `Offset` 관측 (짚으신 그대로) + +앞 형제의 길이가 변해 내 베이스가 밀리면 **내 자식들의 offset도 다시 계산돼야 +한다.** 그래서 중첩 Slot은 자기 `Offset`에 Observer 하나를 걸고 +`recompute(self)`를 태운다. `_listObserver`/`_detachCleanup`과 **완전히 같은 +취급**으로 뒀다 — 생성 1회, 언마운트 시 앵커만 해제, 재마운트 시 새 +`physicalTarget`에 재앵커, 파괴 시 핸들까지 `nil`. + +## H-4. ⭐ 반영하다 발견한 결함 하나 더 — 재마운트가 `Offset` Source를 새로 만들고 있었다 + +`materializeSlotTree`가 `local offsetSource = Source(0)`으로 **매 마운트마다 새 +Source를 만들어** `slot.Offset`에 넣고 있었다. 그런데 언마운트 쪽은 +`slot.Offset`을 **일부러 보존**한다(`SL-75`) — 그 이유가 *"이미 렌더된 요소들이 +그 Source를 구독한 채 함께 딸려 나가기 때문"*(`DC-6`에서 사용자가 정밀화)인데, +재마운트가 객체를 갈아치우면 **그 구독자들이 옛 객체에 남아 새 위치를 영원히 +못 받는다.** 포탈이 정확히 그 지점에서 깨진다. + +→ `local offsetSource = slot.Offset or Source(0)`으로 **identity 재사용**하도록 +정정했다. `SL-75`/`DC-6`이 세운 불변식("`Offset`은 객체를 유지하고 값만 +갱신한다")이 이제 마운트/언마운트 양쪽에서 일관된다. + +## H-5. 반영 목록 + +| 대상 | 내용 | +|---|---| +| `dispatch-core-plan.md` | `Dispatch.getOffsetAt(ownerKey, i)` 신설, `recompute`에 `bk.base` 시드(+`Length`는 base 제외), `setOffsetSource`의 `None` 얼리 리턴, `None` 의미를 "발행 채널 없음"으로 정정(plain 요소가 `None`+`setLength(1)`인 게 정상임을 명시) | +| `slot-plan.md` | `materializeSlotTree`의 `Offset` identity 재사용 + `_baseObserver`(생성 1회/재앵커/파괴 시 정리), `mountSlotTree`가 러닝 누적으로 `mountInst(target, element, acc)` 호출(O(n)), `rawAdd`/`rawReplace`는 `getOffsetAt`으로 위치 전달, `mountInst` 절을 확정으로 갱신(0-based 절대 offset, `unmountInst`는 인자 없음) | +| `question.md` | G절 항목을 **해소**로 갱신 | + +## H-6. 남은 확인 (작음) + +- **`getOffsetAt`은 O(i)**라 배치에서 자리마다 부르면 O(N²)다. 그래서 + `mountSlotTree`는 러닝 누적으로 O(n)을 유지하고, 단건 경로(`rawAdd`/ + `rawReplace`)만 직접 부른다. 이 구분이 맞다고 보고 그렇게 적었다. +- **`getOffsetAt`이 공개 표면인지** — 지금은 `Dispatch.*`로 뒀다(백엔드가 + 자기 핸들러에서 부를 수 있어야 하므로). 비공개로 두고 싶으면 알려주시라. + + +--- + +# I. 반영 후 자체 트레이싱 (2026-08-21, 커밋 전) + +H절 반영을 커밋하기 전에 새 의사코드를 손으로 실행해보다 **실제로 터지는 경로 +둘**을 찾아 같이 고쳤다. + +## I-1. ⭐ 빈 Slot이 `recompute`에서 크래시 (`bk.N`이 `nil`) + +`bk.N`은 `setLength`가 처음 불릴 때 생긴다(`bk.N = math.max(bk.N or 0, i)`). +그래서 **요소가 하나도 없는 Slot**(`Slot()` 직후, 데이터가 빈 `:List`, +전부 필터로 걸러진 리스트)은 `N`이 `nil`인 채로 `materializeSlotTree` 끝의 +`recompute(slot, bk)`에 도달하고, `for i = 1, nil`이 그 자리에서 터진다. + +**이건 이번 변경이 만든 게 아니라 원래 있던 갭**이다(`recompute`의 루프 상한이 +처음부터 `bk.N`이었음). 빈 Slot은 완전히 정상적인 상태라 방어가 아니라 계약으로 +보고 `for i = 1, bk.N or 0`으로 고쳤다. + +## I-2. ⭐ `_baseObserver`의 "등록 즉시 1회 실행"이 미완성 부기를 훑음 + +G절에서 신설한 `_baseObserver`(자기 `Offset`을 관측해 자식 offset을 다시 미는 +구독)를 처음엔 `blocker:On()` **앞**에 만들었는데, Observer는 **등록 즉시 1회 +실행**되므로 그 자리에서 곧바로 `recompute(slot, ...)`이 돈다 — **자식 등록이 +하나도 안 된 상태**를 훑는다(위 `I-1`과 겹치면 그대로 크래시). + +→ **생성을 `blocker:On()` 뒤로 옮겼다.** 게이트 안에서 만들면 그 1회가 그냥 +삼켜지고, 배치 끝의 명시적 `recompute`가 어차피 정확한 값을 계산한다. 재마운트 +분기(앵커만 다시 거는 쪽)는 Observer를 새로 안 만드니 이 문제가 없다. + +**패턴 메모**: "등록 즉시 1회 실행"이 배치 게이팅과 부딪히는 건 +`setLength`의 Observer에서 이미 한 번 정리된 적이 있다(`gatedRecompute`가 그 +1회도 게이트에 통과시킨다는 규칙) — **새로 만드는 Observer는 그 규칙을 자동으로 +물려받지 않으므로, 배치 구간에서 Observer를 만들 때마다 "이 1회가 게이트를 +지나는가"를 확인해야 한다.** + + +## I-3. `setLength`의 `anchor`를 선택 인자로 (핸드오버 정리 중 보강) + +`C-4`로 신설한 4번째 인자를 "항상 넘긴다"로 적어뒀는데, 그러면 `inst`를 owner로 +쓰는 **기존 3-인자 호출부가 전부 stale**해진다(`ref-plan.md`의 +`ProcessedPreRefHandler`/`ProcessedPostRefHandler`, `modifier-plan.md`의 +`ProcessedModifierHandler`, `lifecycle-hooks-plan.md`, `component-composition-plan.md` +등). 그런데 그 자리들은 **`ownerKey`가 곧 물리 target**이라 앵커를 따로 줄 이유가 +없다. + +→ **`anchor`를 생략하면 `ownerKey`로 폴백**하도록 정리했다. 실제로 넘겨야 하는 +건 **`ownerKey`가 Slot인 자리**(`materializeSlotTree`의 등록 루프, 런타임 +`rawAdd`/`rawReplace`)뿐이고, 나머지 문서의 호출 예시는 손댈 필요가 없다. + + +--- + +# J. 커밋 전 감사 (2026-08-21) — `quad-doc-auditor` 1라운드 + +핸드오버 준비로 감사를 돌렸다(각도: **이번 diff와 그걸 인용하는 자리의 base +정합성**). **실제 결함 6건 + 판단 필요 2건**이 나와 전부 처리했다. + +## J-1. 실제 결함 (전부 반영 완료) + +| # | 발견 | 왜 문제였나 | 반영 | +|---|---|---|---| +| 1 | **`Owned`가 코드에 도달하지 못하고 있었다** | `settle`/`destroySlotTree`/`releaseElement`가 `self._owned`를 9곳 넘게 읽는데, 정작 `Slot:List`/`Slot:Single` 의사코드가 **`opts` 인자를 안 받고** `_owned`를 세우는 줄이 없었다. `wrapElement`도 산문은 "`Owned = false`로 설치된다"면서 코드는 `sub:Single(v)`뿐 | `Slot:List(data, updateFn, keyFn, opts)` / `Slot:Single(state, updateFn, opts)`로 시그니처 보강 + `opts.Owned == false`일 때만 필드 세팅, `wrapElement`는 `sub:Single(v, nil, { Owned = false })` | +| 2 | **`effect-plan.md`가 자기 자신과 모순** | 문서 최상단 시그니처가 `Effect(fn, state?)`이고 "**`Effect(fn, a, b, c)`처럼 trailing args로 받는 sugar는 의도적으로 안 만듦**"이라는 확정 문단이 그대로 남은 채, 같은 파일 아래에 `C-6`의 `Effect(fn, ...deps)` 절이 추가돼 있었다 — **역전 배너 없이 정반대 두 서술이 공존** | 시그니처를 `Effect(fn, ...deps)`로 갱신, 옛 문단에 🔄 역전 배너 + **옛 근거가 무너진 이유 둘**(`Ref`는 `:With`로 합칠 수 없어 애초에 의존성이 될 방법이 없었다 / 구독을 따로 걸면 합치는 노드 자체가 안 생겨 "감출 비용"이 없다) | +| 3 | **`indexOfRaw`가 어디에도 정의돼 있지 않음** | 신설 `rawReplace`가 쓰는데 문서에 없었다. 구현자가 공개 `IndexOf`를 그대로 쓰면 **래핑된 자리에서 어긋난다**(그건 언래핑 기준 비교) | `indexOfRaw` 한 줄 정의 + `IndexOf`와의 차이 명시 | +| 4 | **index/element 혼용 캐비엇 목록이 안 늘어남** | 기존 캐비엇이 `rawRemove`/`rawUnmount`만 나열하는데, 이번에 같은 불일치를 물려받은 `rawDetach`/`releaseElement`/`rawReplace`가 빠져 있었다 | 캐비엇에 셋 추가 | +| 5 | **`mountSlotTree`의 전제가 코드에 없었다** | `acc = slot.Offset:Get()`이 정확하려면 **`materializeSlotTree`가 먼저 돌아야** 하는데, 분해된 함수의 계약이 주석에 없었다(`research/slot-attach-decomposition.md`에 "중간 상태 처리"가 열린 항목이라 더 위험) | ⚠️ 전제 주석 추가 | +| 6 | **`Replace`의 `destroyOld`가 base 본문에 없었다** | `rawReplace`에 인자가 있는데 CRUD 표/산문이 그 존재를 설명 안 함. followup은 처리 기록이지 소스가 아니다(이번 라운드 `CR-1`의 교훈 그대로) | 공개 `Replace`는 항상 파괴 / `:List`만 `_owned`를 넘긴다를 산문에 명시 | + +부수로 인덱스도 같이 정리했다 — **`Gate` 이름이 `question.md` 1번(용어 +대기열)에 없던 것**(5라운드 `CR-2`가 지적한 패턴이 같은 라운드에서 재발), +`todos.md` 00번 헤더의 "열린 항목 없음"(5라운드/`Gate`와 모순), +`architecture.md`/`dispatch-core-plan.md`의 주입 op 목록에 `mountInst`/`unmountInst` +가칭 표시, `setLength` narrative 스텁의 `anchor?`/`getOffsetAt` 반영, +`research/gate-primitive.md`에 **소비자 `Effect(fn, ...deps)`** 추가. + +## J-2. 판단 필요 — `question.md` 3번에 올림 + +1. **offset이 바뀌면 이미 배치된 물리 노드를 옮겨야 하는가**(웹 백엔드 계약). + `mountInst`가 삽입 시점 위치만 받는 일회성이라, 앞 형제 길이가 변해 뒤 + 형제 offset이 밀리는 흔한 경우 DOM에선 순서가 실제로 어긋난다. quad-web이 + 생길 때까지 미룰 수는 있지만 **지금 안 정하면 M6가 "안 옮긴다"를 전제로 + 굳는다.** +2. **`:List` 재조정의 `getOffsetAt` 비용** — `settle`이 키마다 부르므로 이미 + 마운트된 리스트가 통째로 바뀌면 O(N²)(최초 마운트는 `_mounted == false` + 얼리 리턴이 막아준다). `DC-9`의 "배치 1회니 감수"와 달리 **매 reconcile**이라 + 빈도가 다르다. reconcile이 `pos`처럼 절대 offset도 러닝 누적으로 들면 O(n) + — `mountSlotTree`가 이미 그 방식이다. + +## J-3. 감사가 확인만 하고 넘어간 것 (회귀 없음) + +`Tween:Mapped` 표기 통일 / `bk.base` 제거(남은 건 "왜 안 두는지" 서술뿐) / +`D-56` 역전 4곳 정합(포인터·archive 원문·README 색인·question 해소) / +`KeyGone` 의사코드↔산문 / `Owned=false`+`Detach` 3곳 / 물리 op 치환 9곳 +(`element.Parent`/`:Destroy()` 잔존 0) / 5라운드 파일 3개 색인. + + +--- + +# K. 감사 2라운드 + 그 자리에서 받은 회신 (2026-08-21) + +## K-1. 감사 2라운드(각도: 인덱스 레이어·히스토리 문서) — 전부 반영 + +| 발견 | 반영 | +|---|---| +| **`ROADMAP.md` 백로그 문단과 `debounce-throttle-plan.md`가 아직 "Gate 추출은 M3에서"라고 서술** — 이번 세션에 M2로 앞당겨졌는데, **손대지 않은 문서라 diff에 안 잡히는 사각지대** | 두 곳 다 "M2로 앞당겨졌다"로 정정 | +| `question.md`/`README.md`의 State-에포크 서술이 **옛 결론**(중복 통지 안 고쳐짐 / UB 명문화 필요)을 담고 있었음 — 같은 날 후속으로 정반대로 확정됐는데 인덱스만 안 따라옴 | 둘 다 갱신(중복 통지도 접음 / UB 조항 기각 / `seen`·`computedAt` 두 카운트) | +| `README.md`가 5라운드 문항지를 "회신 대기"로 태그한 채 바로 옆에 회신·처리 파일을 등재 | "신설·처리 완료"로 | +| 5라운드 문항지 자신의 상태 줄이 "회신 대기"에 멈춰 있음(4라운드는 종결로 갱신한 선례 있음) | "완료·종결" + followup 포인터 | +| `ROADMAP.md` M2 체크박스의 문장이 접합부에서 깨져 있었음 | 문장 정리 | +| M3의 `Blocker.luau` 체크박스에 "게이트 노드는 M2에 이미 있다"는 표시가 없음 | M3에 순서 주의 항목 + **State 에포크 미결이 `Source.luau`/`State.luau`보다 먼저 닫혀야 한다**는 착수 전 확인 항목 신설 | +| `slot-plan.md`의 주입 op 표가 `mountInst(target, element)` 2-인자로 남음(바로 아래 문단은 3-인자로 확정) | 표를 3-인자로 | +| `todos.md`가 처리 상태를 `1차 처리 완료`로 적어 실제(4차까지 진행)와 갈림 | 라운드 수를 빼고 `처리 완료`로 | + +## K-2. 같은 자리에서 받은 회신 — 셋 다 확정 + +### (1) `raw*`는 **index로 전부 통일** (오래 열려 있던 캐비엇 종결) + +**사용자 확정**: *"index 로 전부 처리되면 될듯. 애초에 안에서 다시 element -> +index 를 찾아야하던걸로 앎."* + +- `rawRemove`/`rawUnmount`/`rawDetach`/`rawMove`/`rawSwap`/`rawExtract`/ + `rawSplice`/**`rawReplace`** 전부 index를 받는다. 예외는 `rawAdd` 하나 + (새로 넣는 대상이라 element가 인자인 게 당연). +- `settle`이 element만 쥐고 있어 index를 구해야 하는 문제는 — **`keyIndex`가 + 이미 그 값을 들고 있다**(그 키가 지금 차지한 압축 위치 = `_elements` 인덱스). + 그래서 `indexOfRaw`(O(n))는 **폴백이지 기본 경로가 아니다.** +- `releaseElement(self, index, element, wasDetached)`로 시그니처 조정 + (detach 중이던 요소만 자리가 없어 `index = nil`). +- **M6 구현 세부로 열어뒀던 항목이 이걸로 닫혔다.** + +### (2) 래핑은 `raw*` **바깥**에서 (`raw*`는 물리 요소만) + +**사용자 확인**: *"raw들은 모두 래핑된거 그대로 넣고 빼도록 할까?"* — 그렇다. +래핑 지점은 **공개 표면 + `settle`** 둘뿐이고, `raw*`의 세계는 물리 요소 하나로 +균일하다. 그래야 방금 통일한 index 규약이 다시 흐려지지 않는다. + +### (3) `B-1` 해소 / `B-2` 해소 + +- **`B-1`(offset이 바뀌면 이미 놓인 노드를 옮겨야 하나) → 아니오.** + *"위에서 넣고 빼면 insert 같은거로 일어나서 뒤로 밀린다"* — DOM은 삽입/삭제 + 자체가 뒤를 밀고 당긴다. 단 **백엔드 op이 아토믹한 최소 단위**여야 한다는 게 + 같이 확인된 요구사항. +- **`B-2`(`getOffsetAt` O(N²)) → 접두합 캐시로.** `bk.offsetCache` + + `bk.offsetDirtyFrom`("이 인덱스 초과부터 무효"), 무효화 트리거 셋을 명시 + (`setLength`는 `i+1`부터 / splice는 그 자리부터 / 베이스 변경은 전체). + **남은 작은 확인**: `recompute`의 전체 순회가 그 캐시를 같이 채우게 할지. diff --git a/.claude/qa-request/pre-implementation-qa-round5-response.md b/.claude/qa-request/pre-implementation-qa-round5-response.md new file mode 100644 index 0000000..ddd6c2c --- /dev/null +++ b/.claude/qa-request/pre-implementation-qa-round5-response.md @@ -0,0 +1,124 @@ +# 구현 전 QA 5라운드 — 사용자 회신 원문 (2026-08-21) + +**이 파일은 회신 원문 그대로의 기록이다** — 처리 결과의 소스는 +`pre-implementation-qa-round5-followup.md`. 4라운드와 같은 구성 +(문항지 / 회신 원문 / 처리 결과 3파일). + +--- + +### DE-7 +_detached 를 모든 slot 이 가져야하나는 의문이 듦. 내부적으로 getOrSet 해서 detached 를 리턴해주는 유틸을 만들고, nilable 해도 되지 않나라는 생각. destroySlotTree 가 _detached 를 인덱스 해보고 nil 아닌지 보고 돌리는게 더 나아보이는데, 테이블 생성 비용을 모든 slot 이 가져야하나는 의문. List 슬롯에서만 작동하는것인데, 너무 광범위하지 않은가? if 확인으로 nil 이면 스킵이 훨씬 싸게 먹히지 않는가? +순수 구현 상 아무 문제 없겠지만, 단순 최적화 문제. 최적화에 드는 비용이 거의 없는데, 안 할 이유가 보이진 않음. + ++ +아무것도 없는데 Detach 를 보내면 어떻게 되느냐. prev 없음을 유저가 추적해야하는가, 아니면 detach 를 그냥 prev == nil 일 때 던지면 무시해주느냐 +prev 가 마운트 상태에서(Detach 안 함) 다시 prev 를 던지는것은 이번 변경으로 인해서 여전히 문제 없이 잘 작동하는가 + +### DE-9 +error 하면 된다. KeyGone 을 받은 요소는 오직 데이터의 파괴 또는 detach 를 통해 다시 나오는 경우를 위한 캐싱 이외의 새로운 마운트나 생성을 거부한다. + +### DE-11 +맞다. 문서화에서만 유의하면 되는 부분. 단순 삽입/삭제가 빈번한 경우를 위한 최적화 일 뿐. 그 이상의 동작을 돕지 않는다. + +### DE-13 +_detachCleanup 이 정확히 어떤건지 모르겠음. _detached 에 들어가게 될 대상을 말하는것인지? 아니면 detach 된 요소들이 나중에 정리되어야할 때인지? +나중에 detach 했던 요소들을 청소하는것이라면, 소유한것이면 죽이는게 맞긴 하다. +다만 주의해야할 부분이 보인다. 애초에 unowned 의 state 로 받은것은 더이상 가지고 있지 않는다. detach 에 들어가있지도 않는다. 외부로 반출된 것이라 다시 들고와서 자기 자신에 붙이지 않음. 즉 detach 가 unowned 에 대해서 수행되면 prev 가 나중에 nil 이 되는것이다. 그래야 state 에서 내부를 교체했을 때, 이전 요소를 안 건들이게 되는것이라 생각하는데, 내 생각이 잘못 흐른건지 검토해달라. + +### DE-17 +주의 할 점은, state 가 slot 에 바로 안 오기 때문에, updateFn 이 state 를 던져도 싱글 slot화 된다. 그리고 owned = false 가 되는건 이 싱글 슬롯 안 1번째 객체에 대해서 적용이다. +나중에 새로운 state 같은게 나온다면, 이전거는 prev 로 updateFn 이 받으니 그걸 어떻게 처리할지는 updateFn 의 몫. 특히 slot 래핑 안 된 상태로 받아야하는가도 생각해보아야한다. prev 는 이전에 던진 그대로 주는것이므로. 그리고 이전과 같은 state 를 던지면 멱등으로써 새로운 slot 을 만들지 않고 그대로 두는것도 여전해야한다. + +### DE-22 +의도가 맞다. + +### AS-5 +activateList 도 결국 상위에 setlength 를 하는것 아닌가? 게이트가 blocker 없이는 호출되지 않는지 확인 필요 + +### DC-6 +더 정확히는, 슬롯을 뽑아냈을 때, 이전에 렌더된 요소들은 여전히 layout order 등을 위해 offset을 연결해두고 있다. 이 상황에서 아에 다른 값을 slot.Offset 으로 쓴다는것 자체가 나중에 포탈에서 깨지는 부분을 생성한다 + +### DC-11 +필요 이유를 모르겠음. length 업데이트를 위한것임? + +### DC-14 +사실, 외부 입장에서는 그럴 방법이 없어보인다. crud 가 list 시에는 더이상 불가능해지기 때문. + +### DC-19 +rawAdd 에서도 필요한가는 모르겠음. 목적이 다르지 않나? + +### SS-2, SS-3 +단순히 각 state 에, 이전 emit 을 발생시킨 발행 unique table 를 넣는건 어떤지 고민중. source 에서만 발행되고 emit 상 전파된다. +지금 보이는 문제로는 +A -> B --> D + -> C -| +상황에서 결국 dfs 로 순회되어서 D 에 먼저 A 소스의 emit 신호가 온다. C는 아직 받기 전. invalid 하지 않아서 바로 캐시가 읽히고, 그대로 D에 캐시에 쓰인다. C의 변경으로 다시 emit 되긴 하지만, 이걸 잡을만한 방법이 없어보이진 않는다. +이런 문제를 전부 쉽게 푸는 방법이 존재하는데, emit 시 unique 한 테이블 하나를 전파하고 그 테이블 안에 count 값과 invalid 플래그가 들어간다.. 각 source 는 해당 테이블을 가지고 있다가 emit 된다면 invalid=false count=최신으로 값을 로 바꾼다. +with 에서는 여럿 받은 곳에 대한걸 관리하도록 둔다. +-> 생각하다 위쪽껀 이상했음. 이러면 모든 발행된 unique 를 들고 count 를 관리해야한다 +아니면 반대로 count 부분만 담는 테이블을 만들고 연결하는건 어떤가? +unique 자신의 실제 인덱스와, source 의 count 와 비교해서 일치해지는가를 본다. +만일 파이프 뒷 요소로 get 되어진 경우 최상위 소스에서 해당 유니크를 가져와 덮어도 된다. +-> bfs 도 생각했는데, 순서를 꼬아두면 문제가 생긴다. +재정리: 차라리 이렇게? +State() + sourceList <- { + [source:weak] -> count + } weak 로 영향받는 source 들을 담는다. 앞단 요소에서 복사, with 시 합친다 + rawInvalid <- 단순 emit 시에 바로 false 된다. 캐싱 + invalid <- rawInvalid 캐시를 보고 true 라면 sourceList 를 보고 계산 필요 상태인지 확인한다 +더 생각해볼 이야기라 백로깅이나 리서치에 들어가야할듯. +Get 이 항상 최신 상태를 가져온다라는 말이 여기서 무력화되는 부분이라 생각이 필요해보인다. +어차피 emit 단순히 전파는 false 이라 연산 비용이 없어 잘 전파되고. 복합 state 상태를 잘 관리해주는게 나아보인다. +대부분 옵저빙을 하여 state 를 읽지, 폴링해서 get 하는 경우도 잘 없으므로 문제 있는 구현으로 안 보인다. +폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게 Ref 로 변환해주는 등의 작업을 하는 슈거를 줘도 된다. +비용은 다소 한정적으로 보인다. 해시 for은 이미 빠르고, Source가 중간중간 느는게 아니라 처음 시작점이라 수 자체가 적다. 2~4개에 대해 인덱싱 하는 정도라 충분히 가벼워보이는 단일 for 로 해결이 되는것으로 보임. +emit 은 이제, 발행시킨 source 와 count 를 전달하기만 하면 된다. count 를 쓰기 싫다면 단일 테이블을 써도 되는 부분. [source:weak] -> {} +선제 최적화라기 보단 확실히 정해진 동작으로 승격하는 일로 보이는데 어떻게 생각하는가? + +### LC-3, LC-4 +무슨말인지 모르겠다. 애초에 Slot effect 나 다른 요소들을 소유할 수가 없다. +심지어 내 생각으로는 slot owned 가 canBound 에 들어갈 이유가 있나? 모르겠다 +왜냐면 실제 observer/effect 는 실제 inst 에 불림. slot in slot 에서 slot 을 유지하는건 이미 slot 의 강참조 배열이 해결해주는데, 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할 부분인듯? + +### EF-3 +예 + +### AT-1 +(inst, groupValue) → k 이면 충분하다. group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 필요가 없고, 그건 key->name 이 유일성을 검증해준다. {a,a} 는 단순 그룹이 이미 할당된 키가 있나를 보기만 위함임. AT-2 도 같이 닫는다 + +### TW-2 +Mapped 로 확정 CR-2 도 해결됨 + +### DT-4 +그렇다. 이러면 스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer 가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가 되면 이를 전혀 못 지키기 때문. +Gate 가 emit 에 중간에 가로채서 넘길지 말지 처리를 해줄 수 있게하는 방법을 제공하는게 맞다. 그리고 이 API가 비공개일 이유는 없어보인다. + +### CR-3 +게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가 필요함 +생각 상 새로운 state 가 나오지는 않고, Blocker 와 유사히 동작한다. 내부 배선 상 이렇게 되면 되는거 아닌가 생각중. +Gate(function(emit) + ^-- 위 emit 은 언제든 사용 가능 + return function () <- 상위 emit 시 발생. 위 emit 을 쓸지 말지는 자유 + end +end) +다만 프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의 문제. + +### CR-4 +부모가 죽기 전까진 detached 정리가 안되니 그건 맞다. + ++ +Effect 가 지금은 Ref에 대해서 수행될 수가 없다. 단순히 Effect(, ...) 를 만들고 ... 요소를 With 으로 합치는게 아니라 여러 요소에 대해서 Observe/Callback 하는게 어떻겠냐는 생각이 드는 지점. 처음 분기는 Blocker() 의 다른 사용법을 통해 막는다. 그게 더 나은 구현으로 보이는중. + ++ +확인할 부분이 있다. state -> Slot single { Slot } 모양이 될 때 부모 Slot 이 length 를 잘 따라가는가? 아마 그렇다고 보는데, 정확한지 봐야한다. + ++ +뽑는것은 자유롭지만, Slot 이 들고있다 죽는건 죽어야하는데. 그 처리가 영향을 받았는지 궁금함. 즉 +Frame { + Slot { + State(Frame) <- 여기 바인딩은 상위 Frame 이 죽으면 같이 죽음. + } +} +이건 Effect 쪽에서 뭔가 처리해줄 수 있는게 아닌게, 엔진 자체가 recursive 호출로 전부 죽이는게 일반적이기에 우리가 빼줄 수 있는 요소도 아니고, 같이 죽는게 의도 동작이기 때문. +따라서 State 에서 무언가 마운트 된 요소를 뽑아낼 때, 부모가 죽었다면 이미 죽은 요소가 된다. 이건 의도 동작인데, 언급이 되어있나 모르겠음. diff --git a/.claude/qa-request/pre-implementation-qa-round5.md b/.claude/qa-request/pre-implementation-qa-round5.md new file mode 100644 index 0000000..d7029d4 --- /dev/null +++ b/.claude/qa-request/pre-implementation-qa-round5.md @@ -0,0 +1,1302 @@ +# 구현 전 QA **5라운드** — 새 영역 + 심화 문항지 + +**상태**: **[2026-08-21] 완료·종결.** 작성 → 사용자 회신(`-response.md`) → +4차에 걸친 처리로 전량 반영 완료. **처리 결과의 소스는 +`pre-implementation-qa-round5-followup.md`**(마지막 절이 최신) — 이 파일은 +4라운드와 같이 **문항지 원본 그대로** 남긴다. + +**왜 이 라운드가 있는가**: 사용자 요청 — *"5차 qa 를 할게, 4차에서 예로 +넘어갔던건 스킵하고, 새로운 부분들이나 다른 깊은 부분을 예가 나와야 정상인 +질문들을 쌓아보자."* 4라운드(510문항)는 `base/` 전 문서의 확정 주장을 +**한 겹으로 전수** 훑었고, 그중 실제로 "아니오"가 나온 것들은 이미 +`-followup.md`가 전량 처리했다. 그래서 이번엔 같은 자리를 다시 묻지 않고 +아래 셋에만 집중한다: + +1. **4라운드에 문항이 아예 없던 영역** — 신규 문서 `base/project-setup-plan.md`/ + `base/quad-types-plan.md`, 그리고 **문서가 아니라 실제로 커밋된 M1 코드** + (`quad-base/src`, `quad-types/src`, `type-version-check/src`). 4라운드는 + `base/` 문서만 대상이었다. +2. **4라운드 회신 이후에 새로 확정된 것** — `Detach` 보존 주체 + (`slot._detached`), `KeyGone`, `Owned`, `attachSlot` 분해 + (`materializeSlotTree`/`mountSlotTree`), raw 3형제, `settle`/`releaseElement`, + "부기가 물리보다 먼저"의 일반 계약 승격, flatten의 `ProcessedModifier`, + `recompute`의 `nil` → `error`, `Tween:Map`, `RunInit` 재설계 등. 전부 + **확정된 지 하루도 안 된 것들**이라 4라운드가 검증한 적이 없다. +3. **4라운드가 얕게만 훑은 큰 문서의 심화** — 예: `debounce-throttle-plan.md`는 + 1100줄이 넘는데 4라운드 문항은 9개뿐이었다. 이번엔 그 안의 실제 의사코드/ + 상태 전이/에러 경로를 묻는다. + +**그래서 문항의 성격이 4라운드와 다르다.** 4라운드가 "문서에 적힌 확정을 그대로 +문장으로 옮긴 것"이었다면, 이번엔 **문서가 명시적으로 말하지 않았지만 설계상 +따라나와야 하는 귀결**을 많이 묻는다 — 그게 "아니오"가 나올 확률이 높은 +자리이기 때문이다. 그런 문항은 `⭐`로 표시했다. + +**회신 방법**: 각 문항은 `예/아니오`로 답할 수 있게 썼다. **`아니오`인 것만** +알려주면 되고(어디가·어떻게 틀렸는지 + 원래 뭐가 맞는지), 판단이 애매하면 +"보류"라고만 해도 된다 — 4라운드처럼 풀어 쓴 답을 followup에 준비하겠다. + +**표기**: `X-N`의 `X`는 영역 코드, `N`은 그 영역 안 문항 순번. **4라운드와 +코드 체계가 다르다**(4라운드 `S-1`과 이 문서의 코드는 무관) — 이번엔 문서 +단위가 아니라 **주제 단위**로 묶었다. + +| 코드 | 영역 | 주 대상 | +|---|---|---| +| `PS` | 프로젝트 셋업 (4라운드 문항 없음) | `base/project-setup-plan.md` | +| `QT` | quad-types / 버전 체크 (4라운드 문항 없음) | `base/quad-types-plan.md` | +| `IM` | **실제 커밋된 M1 코드** (문서 아님) | `quad-base/src`, `quad-types/src`, `type-version-check/src` | +| `DE` | `Detach`/`_detached`/`KeyGone`/`Owned` (신규 확정) | `base/slot-plan.md` | +| `AS` | `attachSlot` 분해 (신규 확정) | `base/slot-plan.md`, `research/slot-attach-decomposition.md` | +| `DC` | 디스패치 코어 심화 | `base/dispatch-core-plan.md` | +| `SS` | Source/State 심화 | `base/source-state-plan.md` | +| `LC` | 생명주기 배관 심화 | `base/lifecycle-pattern.md`, `base/relate-plan.md` | +| `EF` | Effect/Observer 심화 | `base/effect-plan.md` | +| `MO` | Modifier 심화 | `base/modifier-plan.md` | +| `AT` | Attribute/Tag 심화 | `base/attribute-plan.md`, `base/tag-plan.md` | +| `TW` | Tween/숏핸드 심화 | `base/tween-plan.md`, `base/ui-shorthand-plan.md` | +| `DT` | Debounce/Throttle 심화 | `base/debounce-throttle-plan.md` | +| `ML` | 모듈 생명주기 심화 | `base/module-lifecycle-plan.md` | +| `TL` | 타입 한계 심화 | `base/typing-limits.md`, `base/store-plan.md` | +| `CR` | **크로스컷** — 여러 문서에 걸친 상호작용/순서/에러 경로 | 여러 문서 | + +## 문항 수와 읽는 순서 + +**총 205문항**(이 문서가 그 수의 소스 — 다른 곳에 적지 않는다). 4라운드가 +510문항이었던 것에 비해 적은 건 범위를 줄였기 때문이지 깊이를 줄인 게 아니다 +— 이미 "예"로 넘어간 자리를 다시 묻지 않는다. + +### 읽는 순서 권고 + +**작성한 에이전트의 추정이지 확정이 아니다.** + +1. **`DE`/`AS`** — 어제 확정돼 아직 아무도 재심사 안 한 것. 잘못 굳으면 + M6 구현 전체가 그 위에 얹힌다. +2. **`IM`** — 유일하게 **실제로 돌아가는 코드**를 묻는 영역. 문서와 코드가 + 갈라졌다면 여기서 드러난다. +3. **`CR`** — 문서 하나만 봐서는 안 보이는 자리라 stale이 가장 잘 낀다. +4. **`PS`/`QT`** — 새 문서. 확정이 맞는지보다 "이 결정을 계속 유지할 + 것인가"(예: `pesde.lock` 커밋, symlink 수동 치환)를 묻는 문항이 섞여 있다. +5. **나머지** — 순서 무관. + +--- + +## PS. `base/project-setup-plan.md` — 프로젝트 셋업 (4라운드 문항 없음) + +### PS-1 — pesde 전환의 판정 기준 +wally → pesde 전환의 근거는 "네이티브 workspace가 있다"가 아니라 **dev-dependency +1급 지원 등 툴링 전반이 낫다**는 사용자 판단이고, 모노레포 모양(루트 통합 개발 + +서브패키지별 독립 게시)이라는 **결론 자체는 wally 시절과 안 바뀌었다**. pesde의 +workspace 기능은 그 모양에 맞아떨어진 부수 이득이다. → **예/아니오** + +### PS-2 — 폴더 이름과 패키지 이름이 다르다 +폴더는 하이픈(`quad-base`), `pesde.toml`의 `name`은 언더스코어(`qwreey/quad_base`)로 +**의도적으로 다르게** 간다 — pesde 패키지 이름이 `a-z`/`0-9`/`_`만 허용해서다. +폴더 이름을 언더스코어로 통일하지 않는 이유는 `architecture.md`가 이미 확정한 +소스 트리를 흔들지 않기 위해서다. → **예/아니오** + +### PS-3 — 워크스페이스 의존성은 손으로 적는다 +`pesde add`는 레지스트리 검색 전용이라 로컬 워크스페이스 형제를 못 찾는다. +`[dependencies]`의 `workspace = "scope/name"` 줄은 **항상 손으로 쓴다**. 이건 +버그가 아니라 pesde의 설계이므로 우회 스크립트를 만들 계획도 없다. → **예/아니오** + +### PS-4 — target이 다르면 `target =`을 명시 +`quad-types`(target `roblox`)가 `type-version-check`(target `luau`)에 의존할 때는 +`{ workspace = ..., version = "^", target = "luau" }`처럼 target을 명시해야 하고, +빠뜨리면 `no workspace member found ... and target roblox`로 **install 자체가 +실패**한다(조용히 넘어가지 않는다). → **예/아니오** + +### PS-5 — 툴체인은 mise, rokit은 폐기 +`rokit.toml`은 완전히 폐기하고 `mise.toml`로 간다 — `pesde`/`rojo`/`luau-lsp`/ +`selene` 넷을 핀한다. 채택 근거의 절반은 "이 샌드박스가 이미 mise를 쓰고 있어서 +실제로 검증할 수 있었다"는 것이고, rokit은 끝내 한 번도 직접 검증하지 못했다. +→ **예/아니오** + +### PS-6 — ⭐ `luau` 자체는 툴체인 핀에 없다 +`mise.toml`이 핀하는 건 위 넷뿐이고 **`luau`/`luau-analyze` 자체는 안 핀한다** — +지금은 샌드박스가 제공하는 버전을 그대로 쓴다. 타입 스파이크 결과가 솔버 버전에 +민감하다는 걸 `typing-limits.md`가 이미 아는데도 이걸 안 핀해둔 건 **의도된 +현재 상태**이고, 사람이 로컬에서 다른 luau 버전으로 돌리면 결과가 갈릴 수 있다는 +걸 감수한다. → **예/아니오** + +### PS-7 — darklua 미채택의 정확한 경계 +darklua를 안 쓰는 근거는 "Roblox가 require-by-string을 지원해서"가 아니라 더 +좁다 — **예약 alias(`@self`/`@game`)는 darklua가 아예 안 건드리고 런타임이 +직접 처리**하는 반면, **커스텀 alias(`@pkg` 등)는 darklua 같은 빌드 스텝 +없이는 배포 시점에 해소된다는 근거가 없다.** quad가 커스텀 alias를 안 쓰기 +때문에 지금은 불필요할 뿐이고, `@pkg/quad_base`류를 도입하고 싶어지는 순간 +darklua(또는 동급 변환)가 실질적으로 필요해진다. → **예/아니오** + +### PS-8 — `init.luau` 안의 상대경로 기준점 +`init.luau` 안에서 `./X`는 **그 폴더의 형제**를 가리키므로, 같은 폴더 안 형제 +파일에 접근하려면 `@self/X`를 써야 한다. 그래서 `quad-base/src/init.luau`는 +`require("@self/Relate")`이고, `quad-base/src/Debug/init.luau`가 부모 폴더의 +`Relate.luau`를 가져올 땐 `require("./Relate")`(`../Relate`가 아님)가 맞다. +→ **예/아니오** + +### PS-9 — ⭐ 이 규칙이 조용히 깨진다 +`@self` 대신 `./`를 쓰는 실수는 **런타임에선 크래시하지만 `luau-analyze`는 +진단 0건으로 조용히 통과**한다(`Unifiable`로 샘). 즉 정적 검사만 +돌리는 라운드는 이 실수를 절대 못 잡으므로, `init.luau`를 새로 추가하는 +커밋은 **반드시 런타임으로 한 번 require해봐야** 한다. → **예/아니오** + +### PS-10 — symlink 함정의 범위 +`pesde install`이 워크스페이스 의존성을 symlink로 걸고, Luau standalone CLI의 +require-by-string은 **보안상 의도적으로 symlink를 안 따라간다**. 이건 Rojo/Studio +경로와는 무관하고(`rojo sourcemap`/`rojo build` 둘 다 투명하게 통과함을 실측), +**순수하게 `luau`/`luau-analyze` CLI 실행만의 문제**다. → **예/아니오** + +### PS-11 — ⭐ 그 우회는 아직 수동이다 +지금 쓰는 우회는 "`pesde install` 후 `roblox_packages/.pesde/`의 심볼릭 링크를 +실제 디렉토리 복사본으로 손으로 치환"이고, **스크립트/mise task로 정식화하지 +않았다.** 다음 세션이 CLI 테스트를 돌리려면 같은 수동 작업을 반복해야 한다. +이 상태를 계속 두는 게 맞고, 정식 스크립트화는 "실제로 자주 아파질 때" 하면 +된다. → **예/아니오** + +### PS-12 — ⭐ 그런데 이 우회가 이제 출하 소스에도 걸린다 +`quad-base/src/init.luau`가 `require("./roblox_packages/quad_types")`를 쓰게 +되면서, 이 함정은 더 이상 "스파이크/mock 테스트에만 해당"이 아니다 — **실제 +출하 진입점이 CLI에서 안 도는 상태**다. 그래도 배포 경로(Rojo/Studio)는 멀쩡하니 +문제로 취급하지 않는 게 맞다. → **예/아니오** + +### PS-13 — 패키지별 `selene.toml` +`selene`의 config 탐색이 파일 트리를 거슬러 올라가지 않고 **CWD 고정**이라, +루트 단일 설정을 두면 Luau 타입 문법이 전부 가짜 파싱 에러로 쏟아진다. 그래서 +패키지마다 `selene.toml`을 두고 **항상 패키지 디렉토리 안에서** 실행한다. +→ **예/아니오** + +### PS-14 — ⚠️ `pesde.lock` 커밋 여부는 아직 확정이 아니다 +루트 1개 + 멤버마다 1개씩 생기고, **셋 다(=전부) 커밋**하는 게 지금의 **잠정** +권고다("게시물에 안 들어가므로 라이브러리 lockfile 딜레마가 성립 안 함"). 이건 +아직 사용자 확정이 아니라 열려 있는 항목이 맞다. → **예/아니오** + +### PS-15 — 아직 검증 안 된 것 셋 +(a) 루트 `pesde.toml`의 `[target]`이 실제로 의미가 있는지, (b) Studio 실물 +동기화(계정 분리 대기), (c) `roblox_sync_config_generator` 미설정 WARN의 실제 +영향 — 셋 다 **지금은 방치가 맞고**, 실제로 문제가 드러날 때 열면 된다. +→ **예/아니오** + +### PS-16 — `.luaurc` alias는 편집기 전용 +`.luaurc`의 `quad-base`/`quad-roblox` alias는 런타임 require에서 **여전히 안 +먹는다**(`could not jump to alias`). 이건 위 symlink 문제와 **다른 원인**이고, +`@self`가 예약 alias라 항상 동작하는 것과도 구분된다. alias는 편집기 자동완성/ +타입체크용으로만 남긴다. → **예/아니오** + +### PS-17 — ⭐ 패키지 디렉토리 이름은 의존 대상의 target을 따라간다 +`roblox_packages/`와 `luau_packages/`가 갈리는 기준은 **의존하는 쪽**이 아니라 +**의존 대상 패키지 자신의 target**이다. 그래서 `quad-types`(roblox)가 +`type-version-check`(luau)를 쓸 때 경로가 `./luau_packages/type_version_check`가 +된다. → **예/아니오** + +### PS-18 — ⭐ CI가 없고, 당분간 만들 계획도 없다 +이 저장소엔 `.github/` 워크플로가 없고, 스모크 테스트(`quad-base/test/*.luau`)는 +**사람이나 에이전트가 직접 `luau`로 돌리는 것**이 전부다. `mise task`/`Justfile` +같은 태스크 러너도 아직 없다. M2 이후 코드가 붙기 시작해도 당분간 이 상태로 +가는 게 맞다(테스트 하네스 자체가 ROADMAP 항목). → **예/아니오** + +### PS-19 — ⭐ 테스트는 게시물에 안 들어간다 +`quad-base/pesde.toml`의 `includes = ["src/*"]`라 `test/`는 게시 대상이 아니다. +그래서 테스트가 프로덕션 의존성을 늘리는 걱정 없이 자유롭게 mock을 둘 수 있고, +반대로 **소비자가 mock을 재사용할 방법은 없다**(의도된 것 — 필요해지면 +`quad-mock`이 백로그에 있음). → **예/아니오** + +--- + +## QT. `base/quad-types-plan.md` — 타입 계약 패키지 + 버전 체크 (4라운드 문항 없음) + +### QT-1 — `quad-types`가 존재하는 진짜 이유 +문제의 뿌리는 "타입만 쓰는 require도 **런타임에 실제로 실행된다**"는 Luau 사실 +하나다. 그래서 `quad_base`를 dev-dependency로만 두면 게시 후 소비자 환경에서 +그 require가 **그 자리에서 크래시**한다. `quad-types`는 "항상 안전하게 실 +의존성으로 둘 수 있을 만큼 작은 것"이라는 자리를 채운다. → **예/아니오** + +### QT-2 — 폴더로는 못 나눈다 +pesde workspace 의존은 **패키지 단위**라 `quad-base/types/` 같은 서브폴더 의존 +문법이 없다. 그래서 "가벼운 타입만" 효과를 실제로 얻으려면 반드시 별도 +워크스페이스 멤버여야 한다. → **예/아니오** + +### QT-3 — quad-base도 자기 타입을 재선언하지 않는다 +`quad-base`는 `Quad` 타입을 스스로 선언하지 않고 `type Quad = QuadTypes.Quad`로 +가져다 쓴다 — 진실을 한 곳에 두고, 구현이 계약과 어긋나면 구조적 타입에러로 +자연히 드러나게 하기 위해서다. → **예/아니오** + +### QT-4 — 이 패턴을 모든 백엔드가 따를 필요는 없다 +`quad-spring`/`quad-spring-roblox` 같은 쌍은 타입 분리 없이 그냥 일반 의존성으로 +둬도 된다. `quad-types` 분리는 **사실상 모든 패키지가 의존하는 핵심 계약**일 +때만 값어치가 있다. → **예/아니오** + +### QT-5 — `Version`은 리터럴 타입 +`Version: "0.0.0"`은 `string`이 아니라 싱글턴 리터럴이고, 그 자체만으로도 이미 +구조적 타이핑이 버전 불일치를 어느 정도 잡는다. `CheckVersion`이 더해주는 건 +**감지**가 아니라 **사람이 읽을 수 있는 진단 메시지**다. → **예/아니오** + +### QT-6 — `AddPlugin`의 `Self`는 반드시 제네릭 +`Self`를 `Quad`로 고정하면 두 번째 `AddPlugin`이 첫 확장을 잃는다. 제네릭이라 +`Quad & A & B`로 누적되는 게 실측 확인됐고, 플러그인 추가 전 그 메소드에 +접근하면 정확히 `Key 'X' not found`로 거부된다. → **예/아니오** + +### QT-7 — 런타임 `AddPlugin`은 mutate + 같은 테이블 반환 +새 테이블을 만들지 않고 `self`를 직접 mutate해 그대로 반환한다 — `RunInit`의 +멱등 추적이 `module` identity에 의존하므로, 새 테이블을 반환하면 그 추적이 +끊기기 때문이다. → **예/아니오** + +### QT-8 — ⭐ 그래서 플러그인 키 충돌은 조용히 덮어쓴다 +`AddPlugin`이 확장 테이블의 필드를 그냥 대입하므로, **두 플러그인이 같은 키를 +제공하면 나중 것이 이긴다** — 경고도 에러도 없다. 지금은 이걸 방어하지 않는 +게 맞고, 실제로 충돌이 관측되면 그때 다룬다. → **예/아니오** + +### QT-9 — `type-version-check`를 뺀 이유 +정확 일치만 보면 독립 게시되는 플러그인 쌍(`quad-spring-roblox`류)이 매번 +재게시를 요구받게 된다. 그래서 글롭/캐럿 패턴을 지원하는 **quad 비종속** 패키지로 +분리했고, 파일 안에 `Quad`류 이름을 절대 안 섞는다(나중에 독립 저장소로 +분리 예정이므로). → **예/아니오** + +### QT-10 — 패턴 문법 +`.`로 나뉜 각 자리가 `"*"`(와일드카드) / `"N^"`(N 이상) / 그 외(정확 일치)다. +자릿수가 다르면(`"0.0"` vs `"0.0.0"`) 그냥 불일치다. → **예/아니오** + +### QT-11 — `type function`은 바깥 로컬을 못 본다 +`Type function cannot reference outer local 'X'`로 **컴파일 자체가 실패**하므로, +런타임 `matchesPattern`과 `CheckVersion` 내부 매칭은 물리적으로 중복된 두 함수다 +— 하나를 고치면 반드시 다른 하나도 고쳐야 하고, 이 중복은 없앨 방법이 없다. +→ **예/아니오** + +### QT-12 — 함정 1: `error()` 대신 `print` + `types.never` +`type function` 안에서 `error()`를 부르면 "이 type function이 실행에 실패함"으로 +판정돼 메시지가 안 뜬다. `print(...)` + `return types.never` 조합만 호출부에 +`TypeError: <메시지>`로 뜬다. → **예/아니오** + +### QT-13 — 함정 2: lazy 평가라 필드를 실제로 참조해야 한다 +`local _ = checked.__versionCheck` 줄이 빠지면 **검사가 조용히 스킵**된다. +함수 본문 안의 로컬 타입 별칭(`type _Check = ...`)으로는 제네릭 인스턴스화 +시점에 재평가되지 않아 아무 진단도 안 뜬다. → **예/아니오** + +### QT-14 — 함정 3: `type function`을 거친 이력만으로 오염된다 +패스스루(`return t`)여도 그 타입에 이후 `AddPlugin` 같은 제네릭 self 메소드를 +부르면 `Expected this to be exactly 'P & Self', but got 'P & Self'`처럼 앞뒤가 +같은 진단을 내며 깨진다. 그래서 `CheckVersion`은 원본을 **절대 반환하지 않고** +트리비얼한 `true`만 반환하며, 결과는 별도 필드(`__versionCheck`)로 완전히 +격리한다. → **예/아니오** + +### QT-15 — ⭐ 그 교훈은 일반 규칙이다 +"검증/변형용 `type function`은 원본 타입을 절대 반환하지 말고 트리비얼한 마커만 +반환해 별도 필드로 격리한다"는 건 이 한 사례의 우회가 아니라 **앞으로 quad가 +`type function`을 쓸 때마다 따를 규칙**이고, `typing-limits.md`의 설계 +체크리스트에 들어가야 한다. → **예/아니오** + +### QT-16 — ⭐ 버전 문자열이 세 곳에 하드코딩돼 있다 +지금 `"0.0.0"`은 (1) `quad-base/pesde.toml`의 `version`, (2) `quad-types`의 +`Quad.Version` 리터럴 타입, (3) `quad-base/src/init.luau`가 대입하는 실제 값 — +**세 곳**에 각각 적혀 있고, 이걸 동기화해주는 기계 장치는 없다(주석의 +"항상 맞출 것"이 전부). 버전을 올릴 땐 사람이 셋을 같이 고쳐야 하고, 지금 +단계에선 그게 맞다. → **예/아니오** + +### QT-17 — ⭐ `CheckedQuad`는 소비자가 자기 패턴을 고른다 +`Pattern`이 타입 파라미터인 이유는 관계마다 엄격도가 달라야 하기 때문이다 — +같은 모노레포에서 함께 개발되는 quad-base/quad-roblox는 `"0.0.0"`(정확 일치), +독립 게시되는 플러그인은 `"0.*.*"` 같은 느슨한 패턴. **quad가 강제하는 기본 +패턴은 없다.** → **예/아니오** + +### QT-18 — ⭐ 런타임 버전 체크는 안 만든다 +`CheckedQuad`는 **컴파일 타임 전용**이고, 런타임에 버전을 비교해 error를 내는 +경로는 만들지 않는다(그러려면 quad-roblox 소스에 버전을 하드코딩해야 하는데 +과하다는 사용자 판단). `type-version-check`가 런타임 `matchesPattern`을 +export하는 건 테스트/문서화용이지 quad가 그걸 부르기 위한 게 아니다. +→ **예/아니오** + +### QT-19 — `quad-roblox-types`는 백로그, 다만 관례는 지금부터 +`quad-roblox`의 공개 타입을 단일 `src/init.luau`(또는 `types.luau`)에 몰아두는 +관례는 **지금부터** 지킨다 — 나중에 `quad-roblox-types`를 쉽게 뽑기 위해서다. +패키지 자체를 지금 만들 필요는 없다. → **예/아니오** + +--- + +## IM. 실제로 커밋된 M1 코드 (문서가 아니라 코드가 대상) + +**이 영역만 성격이 다르다** — 4라운드는 `base/` 문서만 봤고, 아래는 지금 +저장소에 실제로 들어 있는 `quad-base/src/init.luau`, `quad-base/src/Relate.luau`, +`quad-base/src/Debug/init.luau`, `quad-types/src/init.luau`, +`type-version-check/src/init.luau`, `quad-base/test/*`를 읽고 뽑은 문항이다. +**"문서가 이렇게 말한다"가 아니라 "코드가 실제로 이렇게 돼 있다"를 묻는다.** + +### IM-1 — 최상위 반환값 +`quad-base/src/init.luau`는 `return New()`로 **이미 만들어진 인스턴스**를 +반환하고, 그 인스턴스가 자기 필드로 `New`를 갖는다. 즉 `Quad.New()`는 지금도 +호출 가능한 상태이고, `architecture.md`가 말한 "지금 단계에선 `New` 필드가 +아직 노출되지 않는다(싱글톤)"는 **더 이상 코드와 안 맞는다**(코드 쪽이 +맞고 문서 표현이 stale). → **예/아니오** + +### IM-2 — 메소드는 인스턴스마다 새 클로저 +`RunInit`/`AddPlugin`은 공유 메타테이블이 아니라 `New()` 안에서 **매 인스턴스마다 +새로 만들어지는 클로저**다. `New()`가 사실상 한 번만 불리는 지금은 이 비용이 +무의미하고, 메타테이블로 바꿀 이유가 없다. → **예/아니오** + +### IM-3 — 멱등 기록은 파일 스코프 `Relate` 하나 +`runInitRelate`는 `New()` 안이 아니라 **파일 스코프**에 하나뿐이고, `module`을 +weak 키로 쓴다. 그래서 `New()`로 만든 인스턴스들이 서로의 `RunInit` 기록을 +공유하지 않으면서도(키가 다름) 별도 정리 코드가 필요 없다(module이 GC되면 +기록도 같이 사라짐). → **예/아니오** + +### IM-4 — 실행 전에 먼저 표시한다 +`runInitRelate:SetStrong(self, initFn, true)`를 `initFn(self)` **호출 전에** +찍는다 — 순환 의존(`InitA`가 `InitB`를 부르고 그 반대도)에서 무한 재귀를 +막기 위해서다. 그 결과 **`initFn`이 도중에 error를 던져도 "실행됨"으로 +기록이 남고**, 다시 `RunInit`해도 재실행되지 않는다. 이건 알려진 트레이드오프로 +그대로 두는 게 맞다. → **예/아니오** + +### IM-5 — ⭐ `:: Quad` 캐스트가 타입 구멍을 만든다 +`New()` 안에서 `module`은 `New`/`Version` 두 필드만 가진 채 `:: Quad`로 +캐스트되고, `debug`는 그 뒤 `module:RunInit(InitDebug)`가 채운다. 그래서 +**`RunInit(InitDebug)` 줄을 실수로 지워도 타입 에러가 안 나고** 런타임에 +`Quad.debug`가 `nil`이 될 뿐이다(스모크 테스트만이 이걸 잡는다). 지금 +단계에선 이 구멍을 감수하는 게 맞다. → **예/아니오** + +### IM-6 — `InitDebug`는 자기 가드를 안 둔다 +`Debug/init.luau`는 "이미 초기화됐는가"를 스스로 확인하지 않는다 — 멱등성은 +**전적으로 호출부(`module:RunInit`)의 책임**이다. 앞으로 추가되는 모든 +`InitXxx`도 같은 규약을 따른다(각자 가드를 두지 않는다). → **예/아니오** + +### IM-7 — `Relate` 구현이 계획과 일치하는지 +실제 `Relate.luau`는 (a) 바깥 `buckets`가 `__mode = "k"`, (b) 버킷과 +`StrongMap`/`WeakMap`이 **각각 lazy 생성**, (c) `WeakMap`의 메타테이블은 +파일 스코프 `WEAK_VALUE_MT` 하나를 모든 맵이 공유, (d) `Get*`는 서브맵이 +없으면 만들지 않고 그냥 `nil` — `relate-plan.md`의 "실제 구조" 절 그대로다. +→ **예/아니오** + +### IM-8 — ⭐ 삭제 API가 없다 +`Relate`의 공개 표면은 `SetStrong`/`GetStrong`/`SetWeak`/`GetWeak` 넷뿐이고, +**`Delete`/`Clear` 같은 명시적 삭제 메소드가 없다** — 지우려면 +`SetStrong(inst, key, nil)`을 부른다. `relate-plan.md`가 "명시적으로 만든 기록은 +명시적으로 지울 것"이라고 강하게 요구하는데도 전용 API를 안 두는 게 맞다 +(`nil` 대입으로 충분하므로). → **예/아니오** + +### IM-9 — ⭐ 빈 버킷은 회수되지 않는다 +어떤 `inst`의 마지막 키를 `nil`로 지워도 그 `inst`의 버킷 테이블(그리고 빈 +`StrongMap`/`WeakMap`)은 그대로 남는다 — 버킷 자체는 `inst`가 GC될 때만 +사라진다. 빈 버킷을 즉시 청소하는 로직은 **일부러 안 넣는다**(살아있는 +`inst`당 작은 테이블 하나이고, 청소하면 다음 `Set`에서 다시 만들어야 함). +→ **예/아니오** + +### IM-10 — `AddPlugin`의 런타임 계약 +`pluginFn(self)`의 반환값을 **일반화 `for`로 순회해 그대로 `self`에 대입**한다. +그래서 (a) 반환값이 테이블이 아니면 그 자리에서 런타임 에러, (b) 배열 +파트/해시 파트 구분 없이 전부 복사, (c) 검증/충돌 체크는 전혀 없다 — 지금 +단계에선 이게 맞다. → **예/아니오** + +### IM-11 — ⭐ 플러그인은 `self`를 이미 받는다 +`pluginFn`이 인자로 받는 `self`는 **아직 자기 확장이 반영되기 전의 module**이다. +그래서 플러그인이 자기 확장 필드를 그 시점에 `self`에서 읽을 수는 없고, +반대로 자기가 반환하는 테이블 안에서 클로저로 `self`를 캡처하는 건 자유다 +(그때는 이미 mutate가 끝나 있으므로). → **예/아니오** + +### IM-12 — 테스트는 프레임워크 없는 assert 스크립트 +`quad-base/test/*.luau`는 `assert` + `print("PASS")` 조합이고 테스트 러너가 +없다 — 사람이/에이전트가 `luau quad-base/test/smoke.init.luau`처럼 직접 +돌린다. 실패는 assert가 그 자리에서 터지는 것으로 표현된다. M2 이후에도 +당분간 이 형태로 간다. → **예/아니오** + +### IM-13 — mock의 의도적 범위 축소 +`test/mock.luau`는 parent/children 트리 + 타입 검증 없는 property bag + +property별 변경 시그널만 만들고 `IsA()`/클래스별 스키마/`WaitForChild`를 +안 만든다. Vide mock의 GC-독립 userdata 프록시 트릭도 **일부러 안 쓴다** +(그건 quad-roblox의 gcconn 트릭이 다루는 자리라 quad-base 정적 테스트 +범위 밖). → **예/아니오** + +### IM-14 — ⭐ 지금 코드에 없는 것 +현재 `quad-base/src`에는 `Dispatch`도 `Source`/`State`도 `Slot`도 없고, +`Relate`/`Debug`/모듈 골격뿐이다. 즉 **4라운드·5라운드가 검증하는 설계 중 +실제 코드로 존재하는 건 `Relate`와 모듈 생명주기뿐**이고, 나머지는 전부 +의사코드 상태다. 이 인식이 맞다. → **예/아니오** + +--- + +## DE. `Detach` / `_detached` / `KeyGone` / `Owned` — 2026-08-21 신설 확정 + +**4라운드 회신 이후에 확정된 것들이라 아직 아무도 재심사하지 않았다.** + +### DE-1 — 보존 주체가 필드여야 하는 이유 +detach된 요소를 `userdata`가 아니라 `slot._detached` **필드**가 들고 있어야 하는 +이유는 셋 다다 — (1) `userdata`는 opaque라 `:List`가 뭘 죽여야 할지 모르고, +(2) 소유권 기록이 Slot에 남아야 남이 못 가져가고, (3) `destroySlotTree`의 walk가 +닿아야 한다. → **예/아니오** + +### DE-2 — GC 폴백이 아예 없다 +`Parent = nil`인 detach 노드는 자기 gcconn 클로저가 자기를 캡처해 **아무도 안 +들고 있어도 영원히 살아남는다.** 그래서 "언젠가 GC가 치운다"가 성립하지 않고 +명시적 정리 경로가 필수다. → **예/아니오** + +### DE-3 — raw 3형제가 갈리는 축은 둘 +`rawRemove`(반납+파괴) / `rawUnmount`(반납+안 죽임) / `rawDetach`(**유지**+안 죽임) +— "파괴하는가"와 "소유권을 놓는가"가 독립된 두 축이고, 네 번째 조합(유지+파괴)은 +의미가 없어 안 만든다. → **예/아니오** + +### DE-4 — 재클레임 예외는 플래그로만 +`Detach` 재마운트는 `rawUnmount`를 안 거치고 곧바로 `rawAdd`를 부르므로, +`claimOwner`에 `fromDetached` 플래그가 참일 때만 통과하는 **좁은 예외**를 뒀다. +이걸 "같은 owner면 통과"로 완화하면 `Slot { a, a }`가 다시 새어나가므로 절대 +완화하면 안 된다. → **예/아니오** + +### DE-5 — 재-`Detach`는 nop, `prev` 반환은 재마운트 +이미 `_detached`에 있는 키에 또 `Detach`를 반환하면 아무 일도 안 일어나고, +그 요소를 `prev`로 그대로 반환하면 `_detached`에서 빠지며 재마운트된다. 매 +사이클 filter에 걸리는 흔한 경로라 여기서 반복 작업이 생기면 안 된다. +→ **예/아니오** + +### DE-6 — `ud`로 붙잡을 필요가 없어졌다 +`reconcile`이 `mounted[key] or self._detached[key]`를 `prev`로 넘기므로 +`updateFn`은 `return Detach, ud`만 하면 되고, 옛 서술의 `return Detach, { old = prev }` +관용구는 폐기됐다. `ud`에 담는 것 자체는 자유지만 `:List`가 정리해주지 않는다. +→ **예/아니오** + +### DE-7 — ⭐ `_detached`는 모든 Slot이 갖는다 +`settle`/`destroySlotTree`가 `self._detached[key]` / `pairs(slot._detached)`를 +**조건 없이** 인덱싱하므로, `_detached`는 `:List`를 설치하지 않은 Slot에도 +**생성 시점에 빈 테이블로 초기화돼 있어야** 한다(안 그러면 파괴 walk가 `nil` +인덱싱으로 터진다). 즉 `_owned`처럼 "없으면 기본값"으로 다루는 필드가 아니다. +→ **예/아니오** + +### DE-8 — `KeyGone`의 인자 규약 +키가 사라진 자리는 `updateFn(KeyGone, 0, offset, prev, ud)`로 한 번 더 묻고, +`index`는 `0`(자리가 없으므로), `offset`은 Slot의 것을 그대로 넘긴다. 반환값 +의미는 정상 사이클과 같고, `prev`를 그대로 반환하는 것만 `error`다. +→ **예/아니오** + +### DE-9 — ⭐⭐ `KeyGone`에 **새 값**을 반환하면 어떻게 되나 +지금 의사코드는 소멸 루프에서도 같은 `settle`을 `pos = 0`으로 부르므로, `updateFn`이 +`KeyGone`에 대해 **새 요소를 반환하면** 교체 분기로 들어가 `rawAdd(self, result, 0)`, +즉 **인덱스 0에 마운트**를 시도한다(`Add`는 범위 밖 인덱스에 clamp 없이 error). +"반환값 의미는 정상 사이클과 완전히 같다"는 서술과 이 코드가 어긋난다 — +**`KeyGone`에 새 값을 반환하는 것도 `prev` 반환처럼 `error`여야** 하는 게 맞나? +(대안: 마지막 위치에 붙이기 / 조용히 무시) → **예/아니오 + 어느 쪽인지** + +### DE-10 — 다시 안 묻는다 +소멸 루프가 **직전 사이클의 `keyIndex`**만 순회하므로 사라진 키는 다음 사이클 +대상이 아니다 — "한 번만 묻는다"를 위한 별도 플래그가 필요 없다. → **예/아니오** + +### DE-11 — ⭐ 그래서 홀드된 것은 owner가 죽을 때까지 남는다 +`KeyGone`에 `Detach`로 답한 요소는 (a) 키가 다시 나타나 `prev`로 부활하거나 +(b) owner가 죽어 `_detachCleanup`이 정리할 때까지 **무한정 `_detached`에 쌓인다.** +키 churn이 큰 리스트에서 이게 메모리 증가로 보일 수 있다는 걸 감수하고, +잘라내는 정책(LRU/최대 개수 등)은 만들지 않는다. → **예/아니오** + +### DE-12 — 정리 Effect의 소유 층위 +`_detachCleanup`은 `mountSlotTree`가 아니라 **`activateList`**가 만들고, +`_listObserver`와 **완전히 같은 취급**을 받는다 — 생성 1회, 언마운트 시 +앵커만 해제하고 핸들 보존, 재마운트 시 새 target에 재앵커, 파괴 시 해제 후 +`nil`. → **예/아니오** + +### DE-13 — cleanup이 하는 일의 순서 +`_detachCleanup`의 cleanup은 각 요소에 대해 **`releaseOwner`를 먼저, 두 분기 +공통으로** 부르고 그 다음에만 `_owned ~= false`일 때 파괴한다. 반납을 +빠뜨리면 `Owned = false` 요소가 죽은 Slot을 owner로 달고 남아, 사용자가 그 +값을 다른 Slot에 넣을 때 GC 타이밍에 따라 "이미 마운트됨" error가 난다. +→ **예/아니오** + +### DE-14 — ⭐ 상태 없는 `Effect`가 이 용도의 유일한 도구다 +`Effect(function() return function() ... end end)`처럼 **state 없이** 쓰는 건 +"leaf가 죽을 때 cleanup 1회"만 얻으려는 것이고, `bindLifetime`은 "실행해도 +되는가"만 게이팅할 뿐 죽는 순간의 콜백을 안 주기 때문에 대안이 없다. +→ **예/아니오** + +### DE-15 — `Owned`는 설치 시점에 고정되는 축 +`Owned`는 사이클마다 달라지는 판단이 아니라 **설치 시점 플래그**(기본 `true`)이고, +`Detach`("지금은 안 쓰지만 내 것")와 **직교**한다. 그래서 반환값 계열에 +"unowned replace" 센티널을 하나 더 만들지 않는다. → **예/아니오** + +### DE-16 — `_owned`는 필드여야 한다 +`destroySlotTree`/`dispose`가 읽어야 하므로 클로저 업밸류가 아니라 Slot 필드 +(`slot._owned`)이고, 코드는 `_owned == false` / `_owned ~= false`로 판정한다 +(= 미설정이면 owned). → **예/아니오** + +### DE-17 — ⭐ `Owned = false`인데 `updateFn`이 새 요소를 만들면 +`Owned`는 리스트 전체 단위라, `Owned = false`로 설치된 `:List`에서 `updateFn`이 +자기가 만든 새 Instance를 반환하면 **그건 아무도 파괴해주지 않는다**(밀려날 +때도 언마운트만, owner가 죽을 때도 언마운트만). 이건 "혼합은 표현 못 함"의 +구체적 귀결이고, 그래도 지금은 새 축을 안 만드는 게 맞다. → **예/아니오** + +### DE-18 — `Slot:Add(state)` sugar가 `Owned = false`의 유일한 정규 진입점 +사용자가 직접 `opts.Owned = false`를 넘길 수도 있지만, 실제로 이게 필요한 +정규 경로는 **반응형 raw 요소 sugar**(`:Single` + identity `updateFn`)다. +`state` 교체가 이전 값을 파괴하지 않는다는 확정 의미론이 이걸로 지켜진다. +→ **예/아니오** + +### DE-19 — ⭐ `opts`는 네 번째 위치 인자다 +`Slot:List(data, updateFn, keyFn?, opts?)`라 `keyFn` 없이 `opts`만 주려면 +`nil`을 명시적으로 끼워 넣어야 한다(`slot:List(data, fn, nil, { Owned = false })`). +테이블 하나로 합치거나 named 파라미터로 바꾸지 않고 이 모양으로 간다. +→ **예/아니오** + +### DE-20 — `settle`을 뽑은 이유 +정상 사이클과 소멸 루프가 **같은 처분 함수**를 공유해야 두 경로가 갈라지지 +않는다. 그래서 `Detach`/`nil`/`prev`/교체 네 분기가 전부 `settle` 한 곳에만 +있다. → **예/아니오** + +### DE-21 — ⭐ `prev`가 그대로인데 위치도 같으면 아무 일도 안 한다 +`result == prev`이고 detach 상태가 아니면 `keyIndex[key] ~= pos`일 때만 +`rawMove`를 부른다 — 즉 값도 위치도 그대로인 흔한 경로는 **테이블 조회 +몇 번이 전부**이고 물리 트리에 손을 안 댄다. → **예/아니오** + +### DE-22 — ⭐ 언마운트 중에 들어온 데이터 변경은 재마운트가 따라잡지 않는다 +`unmountSlotTree`가 `_listObserver`의 앵커만 풀기 때문에 언마운트 동안의 emit은 +`canExecute`에서 걸러진다. 그리고 `activateList`의 재마운트 분기는 앵커만 다시 +걸고 **`reconcile`을 다시 돌리지 않는다.** 그래서 포탈로 옮겨온 Slot은 다음 +emit이 올 때까지 **옛 스냅샷 그대로**다 — 이게 의도된 동작이 맞나? +(대안: 재마운트 시 1회 강제 reconcile) → **예/아니오** + +--- + +## AS. `attachSlot` 분해 — `materializeSlotTree` + `mountSlotTree` (2026-08-21 확정) + +### AS-1 — 쪼갠 건 함수가 아니라 재귀다 +공개 `attachSlot`은 이름/시그니처/호출부가 하나도 안 바뀌고 몸통만 두 줄이 +됐다 — 실제로 갈라진 건 **트리를 도는 재귀 자체**(부기 재귀 / 물리 재귀)다. +그래서 다른 문서의 `attachSlot` 참조가 전부 그대로 유효하다. → **예/아니오** + +### AS-2 — 분해의 진짜 동기 +"책임이 일곱 개라 읽기 어렵다"가 아니라, **C6("부모에게 미는 길이는 최종값")과 +C7("부기가 물리보다 먼저")이 단일 함수 안에서는 `setLength` 슬롯이 하나뿐이라 +원리적으로 동시 만족 불가**였다는 것이 진짜 이유다. 분해로 둘이 처음 동시에 +성립한다. → **예/아니오** + +### AS-3 — 순서가 함수 경계로 강제된다 +`_mounted`를 켜는 코드가 `materializeSlotTree`에는 **아예 없으므로**, +`RC-3`/`RC-4` 류(한 함수 안에서 줄 순서를 잘못 잡는) 실수 클래스가 구조적으로 +사라진다. 이게 "지금 의사코드를 건들이는 비용이 추후 실수 누적 비용보다 싸다"의 +구체적 내용이다. → **예/아니오** + +### AS-4 — `materializeSlotTree` 안의 순서 +`setOffsetSource`(즉시 계산) → `slot.Offset` 대입 → (`_listed`면) `activateList` +→ `blocker:On()` → 자식 루프(재귀 또는 `setOffsetSource(None)`+`setLength(1)`) +→ `OffWithoutEmit()` → `recompute` → **마지막에** `setLength(ownerKey, position, +slot.Length)`. 이 일곱 줄의 순서가 전부 실제로 밟은 버그에서 나온 제약이다. +→ **예/아니오** + +### AS-5 — ⭐ `activateList`가 Blocker **밖**에서 불리는 게 맞나 +지금 코드는 `blocker:On()`보다 **먼저** `activateList`를 부른다. 그 시점의 +`:List` 최초 reconcile이 게이팅 없이 도는데도 `RC-1`류 크래시가 안 나는 이유는 +**그 Slot이 아직 `_mounted == false`라 `rawAdd`가 `_elements`에만 넣고 끝나는 +경로를 타기 때문**이고, 부기(`setLength`/`recompute`)를 아예 안 건드리므로 +게이팅할 대상 자체가 없다. → **예/아니오** + +### AS-6 — Blocker의 범위가 정의와 일치한다 +이제 Blocker가 감싸는 건 **자식 등록 루프뿐**이고 물리 마운트는 감싸지 않는다 +— "배치 등록 게이팅"이라는 이름과 실제 범위가 처음으로 정확히 같아졌다. +`mountSlotTree`는 Blocker가 필요 없다. → **예/아니오** + +### AS-7 — 예외 시 Blocker가 켜진 채 남는 건 감수한다 +자식 재귀가 예외를 던지면 `OffWithoutEmit()`에 도달하지 못해 그 Slot의 +`recompute`가 영원히 게이팅되고 `Length`가 영구 stale해진다. `pcall`로 감싸지 +않는 이유는 (1) 마운트 도중 예외는 quad가 복구를 보장하지 않는 상태이고 +(에러 경계는 `Fallback`/`Traceback` 몫), (2) 실제로 밟은 적 없는 경로이며, +(3) 이건 분해가 만든 창이 아니라 옛 단일 `attachSlot`에도 있던 갭이기 때문이다. +→ **예/아니오** + +### AS-8 — `mountSlotTree`는 정말로 물리 대입만 한다 +`_mounted`/`_mountedInst` 세팅과 `Parent` 대입 재귀가 전부이고, `_detachCleanup` +설치가 `activateList`로 이관되면서 부기가 한 줄도 안 남았다. → **예/아니오** + +### AS-9 — 관측 가능한 차이는 "부기 완료 후 일괄 물리" +`Parent` 대입 **순서 자체는 동일**하고(깊이 우선 같은 순서), 달라지는 건 +부기와 물리가 더 이상 인터리브되지 않는다는 것뿐이다. 그래서 첫 `ChildAdded` +핸들러가 볼 때 서브트리의 `Length`/`Offset`이 이미 최종값이고(옛 코드는 +`inner.Length == 0`인 미완성 스냅샷), `slot.Length` 구독자도 `0` → 최종 두 번이 +아니라 최종값 한 번만 본다 — **엄밀히 더 정확해지는 방향**이다. → **예/아니오** + +### AS-10 — ⭐ 재귀는 같은 `physicalTarget`을 그대로 내려보낸다 +중첩이 아무리 깊어도 `materializeSlotTree`/`mountSlotTree`가 자식에게 넘기는 +`physicalTarget`은 **트리 최상위의 물리 Instance 하나**다(`ownerKey`만 부모 +Slot으로 바뀐다). 그래서 모든 중첩 Slot의 `bindLifetime` 앵커가 그 한 inst에 +몰리고, **자식 Slot만 파괴돼도 그 inst는 살아있는 게 흔한 경우**라 파괴 +경로가 `_listObserver`/자식 observer를 명시적으로 `unbindLifetime`해야 한다. +→ **예/아니오** + +### AS-11 — 런타임 단건 `Add`는 여전히 게이팅 없이 `attachSlot` 한 줄 +이미 마운트된 outer에 나중에 nested Slot을 `Add`하는 경로는 공개 `attachSlot`을 +그대로 부르고 Blocker가 필요 없다 — 그 시점엔 앞선 모든 position이 이미 안정적으로 +채워져 있어 `nil` 자리가 생길 여지가 없기 때문이다. → **예/아니오** + +### AS-12 — 백엔드 seam +`mountSlotTree`가 부기를 안 건드리는 순수 walk라, 일괄 삽입이 유리한 백엔드 +(웹 `DocumentFragment` 등)는 **이 함수 하나만** 갈아끼우면 된다. 그게 분해의 +이득 넷 중 하나로 문서화돼 있다. → **예/아니오** + +### AS-13 — ⭐ `attachSlot` 두 줄 사이에서 중단되는 상태를 정의하지 않는다 +`materializeSlotTree`만 돌고 `mountSlotTree`가 아직 안 돈 중간 상태(부기는 있고 +물리는 없음)는 **공개 API로 노출하지 않고**, 그 상태에 이름도 안 붙인다 — +공개 진입점이 항상 둘을 연달아 부르기 때문. 나중에 "prepare만 하고 나중에 +mount"를 실제로 원하게 되면 그때 표면을 만든다. → **예/아니오** + +### AS-14 — ⭐ `Dispatch.drive`는 같은 모양으로 안 맞춘다 +분해 후보 (C)는 `Dispatch.drive`(일반 props 마운트)까지 같은 부기/물리 2단 +모양으로 수렴시키는 것이었는데, 채택한 건 (B)라 **`drive`는 지금 형태 그대로**다. +Slot만 2단이고 일반 props는 아닌 비대칭을 감수한다. → **예/아니오** + +--- + +## DC. 디스패치 코어 심화 + +### DC-1 — "부기 먼저"는 이제 일반 계약이다 +`rawAdd` 한 곳의 관습이던 것이 **모든 `raw*`가 따르는 일반 계약**으로 승격됐고, +근거는 "일관성 있는 동작이 백엔드 작성자에게 전제를 준다"는 것이다(연산마다 +선후가 다르면 백엔드가 매번 확인해야 함). → **예/아니오** + +### DC-2 — 방향이 반대로 보이는 건 같은 원칙의 두 얼굴 +"넣기는 부기 먼저"(`rawAdd`)와 "빼기는 물리 먼저"(`rawRemove`/`rawUnmount`/ +`rawDetach`)는 모순이 아니라 **항상 좁은 쪽이 먼저**라는 하나의 원칙이다 — +빼면서 부기를 먼저 줄이면 아직 트리에 있는 요소가 순서 계산에서 빠지는 +역전이 생긴다. → **예/아니오** + +### DC-3 — 진짜 이유는 깜빡임이 아니다 +`process`/`attachSlot` 체인 도중 코루틴 yield가 금지돼 있으므로 이 순서가 +어긋나도 **사용자에게 보일 프레임이 그 사이에 없다.** 그래서 계약의 목적은 +"안 지키면 깜빡인다"가 아니라 "백엔드가 전제할 수 있게 하나로 고정한다"이다. +→ **예/아니오** + +### DC-4 — `Splice`와 `Move`/`Swap` +`Splice`는 "제거는 물리 먼저, 삽입은 부기 먼저"를 이어 붙인 것이고, +shift/recompute를 1회로 묶는 최적화는 **그 사이에서만** 일어난다. +`rawMove`/`rawSwap`은 `Parent`를 안 건드리므로 이 계약의 대상이 아니다. +→ **예/아니오** + +### DC-5 — `recompute`의 `nil`은 skip이 아니라 `error` +`sourceList[i] == nil`은 도달 경로가 없다는 게 재추적 결론이므로(`bk.N`=실제 +개수 / 배치 중엔 Blocker 게이팅 / 해제는 `None` / `spliceArraysDown`은 압축), +보이면 **부기가 깨진 것**이라 조용히 건너뛰지 않고 즉시 `error`다. +→ **예/아니오** + +### DC-6 — ⭐ 그 `error`와 "Offset을 `nil`로 안 되돌린다"는 한 결정의 양면 +해제 시 `slot.Offset`을 `nil`로 갈아치우지 않고 stale하게 두기로 한 것 +(`SL-75` 정정)과, `sourceList`에 `nil`이 보이면 error를 내는 것은 **같은 +불변식의 두 표현**이다 — "이 자리는 항상 실재하는 값(`Source` 또는 `None`)으로 +채워져 있다". → **예/아니오** + +### DC-7 — `bk.N`은 고정값이 아니라 그때그때 실제 개수 +`setLength`가 새 최대 position을 등록할 때 늘고 `spliceArraysDown`이 줄인다. +**`setOffsetSource`는 `bk.N`을 안 건드리는데**, 그 이유는 호출 순서가 항상 +`setOffsetSource(i)` → `setLength(i)`라서 `lengthList[i]`가 아직 안 채워진 채 +`bk.N`만 먼저 커지는 창을 막기 위해서다. → **예/아니오** + +### DC-8 — Blocker 게이팅의 현재 근거는 비용이다 +`bk.N` 모델이 바뀌면서 "배치 중 `nil`을 읽어 크래시"는 더 이상 발생 경로가 +아니고, 게이팅이 여전히 필요한 이유는 **등록마다 `recompute`가 도는 O(N²)를 +배치 끝 1회로 줄이는 것**이다. → **예/아니오** + +### DC-9 — ⭐⭐ 그런데 `setOffsetSource`의 즉시 계산이 O(N²)다 +`setOffsetSource(ownerKey, i, source)`는 등록될 때마다 `bk.lengthList[1..i-1]`을 +**매번 다시 합산**하므로, 배치 전체로 보면 그 자체가 O(N²)다 — Blocker로 +`recompute`를 O(N²)→O(N)으로 줄인 이득이 여기서 상쇄된다. 그래도 +(a) N이 보통 작고 (b) `recompute`의 비용은 순회가 아니라 `Set`이 트리거하는 +다운스트림 캐스케이드라 성격이 다르므로, **누적합을 배치 상태로 들고 +다니는 최적화는 지금 안 한다**가 맞나? → **예/아니오** + +### DC-10 — `setOffsetSource`의 `None` 스킵 +`source == None`이면 즉시 계산 자체를 건너뛴다 — 참여 안 하는 자리는 계산할 +게 없기 때문이고, 이건 `recompute`가 `offset ~= None`일 때만 `Set`하는 것과 +같은 판정이다. → **예/아니오** + +### DC-11 — 배치 끝의 `recompute`는 명시적 1회 호출이다 +`OffWithoutEmit()`이 스스로 recompute를 트리거하는 게 아니라, 배치를 연 쪽이 +**끄고 나서 직접 한 번** `recompute(ownerKey, bk)`를 부른다. → **예/아니오** + +### DC-12 — yield 금지는 새 방어 로직이 아니다 +"`process`/`attachSlot` 체인 도중 yield 금지"는 런타임 검사를 넣겠다는 뜻이 +아니라 **어기면 UB라는 계약 문서화**다(재진입/무한루프를 방어하지 않는다는 +기존 원칙과 같은 톤). → **예/아니오** + +### DC-13 — ⭐ 컴포넌트 함수도 그 계약 아래 있다 +그래서 **사용자가 쓰는 컴포넌트 함수·`updateFn`·Handler 전부가 동기 함수여야 +한다** — 안에서 `task.wait()`류로 yield하면 그 순간 UB다. 이건 quad의 공개 +계약으로 문서에 나가야 하는 제약이다. → **예/아니오** + +### DC-14 — `recompute` 재진입 가드는 없다 +Slot마다 `Relate(자기 자신)`으로 독립된 `bk`를 가지므로 중첩만으로는 같은 +`(ownerKey, bk)`가 재진입되는 경로가 없다. 사용자 코드가 `updateFn` 안에서 +같은 Slot에 `Add`/`Remove`를 다시 거는 경우만 남는데 그건 사용자 버그로 +간주한다. → **예/아니오** + +### DC-15 — 배열 자리의 센티널 선택은 자리마다 다르다 +`sourceList`/`flattened`처럼 "채워짐 여부"를 엄밀히 구별해야 하는 배열은 +실재 센티널(`None`/`ProcessedPreRef`)을 쓰고, `Ref`의 콜백/대기자 배열은 +**`nil`로 되돌아갔다**(`table.insert`가 구멍을 되찾아 쓰므로). 두 결정은 +충돌이 아니라 서로 다른 요구에서 나온 것이다. → **예/아니오** + +### DC-16 — props 순회: 계약은 그대로, 구현만 1회로 +"배열 파트 전체가 해시 파트보다 먼저"는 여전히 base가 **보장하는 계약**이고 +백엔드가 자기 드라이버를 짜도 지켜야 한다. 바뀐 건 quad-base 자신의 구현이 +그 보장을 **단일 일반화 `for` 한 번**으로 얻는다는 것뿐이다. → **예/아니오** + +### DC-17 — 그 전제는 `flattened`가 항상 평범한 Luau 테이블이라는 것 +props는 사용자가 쓴 Lua 테이블 리터럴에서 오고 `flatten`도 그걸 제자리에서 +뮤테이션할 뿐이라, `__pairs`/`__ipairs`를 갈아끼운 값이 들어올 경로가 없다. +**`inst`가 백엔드마다 다를 수 있다는 것과는 무관한 얘기**다. → **예/아니오** + +### DC-18 — ⭐ 스파이크 `01`이 지금 계약과 안 맞는다 +`luau-test/done/01-two-pass-array-hash-order.luau`는 두 루프 버전이라 +재작성 대상이고, 재작성 시 확인할 것은 **"배열 파트 전체가 해시보다 먼저 + +배열 안에서는 index 순서"** 둘이다. 이 재작성은 M2 착수 전에 하는 게 맞다. +→ **예/아니오** + +### DC-19 — `Get` 가드의 목적 +`recompute`가 `offset:Get() ~= sum`일 때만 `Set`하는 건 순회 비용 때문이 +아니라 **다운스트림 리액티브 캐스케이드**(이미 마운트된 원소들의 `LayoutOrder` +재적용)를 막기 위해서다. 같은 원칙이 `rawAdd`의 `Length:Set` 호출부에도 +적용된다. → **예/아니오** + +### DC-20 — `Slot.Length`는 개수가 아니라 기여도의 합 +plain 요소는 1, nested Slot은 그 `.Length`를 기여한다. plain만 있는 흔한 +경우엔 합 == 개수라 체감 차이가 없다. 그리고 수동 `Visible` 토글은 `Length`가 +못 잡는 게 **맞다**(그건 사용자가 별도 State로 계산할 몫). → **예/아니오** + +### DC-21 — 웹 백엔드는 offset 관측을 no-op으로 둔다 +`insertBefore`류는 물리 삽입만으로 뒤 형제가 밀리므로, quad-web의 offset +핸들러는 아무것도 안 하고 숫자는 "다음에 스스로 insert/remove할 물리 +인덱스"를 위해서만 부기된다. base 로직은 완전히 동일하다. → **예/아니오** + +### DC-22 — 동적 자식의 유일한 정당 경로 +`Slot` 또는 `state`류 store-bind만이 `setLength`/`setOffsetSource`를 +정확히 부르는 경로이고, 사용자가 `newInst.Parent = parentInst`로 직접 끼워 +넣는 건 **방어 로직 없는 UB**다(부기가 조용히 어긋남). → **예/아니오** + +--- + +## SS. Source/State 심화 + +### SS-1 — `invalid`는 전파 장치가 아니다 +`invalid`는 "내 캐시가 낡았다"는 표시 하나뿐이고, 이미 `invalid`여도 신호는 +그대로 아래로 전파된다. **평범한 State는 절대 신호를 삼키지 않으며**, 신호를 +늦추거나 흡수할 수 있는 건 명시적 게이트(`Blocker`, 그리고 설계만 끝난 시간 +기반 게이트)뿐이다. → **예/아니오** + +### SS-2 — 다이아몬드에서 접는 것과 안 접는 것 +중복 **재계산**은 pull + 캐시가 막고, 중복 **통지**는 의도적으로 안 막는다 — +`d` 아래의 Observer가 한 사이클에 두 번 우는 건 정상 동작이고, 접고 싶으면 +`Blocker` 같은 명시적 게이트를 쓴다. → **예/아니오** + +### SS-3 — 나중에 넣을 dedup은 파동 단위여야 한다 +순회 비용이 실측에서 문제가 되면 "한 번의 전파 파동 안에서만 같은 노드를 두 번 +방문하지 않는다"(방문 집합/에포크)를 넣을 수 있지만, **시간에 걸쳐 유지되는 +`invalid` 플래그로 하면 절대 안 된다** — 그게 2026-08-14에 뒤집힌 바로 그 +실수다. → **예/아니오** + +### SS-4 — `table.clone`은 관측이 아니다 +구조적 복사는 State 핸들을 옮길 뿐 `:Get()`을 안 부르므로 계산을 트리거하지 +않는다. 그래서 Modifier의 clone 기반 체이닝과 "관측해야 실체화된다" 원칙이 +충돌하지 않는다. → **예/아니오** + +### SS-5 — ⚠️ 중간 State 생존은 여전히 열려 있다 +`A → B → C → Observer`에서 중간 `B`/`C`를 **강하게 붙잡는 주체가 문서 어디에도 +없다**는 게 미해결 항목이고, 해법 방향은 "구독 엣지는 하류로 weak, 상류로 +strong"이다. **M3 착수 전에 (a) 불변식 명문화 여부와 (b) `luau-test` 실측이 +둘 다 필요**하다. → **예/아니오** + +### SS-6 — ⭐ `:Compute`가 "우연히" 안전한 것에 기대면 안 되는 이유 +`:Compute`는 콜백 클로저가 상류를 캡처해 우연히 살릴 수 있지만 **`:With`가 +만드는 pass-through 노드는 계산 함수가 없어** 그 우연이 없다. 즉 이 문제는 +"거의 항상 괜찮은데 가끔 새는" 게 아니라 **`:With` 체인에서는 구조적으로 +끊긴다**. → **예/아니오** + +### SS-7 — ⭐ 상류 strong이 순환을 만들지 않는다 +"상류 strong / 하류 weak"를 채택해도 A↔B 강참조 순환이 생기지 않는 이유는 +방향이 한쪽뿐이기 때문이고, 그래서 `relate-plan.md`가 경고하는 두-`Relate` +상호 강참조(ephemeron 부재로 실제 누수)와는 다른 모양이다. → **예/아니오** + +### SS-8 — `canBound`와 `canExecute`는 서로의 부정 +`canBound(v) == not isBoundAlive(v)`, `canExecute(v) == isBoundAlive(v)`이고 +판정 로직만 `isBoundAlive` 하나로 공유한다. 게이트 형태는 항상 +`if not canBound(v) then error(...) end`이고, 전파 루프만 `canExecute`를 부정 +없이 쓴다. → **예/아니오** + +### SS-9 — 죽은 바인딩의 재사용은 허용이다 +`inst`가 Destroy됐거나 `unbindLifetime`된 값은 `canBound`가 **참**이라 다시 +다른 `inst`에 걸 수 있다 — 이 게이트는 **살아있는 바인딩만** 막는다. +→ **예/아니오** + +### SS-10 — ⭐ 두 predicate가 벌어질 여지를 남겨둔 이유 +지금은 정확히 부정 관계지만, 나중에 "구조적으로는 묶여 있으나 일시적으로 발화만 +멈춘" 상태가 생기면 관계가 단순 부정에서 벌어질 수 있다 — 이름을 둘로 나눠 둔 +게 그때를 위한 여지이기도 하다. 다만 **그런 상태를 지금 만들 계획은 없다.** +→ **예/아니오** + +### SS-11 — `SetAndDispose`는 `Source`의 콜론 메서드 +`source:SetAndDispose(value)`로 확정됐고 `:Set`과 한 세트다. `Apply` 오버라이딩 +경로가 불가능한 이유는 `Source`→`State`가 **단방향**이라 `Apply`에 `Source`를 +넘겨주는 시그니처가 타입으로 성립하지 않기 때문이다. → **예/아니오** + +### SS-12 — ⭐ 그래서 `State`엔 `SetAndDispose`가 없다 +쓰기 API가 `Source`에만 있다는 기존 원칙 그대로이고, `state:Apply(...)` +시그니처는 이 결정에 **전혀 영향받지 않는다**. → **예/아니오** + +### SS-13 — ⭐ `dispose`는 여전히 "값을 지우는" 단일 경로다 +`SetAndDispose`는 `Get()` → `Set(new)` → 옛 값 `dispose` 3단계의 sugar일 뿐 +새 파괴 경로가 아니고, reconcile 도중 호출이 거부되는 등 `dispose`의 기존 +제약을 그대로 물려받는다. → **예/아니오** + +--- + +## LC. 생명주기 배관 심화 (`bindLifetime`/`Relate`) + +### LC-1 — ⚠️ `bindLifetime`의 첫 인자는 Instance가 아닐 수 있다 +`Dispatch.setLength(ownerKey, ...)`가 Slot-in-Slot에서 **Slot 자신**을 넘기므로, +백엔드의 `bindLifetime`은 이 경우를 반드시 핸들링해야 한다. gcconn 트릭은 +`GetPropertyChangedSignal`에 의존해 평범한 Lua 테이블엔 못 건다. → **예/아니오** + +### LC-2 — 계약 둘은 그대로, 구현만 갈린다 +"(1) 바인딩이 유효한 동안 `value`가 최소한 `inst`만큼 산다" / "(2) `value`가 +`inst`의 생존을 스스로 확인할 수 있다" 두 계약은 안 바뀌고, Slot일 땐 그걸 +`Relate(slot)`의 `SetStrong`(또는 Slot 필드) + weak-keyed 기록으로 구현한다. +→ **예/아니오** + +### LC-3 — ⚠️ `isBoundAlive`의 세 번째 분기가 아직 미확정 +지금 스케치는 gcconn이 없으면 `.Subscribed` 폴백으로 떨어지는데, Slot-owned +바인딩은 **둘 다 없어서 살아있는데도 `canBound`가 참으로 잘못 나온다**(이중 +바인딩 가드가 이 경로에선 안 걸림). 세 번째 분기의 정확한 형태는 M2/M3에서 +정한다. → **예/아니오** + +### LC-4 — ⭐ 그래서 지금 Slot-owned 바인딩엔 이중 바인딩 가드가 사실상 없다 +그 상태를 M2/M3까지 안고 가는 게 맞고(그 경로를 만드는 건 quad 자신의 내부 +배관뿐이라 사용자가 이중 바인딩을 만들 방법이 없음), 사용자 표면에 노출되는 +가드에는 영향이 없다. → **예/아니오** + +### LC-5 — `Subscribed`는 이 계약과 무관 +`bindLifetime`/`unbindLifetime`은 `.Subscribed`를 **읽지도 쓰지도 않는다** — +그 필드는 전역 `:Subscribe()` 경로 전용이고, 옛 스케치가 여기서 그걸 +세팅하던 게 오염 지점이었다. → **예/아니오** + +### LC-6 — gcconn/gchold는 Instance **생성 시점**에 만든다 +`bindLifetime`이 만드는 게 아니다. 그래서 quad가 만들지 않은 Instance를 키로 +쓰는 건 UB이고, `Relate`의 `inst` 자리는 항상 weak다. → **예/아니오** + +### LC-7 — ⭐ 단일 `Relate` 안의 자기참조가 안전한 조건 +"값이 자기 키를 되참조해도 단일 `Relate` 안이면 안전"이라는 서술은, Luau에 +ephemeron이 없다는 걸 감안하면 **그 값이 weak로 보관될 때**(또는 값이 키를 +강하게 되잡지 않을 때)만 정확한 것 아닌가? `SetStrong`으로 보관하면서 값이 +키를 캡처하면 단일 `Relate` 안에서도 그 엔트리가 영원히 안 죽는 것 아닌가? +(실제 코드는 gcconn/gchold를 전부 `SetWeak`으로 두어 이 문제를 피하고 있다) +→ **예/아니오** + +### LC-8 — `SetStrong` 전에 물어야 할 것 +"이 값을 붙잡는 다른 근거가 이미 있는가" — 있으면 `SetWeak`가 맞다. 이 규칙의 +목적은 성능이 아니라 **"이 값의 수명이 어디서 끝나는가"의 답을 항상 하나로 +유지하는 것**(디버깅 가능성)이다. → **예/아니오** + +### LC-9 — 파괴 관측 지점은 지금 정확히 하나 +Instance 파괴를 관측하는 quad 내부 지점은 **`Effect` 하나뿐**이고, 사용자가 +Destroy-time 처리를 원하면 `Effect`(그 슈가 `OnDestroyed`)를 쓴다 — +`[Event "Destroying"]`을 직접 바인드하라는 안내는 정본이 아니다. → **예/아니오** + +### LC-10 — quad는 라이프사이클 "중간"에 없다 +엔진이 Destroy 시 Tag/Attribute/실행 중인 Tween을 정리해주므로 라이브러리가 +따로 처리하지 않고, `retract`는 Destroy 시점에 호출되지 않는다. → **예/아니오** + +--- + +## EF. Effect/Observer 심화 + +### EF-1 — leaf 바인딩된 핸들엔 `:Unsubscribe()`가 아예 안 먹는다 +Observer와 정확히 같은 규칙으로 통일됐다 — `:Subscribe()`의 짝이 `:Unsubscribe()`, +leaf 해제는 `unbindLifetime` 전용. 옛 "안 되거나/최소한 cleanup을 앞당기면 안 +됨"이라는 어정쩡한 서술은 폐기됐다. → **예/아니오** + +### EF-2 — 그 통일이 dedup 시나리오를 원천 차단한다 +"값이 안 바뀌어 retract가 no-op인데 `:Unsubscribe()`가 cleanup만 앞당겨 Effect가 +조용히 죽는" 경로는 leaf에서 `:Unsubscribe()`가 안 먹으면서 **발생 경로 자체가 +없어진다.** → **예/아니오** + +### EF-3 — ⭐⭐ 그런데 `effect-plan.md`가 아직 "미해결"이라고 말한다 +4라운드 followup A절은 `E-10`(dedup 경로의 process/retract 대칭)을 **"확인 완료 — +`relate`로 이전 값을 들고 `old ~= v` / `nextValue ~= v` 두 분기 안에서만 bind/unbind가 +일어나므로 성립"**으로 처리하고 `effect-plan.md`에 반영했다고 적었는데, **실제 +`effect-plan.md`에는 그 문장이 없고 여전히 "⚠️ 같이 확인해야 할 별건(미해결) … +M3 착수 전 확인할 것"이 그대로 남아 있다.** `.claude/todos.md`도 이 항목을 아직 +열린 것으로 센다. 즉 **반영이 실제로는 안 됐다**(followup의 과잉 보고). 이 +인식이 맞나? → **예/아니오** + +### EF-4 — `EffectHandle`의 cascade 요구 +`state`가 있는 Effect는 leaf 부착/`:Subscribe()` 어느 경로든 **내부 Observer까지 +같이** 바인드/등록해야 한다 — 안 하면 그 Observer에겐 `canExecute` 판정 근거가 +없어 재실행이 통째로 죽는다. `unbindLifetime(handle)`도 대칭으로 내부 Observer를 +같이 푼다. → **예/아니오** + +### EF-5 — ⭐ 그 cascade가 dedup 분기 **안**에 있어야 한다 +`ObserverEffectLeafHandler`의 `if old ~= v then bindLifetime(...) end` / +`if nextValue ~= v then unbindLifetime(...) end` 짝 안에 **내부 Observer cascade도 +같이** 들어가야 한다 — 한쪽만 dedup되면 handle과 내부 Observer의 바인딩 상태가 +갈린다. 이게 `E-10`이 실제로 확인해야 하는 내용이다. → **예/아니오** + +### EF-6 — `:Subscribe()`는 GC 원칙의 의도적 예외 +강참조 레지스트리라 로컬 참조를 다 놓아도 GC되지 않고 계속 실행된다 — +quad의 다른 프리미티브가 전부 GC-native인 것과 정반대라 **사용자 문서에 +명시적 경고가 필요하다**. 용도는 top-level 사이드 이펙트로 한정. → **예/아니오** + +### EF-7 — leaf 부착과 `:Subscribe()` 동시 사용은 즉시 error +"같은 liveness 게이트를 공유하니 안전"이던 옛 판단은 뒤집혔고, 지금은 +`canBound` 게이트로 **즉시 에러**다(한 핸들은 라이프사이클 경로를 하나만 갖는다). +→ **예/아니오** + +### EF-8 — ⭐ `state` 없는 Effect의 `:Unsubscribe()` +`Effect(fn)`(state 없음)은 install이 호출 시점에 이미 끝났으므로 +`:Unsubscribe()`는 "leaf-사망 cleanup을 수동으로 앞당기는 것"과 완전히 동치이고, +그 이상의 분기가 필요 없다 — 단 이것도 `:Subscribe()`한 핸들에서만 의미가 있다. +→ **예/아니오** + +--- + +## MO. Modifier 심화 — flatten 확정 이후 + +### MO-1 — flatten은 새 테이블을 안 만든다 +props 테이블 리터럴은 그 호출 한 번을 위해 만들어져 소비되고 끝이라 원본 보존 +의무가 없으므로, flatten은 **입력을 제자리에서 뮤테이션**한다. `Pre`/`PostRef` +소진이 이미 `flattened`를 제자리에서 갈아치우는 것과 같은 취급이다. +→ **예/아니오** + +### MO-2 — 소진 자리는 `ProcessedModifier` 센티널 +Modifier를 뽑아낸 배열 자리를 그냥 지우면 구멍이 생겨 배열 파트 순서 보장을 +잃으므로, `ProcessedModifier`로 채우고 전담 nop 핸들러 +`ProcessedModifierHandler`가 정상 `process` 경로에서 캐치해 +`setOffsetSource(None)`/`setLength(0)`을 등록한다 — **새 규칙이 하나도 안 는다.** +→ **예/아니오** + +### MO-3 — 반복은 반드시 역순 +"이미 있으면 건너뛴다"는 **먼저 쓴 쪽이 이기는** 규칙이라, 정방향으로 돌면 +"배열 순서상 나중 modifier가 우선"이라는 확정 규칙과 정반대가 된다. 그래서 +`for i = #input, 1, -1`이다. → **예/아니오** + +### MO-4 — 인라인 우선은 `~= nil` 하나로 성립 +인라인 해시 키는 flatten 전에 이미 테이블에 있고 명시적 unset(`None`)도 +실재값이라, "이미 값이 있으면 건너뛴다" 검사 하나가 곧 "인라인이 modifier를 +이긴다" 규칙이 된다 — 별도 분기가 없다. → **예/아니오** + +### MO-5 — ⭐ 세 센티널이 같은 패턴을 공유한다 +`ProcessedPreRef`/`ProcessedPostRef`/`ProcessedModifier`는 전부 "pre-pass가 +소진한 배열 자리"를 표시하고 각각 nop 핸들러가 받는다 — 앞으로 pre-pass가 +하나 더 생기면 **같은 모양을 그대로 복제**하는 게 맞고, 이걸 하나의 공용 +"소진됨" 센티널로 합치지는 않는다(핸들러가 무엇을 소진했는지 구별해야 하므로). +→ **예/아니오** + +### MO-6 — `None`만이 명시적 unsetter +`mod:X(nil)`은 "그 필드가 없는 새 Modifier", `mod:X(None)`은 "`None`으로 채워진 +Modifier"이고 차이는 `Overridden`/`Peek`에서 관측된다. → **예/아니오** + +### MO-7 — Getter를 안 만든다와 `:Peek`은 모순이 아니다 +"Getter를 안 만든다"는 **필드별 setter의 짝 getter**를 말하는 것이고, `:Peek`은 +키를 받는 범용 접근자라 층위가 다르다. → **예/아니오** + +--- + +## AT. Attribute/Tag 심화 + +### AT-1 — `groupClaimKeys`는 이름만 확정됐다 +위치별 claim 레지스트리의 이름은 `groupClaimKeys`로 확정됐지만 **키 설계는 +여전히 미정**이다 — `(inst, groupValue) → k`인지 `groupKey` 단위인지, +`nameClaims`와 어떤 순서로 확인하는지는 M10 구현 전에 정한다. → **예/아니오** + +### AT-2 — ⭐ 그래서 M10 전까지 `Frame { a, a }`는 여전히 뚫려 있다 +그룹 Attribute를 같은 객체로 두 위치에 놓으면 지금 설계로는 claim 체크를 +통과하고 `k=1` retract가 `k=2` 바인딩까지 철거한다 — **레지스트리를 실제로 +구현하기 전까지 이 갭은 열린 채로 간다**(M10 전엔 그룹 Attribute 자체가 없으니 +실사용 위험이 없음). → **예/아니오** + +### AT-3 — `Tag`가 다른 이유는 자원의 성질이다 +`Tag`는 자원이 "이름 집합"이라 겹치면 합집합이면 되고 위치별 참조 카운트로 +안전하지만, 그룹 `Attribute`는 자원이 **값 하나**라 겹침이 곧 충돌이다. +→ **예/아니오** + +### AT-4 — 생존 이름 최적화는 원리적으로 불가능 +이름이 같아도 값이 바뀌었을 수 있고, 값을 비교하려면 `:Get()`이 필요한데 그건 +State 계약 위반이다 — "부품이 늘어나서 안 한다"가 아니라 **할 수 없다.** +→ **예/아니오** + +### AT-5 — `Attribute(a, b, ...)` 생성자는 뒤가 이긴다 +`Merged`(겹치면 error) / `Overridden`(뒤가 이김)과 별개로 **생성자 자신의 +겹침 정책은 "뒤가 이김"**이다. → **예/아니오** + +### AT-6 — ⭐ `plain =`에 State/Source도 온다 +`Attribute(store1, store2, { plain = ... })`의 plain 테이블 값 자리에는 +raw 값뿐 아니라 `State`/`Source`도 올 수 있고, 이건 새 엔지니어링이 아니라 +원래 그렇게 구현될 예정이었다 — 문서에 그렇게 적는 게 맞다. → **예/아니오** + +### AT-7 — 해제→재클레임 순서는 Dispatch가 보장한다 +같은 핸들러 재프로세스는 `process`가 이전 retractor를 먼저 굴리고 자기 일을 +하는 모양(`process` → `retractor(v)` → 새 process)이고, 핸들러가 바뀌면 +`retractFrom` → `process`다 — 어느 경로든 옛 claim 반납이 먼저다. +→ **예/아니오** + +### AT-8 — `GetKey`는 비공개다 +키를 반출하면 사용자가 같은 키를 두 자리에 놓아 claim으로도 못 잡는 수렴이 +생긴다(0-W와 같은 형태의 갭). → **예/아니오** + +--- + +## TW. Tween / UI 숏핸드 심화 + +### TW-1 — `mapTweenValue`는 공개 메소드로 승격됐다 +로컬 헬퍼가 아니라 `Tween`의 공개 메소드다 — `table.clone` 후 `Value`만 +`fn(Value)`로 교체해 새 `Tween`를 반환하고, 원본은 안 건드린다. +→ **예/아니오** + +### TW-2 — ⚠️ 이름이 아직 안 정해졌다 +`Map`과 `Mapped` 둘 다 열려 있고, 코퍼스의 `-ed` 관례(즉시 확정되는 raw 값은 +과거분사)만 보면 **`Mapped`가 더 일관적**이다. 최종 결정이 필요하다. +→ **예/아니오 (+ 어느 쪽인지)** + +### TW-3 — ⭐ 호출부 분기는 그대로 남는다 +`isTween(v)`인지 판정하는 분기는 여전히 숏핸드 호출부에 있고, `Tween`이면 +`v:Map(wrap)`, 아니면 `wrap(v)`다 — 승격된 건 변환 로직이지 분기가 아니다. +→ **예/아니오** + +### TW-4 — 부수 이득은 좁다 +`local FAST = Tween{Value = 0, Time = 0.15}`를 두고 `FAST:Map(...)`으로 +옵션만 재사용하는 패턴이 열리지만 **사용 케이스가 넓지는 않을 것**으로 보고, +어차피 내부에 필요해서 만드는 걸 공개하는 것뿐이다. → **예/아니오** + +### TW-5 — `CanAnimate`는 처음부터 `State | boolean | nil` +`resolve`가 `:Get()`으로 푼다 — 4라운드 문항이 "단순 boolean"으로 축약한 게 +부정확했을 뿐 문서는 처음부터 맞았다. → **예/아니오** + +### TW-6 — 자식 파괴 시 `retractFrom`은 정석이 아니다 +엔진이 Destroy 시 실행 중인 Tween을 알아서 정리하고 `PropertyHandler`의 +retractor는 애초에 no-op이라, `retractFrom(child, prop, 1)`은 **두 겹으로 +무의미**하다. → **예/아니오** + +### TW-7 — 숏핸드 자식의 gcconn/gchold는 확인된 전제다 +`process` 위임을 하는 이상 gcconn/gchold가 없으면 **옵저버 바인딩부터 실패**하므로 +"조용히 미아"가 아니라 즉시 드러나는 전제 조건이고, `ensureManagedChild`가 +일반 인스턴스 생성과 같은 경로를 타야 한다는 계약으로 승격됐다. → **예/아니오** + +--- + +## DT. Debounce/Throttle 심화 (4라운드에선 9문항뿐이었던 영역) + +### DT-1 — 두 도구의 차이는 정확히 한 비트 +"신호가 창 타이머를 리셋하는가" 하나뿐이고 leading/trailing/통과 후 창 +재개방은 **완전히 동일**하다. 그래서 공용 게이트 + `Reset` 불리언 하나로 둘 +다 나온다. → **예/아니오** + +### DT-2 — lodash식 `maxWait`로 throttle을 흉내내지 않는다 +초안이 그 공식을 옮겼다가 **trailing 통과 직후 타이머를 회수해 바로 뒤 신호가 +"창 밖"으로 판정돼 1초 안에 두 번 나가는 버그**를 냈고, 지금 정식화는 그 +구멍이 구조적으로 없다. → **예/아니오** + +### DT-3 — `MaxTime`은 디바운스 전용 +"신호가 안 끊기면 영원히 발화 안 함"은 디바운스의 정의 그 자체라 안전장치가 +필요하지만, 스로틀은 원래 주기적으로 발화하므로 필요 없다. → **예/아니오** + +### DT-4 — 공개 `Blocker` API 위에는 못 얹는다 +`Blocker`엔 "상류 신호가 지금 도착했다"는 통지가 없어서 타이머를 (재)시작할 +순간을 알 수 없다. 그래서 게이트 노드 내부 훅이 필요하다. → **예/아니오** + +### DT-5 — ⭐ 그래서 M3에서 해야 할 일이 하나 생긴다 +`Blocker`를 구현할 때 게이티드 노드를 **공용 `Gate` 노드로 한 겹 일반화**해 +두는 것만은 그 시점에 해야 한다(나중에 하면 같은 설계를 두 번 한다). 새 노드 +종류를 만드는 게 아니라 이미 있는 하나에 이름을 붙여 꺼내는 것이다. +→ **예/아니오** + +### DT-6 — 제어 핸들은 세 번째 모양으로 수렴했다 +State 자신에 메소드를 붙이지도(타입이 갈림), `Blocker`식 공유 외부 객체도 +아니고(상태 기계라 공유가 위험), **개별은 `Ref` 아웃파라미터(`Handle = Ref()`), +전체는 팩토리 자신의 `:Flush()`/`:Cancel()`**이다. → **예/아니오** + +### DT-7 — 팩토리는 자기 게이트를 weak로만 추적한다 +strong으로 잡으면 다운스트림이 다 죽어도 게이트가 GC되지 않아 "정리는 GC에 +위임" 원칙과 충돌한다. → **예/아니오** + +### DT-8 — `Flush`/`Cancel`의 의미 +`Flush()`는 pending이면 창 끝을 안 기다리고 즉시 커밋(+창 재개방), pending이 +없으면 no-op(idempotent). `Cancel()`은 타이머를 정리하고 pending을 **버린다** +(전파 없음). → **예/아니오** + +### DT-9 — 주입 op는 base 범용 유틸 그룹이다 +`setTimeout`/`clearTimeout`은 핸들러 op 3종(`addTag`/`removeTag`/`setAttribute`)이 +아니라 `bindLifetime`/`canExecute`와 같은 층위이고, 게이트 전용도 아니다 +(나중에 타이머가 필요한 다른 것이 재사용). → **예/아니오** + +### DT-10 — JS 어휘를 고른 이유와 그 대가 +`task.delay`를 안 따라간 건 **`task`가 한 엔진의 것이라 base 층에 새기면 안 +되기 때문**이고, 그 대가로 Roblox 배선에서 인자 순서가 뒤집히는(시간이 먼저) +조용히 틀리기 좋은 자리가 생긴다 — 백엔드당 한 곳뿐이라 감당한다. +→ **예/아니오** + +### DT-11 — 가변인자를 안 받는 이유 +게이트 콜백은 게이트당 하나씩 만들어져 재사용되는 안정된 클로저(`onWindowEnd`)라 +호출마다 새로 만들 필요가 없다 — varargs로 아낄 할당이 애초에 없다. +→ **예/아니오** + +### DT-12 — `Timeout`은 마커 필드가 있는 전용 타입 +`type Timeout = {}`는 구조적으로 모든 테이블과 호환이라, `__type_timeout: true` +싱글턴 마커로 사실상 nominal하게 만들고 **런타임에도 실제로 그 필드를 넣는다** +(`isTimeout` 판별이 공짜로 따라옴). `_native`는 백엔드 페이로드고 base는 절대 +안 읽는다. → **예/아니오** + +### DT-13 — `clearTimeout`은 모든 백엔드에 요구해도 되는 계약 +네이티브 취소가 없는 엔진도 "플래그를 뒤집는 래퍼"로 항상 구현 가능하다. +다만 그 경우 타이머가 예정대로 깨어나 아무 일도 안 하므로 "대기 타이머가 +게이트를 붙잡는다"는 성질은 남는다(유계라 문제 아님). → **예/아니오** + +### DT-14 — `os.clock()`은 주입하지 않는다 +Luau 표준이고 고정밀이라 base가 그냥 부른다. **단 절대 시각이 아니라 차이 +계산 전용**이고, 이 설계는 전부 `deadline - os.clock()` 형태의 차이만 쓴다 +(부수 이득: 벽시계 보정에 영향 안 받음). → **예/아니오** + +### DT-15 — ⭐ 결국 순수 슈가라 우선순위가 낮다 +제어 핸들까지 닫히고 나니 quad-base에 **새 코어 메커니즘을 추가하지 않는** +순수 슈가로 확인됐고, M0/M3를 막지 않는다 — 예외는 위 DT-5(`Gate` 일반화)뿐. +→ **예/아니오** + +--- + +## ML. 모듈 생명주기 심화 — `RunInit` 재설계 이후 + +### ML-1 — 가드 소유 위치가 파일별에서 공유로 옮겨졌다 +각 `InitXxx`가 자기 `Relate()`+`INITED` 센티널을 두던 옛 설계는 폐기되고, +`module` 인스턴스가 공유하는 `RunInit` 하나가 판단을 전담한다 — 결론(멱등)은 +같고 **가드가 어디 있느냐만** 바뀌었다. → **예/아니오** + +### ML-2 — 함수 자신이 릴레이션 키다 +`(module, initFn) → boolean` 표가 곧 "이 함수를 이 모듈에 실행했는가"라서 +센티널 키가 아예 필요 없다. → **예/아니오** + +### ML-3 — 의존성 있는 `InitXxx`도 같은 패턴 +자기 의존성을 `module:RunInit(InitLifetime)`처럼 부르기만 하면 되고 순서/중복은 +`RunInit`의 멱등성이 흡수한다 — 최상위 `New()`가 순서를 관리하지 않는다. +→ **예/아니오** + +### ML-4 — `_initializedBy`와는 계속 별개다 +`RunInit`은 "이 **함수**가 이미 돌았는가"(멱등 실행)이고 `_initializedBy`는 +"이 **슬롯**을 누가 채웠는가"(다른 팩토리면 error)라 표현하는 게 다르다. +억지로 합치면 두 의미가 한 API에 섞인다. → **예/아니오** + +### ML-5 — ⭐ 그래서 backend 설치는 `RunInit`을 안 거친다 +`InitRoblox(module)`은 `module:RunInit(InitRoblox)`로 부르는 게 아니라 자기가 +`_initializedBy`를 직접 확인/기록한다 — 두 메커니즘이 한 호출에 겹치지 않는다. +→ **예/아니오** + +### ML-6 — 타입 재익스포트는 실측 확인됐다 +`type Dispatch = InitDispatch.Dispatch` 형태가 Luau에서 문제없이 동작하고, +이건 `typing-limits.md`의 재귀 제네릭 한계와 다른 자리(단순 alias)다. +→ **예/아니오** + +### ML-7 — `Quad.debug`의 게이팅 범위 +`Quad.debug`가 게이팅하는 건 **라이브러리가 스스로 콘솔에 쓰는 동작**뿐이고, +`Dispatch.listHandlers()`는 이 플래그와 무관하게 항상 호출 가능하다(목록을 +반환만 하고 출력은 사용자 몫). → **예/아니오** + +### ML-8 — ⭐ 다중 인스턴스에서 `debug`는 인스턴스별이다 +`InitDebug`가 `module.debug = false`를 각 module에 설치하므로 `New()`로 만든 +인스턴스마다 독립적이다 — "전역이냐 인스턴스별이냐"는 이제 미정이 아니라 +**코드가 이미 인스턴스별로 답했다.** → **예/아니오** + +--- + +## TL. 타입 한계 / Store 심화 + +### TL-1 — §6은 §1과 다른 한계다 +§1(재귀 제네릭이 자기를 다른 인자로 반환)은 **Luau 솔버의 상위 한계라 기다리는 +것**이고, §6(`type function`을 거친 이력이 제네릭 self 체이닝을 깨뜨림)은 +**설계로 회피 가능한 것**이라 quad가 실제로 회피 규칙을 세웠다. → **예/아니오** + +### TL-2 — 회피는 세 조각이 다 필요하다 +①`error()` 대신 `print`+`types.never`, ②검증을 리턴/필드 타입 표현식 자체에 +박아 호출부마다 재평가되게, ③원본을 절대 반환하지 않고 별도 필드로 격리 — +셋 중 하나라도 빠지면 안 된다. → **예/아니오** + +### TL-3 — 체크리스트 7번이 그 일반화다 +"`type function`으로 검사/변형한 값에 제네릭 self 메소드를 나중에 부를 +계획인가?"가 새 항목으로 들어갔고, 앞으로 같은 조합을 설계할 때마다 이걸 +먼저 본다. → **예/아니오** + +### TL-4 — 진단 0건은 안전의 증거가 아니다 +`luau-analyze` 0건이어도 타입이 제대로 해소됐다는 뜻이 아니므로 +`--annotate`로 실제 추론 타입을 눈으로 확인하고, 음성 대조군(일부러 틀린 +대입)을 같이 둔다. → **예/아니오** + +### TL-5 — ⭐ 이 규율이 실제로 두 번 물렸다 +(a) 재귀 제네릭이 조용히 `Unifiable`로 새던 것, (b) `init.luau`의 +require 경로를 `@self` 없이 써서 런타임만 깨지고 정적 검사는 통과하던 것 — +원인은 다르지만 **"진단 0건이 통과가 아니다"라는 같은 함정**이다. +→ **예/아니오** + +### TL-6 — Store 미선언 키 거부는 실측 확인됐다 +`ProcessStoreType`이 합성한 레코드 타입엔 인덱서가 없어 미선언 키 접근이 +정확히 `TypeError`로 거부된다(`luau-test/done/21-*`) — 더 이상 열린 항목이 +아니다. → **예/아니오** + +### TL-7 — ⚠️ `store:GetDynamic`의 위치는 여전히 미정 +콜론 메서드로 두면 `GetDynamic`이 **모든 Store의 예약 키**가 되어 lazy +`__index`와 충돌한다는 게 확인된 문제이고, 탑레벨 함수로 둘지 아직 안 정했다. +**M3/M4 착수 전 결론이 필요**하다. → **예/아니오** + +### TL-8 — ⭐ 그 충돌은 `Quad.debug`류와 성격이 다르다 +`Quad`는 필드 집합이 고정된 모듈 테이블이라 이름을 추가해도 사용자 키와 +안 부딪히지만, `Store`는 **사용자 키가 곧 필드**라 어떤 메서드 이름을 +붙이든 그 이름이 예약어가 된다 — 그래서 Store엔 콜론 메서드를 늘리는 +것 자체가 비용이다. → **예/아니오** + +--- + +## CR. 크로스컷 — 여러 문서에 걸친 것 + +### CR-1 — ⭐⭐ followup 표를 신뢰 소스로 쓰면 안 된다 +4라운드 followup의 "반영 완료" 표에 적혀 있어도 **실제 `base/`에 안 들어간 +항목이 최소 하나 있다**(`E-10`, 위 `EF-3`). 그래서 "무엇이 확정됐나"의 소스는 +언제나 `base/` 본문이고, followup은 경위 기록일 뿐이다 — 다음 세션이 followup만 +읽고 "이건 이미 닫혔다"고 판단하면 안 된다. → **예/아니오** + +### CR-2 — ⭐ `Tween:Map`/`Mapped` 이름이 어느 대기열에도 없다 +`tween-plan.md`는 "최종 이름 미확정"이라고 적어뒀는데 `question.md` 1번(용어 +정리)에도 `todos.md`에도 이 항목이 없다 — **결정이 필요한데 추적되지 않는 +상태**다. `Owned`는 제대로 올라가 있는 것과 대비된다. → **예/아니오** + +### CR-3 — ⚠️ M2가 M3의 `Blocker.luau`에 의존하는 순서 문제는 여전히 열려 있다 +`Dispatch.drive`의 배치 등록이 Blocker 게이팅을 전제하므로, `Blocker`(또는 +최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수 전에** 정해야 +한다. 지금은 ROADMAP에 각주만 달린 임시 상태다. → **예/아니오** + +### CR-4 — ⭐ gcconn 전제가 여러 설계의 뿌리다 +"quad가 만든 Instance는 참조를 놓아도 GC로 안 죽는다"는 사실 하나에서 +(a) `userdata`에 quad Instance를 담지 말라는 제약, (b) `_detached`가 영구 +누수라는 결론, (c) `dispose`라는 명시적 경로의 필요성이 **전부** 나온다. +→ **예/아니오** + +### CR-5 — ⭐ 그런데 그 전제는 백엔드마다 다르다 +gcconn 트릭은 quad-roblox의 것이고, mock/웹 백엔드에는 그런 장치가 없어 그냥 +GC될 수 있다. 그러면 "detach 요소는 명시적으로 안 지우면 영구 누수"라는 논증도 +**백엔드에 따라 참/거짓이 갈린다** — 그래도 base는 항상 명시적 정리를 하는 +쪽(가장 강한 요구)으로 통일하는 게 맞다. → **예/아니오** + +### CR-6 — ⭐ `userdata`만 GC에 맡기는 유일한 자리다 +Slot이 죽을 때 `userdata`는 명시적으로 비우는 코드가 없고 `activateList` +클로저가 통째로 GC되는 것에 의존한다(그래서 파괴 경로가 `_listObserver`를 +`unbindLifetime`하는 게 중요하다 — 안 풀면 `gchold`가 그 클로저를 계속 +붙잡는다). 이 비대칭(`_detached`는 명시적, `userdata`는 GC)은 의도된 것이다. +→ **예/아니오** + +### CR-7 — 사용자 표면의 파괴 경로는 셋뿐 +`Slot:Remove`/`Clear`(명시적 CRUD), `dispose(value)`, 그리고 `:List`의 +`Owned = true` 자동 처분 — 사용자가 직접 `Destroy()`를 부르는 건 quad가 +관리 중인 값에 대해선 UB다. → **예/아니오** + +### CR-8 — ⭐ M2 착수 전에 남은 것의 전체 목록 +지금 열려 있어 M2/M3를 실제로 막을 수 있는 건 다음이 전부다 — +(1) M2 vs `Blocker` 순서(CR-3), (2) 중간 State GC 불변식 + 실측(SS-5), +(3) `store:GetDynamic` 위치(TL-7), (4) `E-10` dedup 대칭 확인(EF-3/EF-5), +(5) `isBoundAlive`의 세 번째 분기(LC-3), (6) 스파이크 `01` 재작성(DC-18), +(7) `table.insert` 구멍 재사용 실측, (8) `Tween:Map` 이름(CR-2), +(9) 그룹 `Attribute`의 `groupClaimKeys` 키 설계(M10 전). **이 목록에 빠진 +게 없나?** → **예/아니오 (+ 빠진 것)** + +### CR-9 — ⭐ 그중 (5)와 (8)은 어디에도 추적되지 않는다 +`isBoundAlive`의 세 번째 분기는 `lifecycle-pattern.md` 본문에만 ⚠️로 있고 +`question.md`/`todos.md` 어디에도 없다. `Tween:Map` 이름도 마찬가지(CR-2). +**둘 다 추적 목록에 올려야 한다.** → **예/아니오** + +### CR-10 — 동기 계약은 공개 문서로 나가야 한다 +"컴포넌트 함수/`updateFn`/Handler 안에서 yield 금지"는 지금 `dispatch-core-plan.md` +안쪽에만 있는데, 이건 **사용자가 지켜야 하는 공개 계약**이라 사용자 문서에도 +반드시 나가야 한다. → **예/아니오** + +### CR-11 — ⭐ 이 라운드 이후의 계획 +4라운드 처리 때 "이후 stale만 잡는 것으로 끝낼 수 있어보임"이라고 했고 그래서 +5라운드를 안 만들 예정이었는데, 실제로 만들어보니 **새 확정분(어제 것)과 +아예 문항이 없던 영역이 남아 있었다.** 그러면 앞으로도 "큰 확정이 있은 뒤엔 +그 부분만 대상으로 하는 소규모 라운드"를 두는 게 맞나, 아니면 이번이 마지막이고 +이후엔 감사(`quad-doc-auditor`)+`/code-review`만으로 가는 게 맞나? +→ **둘 중 어느 쪽인지** + diff --git a/.claude/question.md b/.claude/question.md index e531b90..a3c425a 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -58,6 +58,14 @@ callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라 **패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절. +- **`Gate`(2순위, 2026-08-21 신설)**: `Blocker`/`Debounce`/`Throttle` 아래의 + 공용 게이트 노드 이름. 사용자 지적 — *"프리미티브 명을 Gater? 뭔가 이상하게 + 들어간다는게 약간의 문제"* — 코퍼스가 `Blocker`/`Modifier`/`Observer`처럼 + `-er`를 많이 쓰는데 `Gater`는 영어로 어색하다. 에이전트 권고는 **`Gate` + 그대로**(`gate`는 이미 행위자가 아니라 **장치**를 가리키는 명사라 `-er`가 + 불필요 — `Source`/`Ref`/`Slot`/`Tween`도 같은 계열), 대안 후보는 + `Valve`/`Relay`. **설계 자체가 다음 세션으로 미뤄졌으므로 이름도 그때 같이** + — 3번 절의 `Gate` 항목과 `research/gate-primitive.md`가 소스. - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. @@ -126,8 +134,14 @@ ## 3. 낮은 우선순위 — 열려 있지만 급하지 않음 -- **[신설, 2026-08-18 구현 전 QA 3라운드] M2가 M3의 `Blocker.luau`에 - 구조적으로 의존하게 됨 — 이대로 각주만 두고 로드맵 순서를 유지할지, +- **[해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] M2가 M3의 `Blocker.luau`에 + 구조적으로 의존하던 순서 문제 — "게이팅 먼저"로 결정.** 사용자 판정: + *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가 + 필요함."* 즉 로드맵 순서를 유지하지 않고 게이팅을 M2로 앞당긴다. **다만 + 앞당기는 대상이 `Blocker` 그 자체가 아니라 그 아래의 공용 `Gate` 노드로 + 바뀌었고**(같은 라운드 `DT-4`), 그 표면/이름이 미정이라 **새 열린 항목으로 + 이어진다 — 바로 아래 `Gate` 항목.** 아래는 해소 전 서술: 이대로 각주만 두고 + 로드맵 순서를 유지할지, `Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로 앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법 때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가 @@ -137,6 +151,95 @@ 임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전 필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤 정합성" 절. +- **[해소, 2026-08-21 구현 전 QA 5라운드 H절] `mountInst`의 삽입 위치 + 중첩 + offset 결함 — `Dispatch.getOffsetAt(ownerKey, i)` 신설로 확정.** + `setOffsetSource(None)`은 얼리 리턴하고, 숫자가 필요한 쪽이 `getOffsetAt`을 + 직접 부른다(사용자안 — 병렬 배열을 안 늘리는 pull 방식). 베이스는 `isSlot` + 분기의 정체가 "베이스가 있나"임을 명확히 했고(베이스는 따로 저장하지 않고 + Slot의 `.Offset`을 그대로 읽는다 — 최상위 물리 inst는 항상 0), 중첩 Slot은 + 자기 `Offset`을 관측해 깊은 전파를 한다. 반영하다 **재마운트가 `Offset` Source를 새로 만들던 결함**도 + 같이 잡았다(identity 재사용). 상세는 + `qa-request/pre-implementation-qa-round5-followup.md`의 H절. + 아래는 열려 있던 시점의 서술: 둘이 한 덩어리다: + (a) `setOffsetSource`의 `None`이 "아무것도 안 차지함"과 "발행 채널 없음" + 두 뜻을 겸하고 있어 **plain 요소의 offset 숫자가 계산조차 안 된다**(그래서 + DOM류 백엔드가 삽입 위치를 알 방법이 없다 — 사용자 지적), (b) `recompute`가 + `sum = 0`에서 시작해 `ownerKey.Offset`을 안 읽으므로 **depth ≥ 2에서 중첩 + Slot의 자식 offset이 부모 베이스만큼 어긋난다**(이번에 발견, 지금까지 depth 1만 + 써서 안 드러났음). 제안은 `bk.offsetList` 신설(항상 숫자 계산) + + `mountInst(target, element, index)` + `recompute`의 `base` 시드 + 중첩 Slot이 + 자기 `Offset`을 관측해 재계산하는 구독 하나 — + `qa-request/pre-implementation-qa-round5-followup.md`의 G절이 소스. +- **[해소, 2026-08-21] offset이 바뀌면 이미 배치된 물리 노드를 옮겨야 하는가 — + 아니오.** **사용자 확정**: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고 + 빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"* — DOM류는 삽입/삭제 + 자체가 뒤 형제를 물리적으로 밀고 당기므로, **이미 놓인 노드를 다시 옮길 일이 + 없다.** offset 숫자는 그 자리가 **다음에** insert/remove할 때 쓰는 것뿐이라는 + 기존 서술이 그대로 맞다. 이게 성립하려면 백엔드 op이 **아토믹한 최소 단위** + (`mountInst`/`unmountInst`/reposition)여야 한다는 게 같이 확인된 요구사항. + 아래는 열려 있던 시점의 서술: `mountInst(target, element, + index)`가 **삽입 시점의 위치만** 받는 일회성 호출이라, 이미 마운트된 요소의 + 물리 위치는 offset이 바뀌어도 갱신되지 않는다. Roblox는 `LayoutOrder`가 + 프로퍼티라 `updateFn`이 반응형으로 처리하면 되지만 **DOM은 물리 순서 자체가 + 배치**다. `dispatch-core-plan.md`는 "quad-web의 offset 핸들러는 no-op이고 숫자는 + *다음에* 스스로 insert/remove할 때만 쓴다"고 적어뒀는데, 앞 형제의 길이가 변해 + 뒤 형제들의 offset이 밀리는 흔한 경우에 **이미 놓인 노드를 옮길지**가 그 서술만 + 으론 안 갈린다. 선택지는 (a) 옮긴다(백엔드가 offset 변경을 관측해 재배치), + (b) 안 옮긴다(그러면 DOM에선 순서가 실제로 어긋남), (c) `:List`가 재조정 때 + 필요한 것만 명시적으로 다시 `mountInst`. quad-web이 실제로 생길 때까지 미룰 수 + 있으나, **계약을 지금 정해두지 않으면 M6 구현이 (b)를 전제로 굳는다.** +- **[해소, 2026-08-21] `:List` 재조정의 `getOffsetAt` 비용 — 접두합 캐시로.** + **사용자 제안 채택**: *"getOffsetAt 은 compute 된걸 캐시해도 될듯. length + 변경되는 뒤는 캐시가 무효화되도록 shouldRecomputeAfter 등을 둬서 특정 인덱스 + 초과부는 offset 다시 계산하고, 해당 값 위치 자체는 유효하므로 그것을 통해 더 + length 를 이어붙이면 될듯."* → `bk.offsetCache` + `bk.offsetDirtyFrom`으로 + 반영(`base/dispatch-core-plan.md`). 무효화 트리거 셋(`setLength`는 `i+1`부터, + splice는 그 자리부터, 베이스 변경은 전체)까지 명시. **남은 작은 확인 하나** — + `recompute`의 전체 순회가 그 캐시를 같이 채우게 할지는 구현 시 판단(그 문서에 + ⚠️로 표시). 아래는 열려 있던 시점의 서술: + `settle`이 키마다 `rawAdd`/`rawReplace`를 부르고 그 각각이 `getOffsetAt`(O(i))을 + 부르므로, 이미 마운트된 리스트의 데이터가 통째로 바뀌면 **O(N²)**다(최초 마운트는 + `_mounted == false`라 얼리 리턴이 막아준다). `DC-9`에서 `setOffsetSource`의 즉시 + 계산이 O(N²)인 걸 "배치 등록 1회니 감수"로 판단했지만 **이건 매 reconcile이라 + 빈도가 다르다.** 해법은 있다 — reconcile이 `pos`처럼 **절대 offset도 러닝 + 누적**으로 들고 다니면 O(n)(그게 `mountSlotTree`가 이미 하는 방식). 그렇게 + 할지, 아니면 실측 전엔 그냥 둘지 판단 필요. +- **⭐ [신설, 2026-08-21 구현 전 QA 5라운드] 공용 `Gate` 노드의 이름과 표면 — + M2 착수 전 필요.** 위 항목의 결정("게이팅 먼저")에 따라 base에 만들 것이 + `Blocker`가 아니라 **상류 emit을 가로채 정책이 통과 여부를 정하는 공용 게이트 + 노드**로 확정됐다(`Blocker`/`Debounce`/`Throttle`이 그 위의 정책). 사용자 + 스케치는 `Gate(function(emit) return function() ... end end)` 2단 구조이고, + **공개 API로 낸다**(사용자: *"이 API가 비공개일 이유는 없어보인다"*). 남은 것 — + (a) **이름**(사용자: *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의 + 문제"*, 에이전트 권고는 `Gate` 그대로 — `gate`는 이미 장치를 가리키는 명사), + (b) `:Apply` 팩토리인지 독립 생성자인지, (c) `Blocker`가 그 위에 어떻게 + 얹히는지, (d) M2에 `Gate`만 넣을지 `Blocker`까지 넣을지. 상세는 + `research/gate-primitive.md`. +- **⭐ [신설, 2026-08-21 구현 전 QA 5라운드] State 재계산 판정을 "소스 에포크 + 비교"로 바꿀지 — M3 착수 전 필요.** 사용자 제안: 각 State가 자기 상류 루트 + `Source`들의 카운트를 들고 있다가 `Get()` 때 비교해 재계산 여부를 정한다. + 동기는 성능이 아니라 **정확성** — DFS 전파 도중 Observer가 `Get()`을 부르면 + 아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어가는 glitch가 지금 모델에 + 실재한다. 에이전트 분석 결과 진단·방향 모두 타당하고 선례도 있으나 + (MobX/Adapton류 버전 검증), **중복 *통지*는 안 고쳐지고 "선언 안 된 의존성"을 + UB로 명문화해야 한다.** State 내부 표현을 바꾸는 결정이라 M3 뒤로 미루면 + 되돌리는 비용이 크다. 상세는 `research/state-epoch-validation.md`. +- **[해소, 2026-08-21 구현 전 QA 5라운드 `C-4`] `Dispatch.setLength`의 Observer + 앵커 — 물리 target으로 확정(4라운드 `D-56` 역전).** `setLength`가 + `(ownerKey, i, len, anchor)`로 4번째 인자를 받아 **부기 키와 생명주기 앵커를 + 분리**한다. 그래서 `bindLifetime`은 항상 물리 Instance만 상대하고, + `isBoundAlive`의 세 번째 분기(형태 미정으로 열려 있던 것)도 **필요 자체가 + 없어졌다.** 역전 원문은 `archive/bindlifetime-slot-owner-reversed.md`, 지금 + 결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단. 아래는 + 열려 있던 시점의 서술: 그때 확정(4라운드 `D-56`)은 + "`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하라"인데, + 사용자가 그 전제 자체에 의문을 제기했다: *"애초에 Slot 이 effect 나 다른 + 요소들을 소유할 수가 없다 … 실제 observer/effect 는 실제 inst 에 불림 … + 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할 + 부분."* 대안은 **부기 키(`ownerKey`)와 생명주기 앵커(물리 `physicalTarget`)를 + 분리**하는 것 — 그러면 `bindLifetime`은 항상 Instance만 받고, + `isBoundAlive`의 세 번째 분기(지금 ⚠️ 미정)도 통째로 불필요해진다. 상세와 + 트레이싱은 `qa-request/pre-implementation-qa-round5-followup.md`. - **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설, 같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level @@ -196,9 +299,11 @@ claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼 `bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야 하므로), **[2026-08-20 QA 4라운드] 이름도 `groupClaimKeys`로 확정**. - 남은 건 **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey` - 단위인지)와 기존 `nameClaims`와의 공존 방식 — - `base/attribute-plan.md`의 "이름 소유권" 절. + **[해소, 2026-08-21 QA 5라운드 `AT-1`] 키도 `(inst, groupValue) → k`로 확정** + (사용자: *"group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 + 필요가 없고, 그건 key->name 이 유일성을 검증해준다"*), `nameClaims`보다 + **위치 claim을 먼저** 본다 — `base/attribute-plan.md`의 "이름 소유권" 절. + **이 항목은 닫혔다.** - **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가 diff --git a/.claude/research/gate-primitive.md b/.claude/research/gate-primitive.md new file mode 100644 index 0000000..3670c17 --- /dev/null +++ b/.claude/research/gate-primitive.md @@ -0,0 +1,99 @@ +# `Gate` — emit을 가로채는 공용 게이트 노드 (2026-08-21 신설) + +**상태**: research — **방향은 사용자 확정, 정확한 표면이 미정. +[2026-08-21] 사용자 지시로 설계 자체는 다음 세션으로 미룸** — *"고칠것이 많으므로 +Gate 는 다음 세션에 다루겠음. 해당 부분은 정정이 아니고 추가이고, 새 인터페이스를 +고민해야하므로 해결해야할 일로 남겨두길 바람. 단지 지금 세션 상 지식만 이전될 수 +있게 두세요."* 그래서 이 문서는 **다음 세션이 바로 이어받을 수 있게 재료만** +모아둔 상태다(아래 "아직 안 정한 것"이 그 목록). 구현 전 QA +5라운드(`CR-3`/`DT-4`)에서 "M2 전에 게이팅부터 만들 준비를 하고, 실질적 모양을 +정의해야 한다"는 사용자 결정이 나와 신설. 회신 원문은 +`qa-request/pre-implementation-qa-round5-response.md`. + +**한 줄**: `Blocker`가 쓰는 "게이티드 State 노드"를 한 겹 일반화해서, **상류 +emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 공개 프리미티브로 꺼낸다. +`Blocker`/`Debounce`/`Throttle`이 그 위에 얹히는 서로 다른 정책이 된다. + +## 왜 지금인가 — 두 갈래가 같은 자리를 가리켰다 + +1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을 + 전제하므로 M2가 M3의 `Blocker.luau`에 구조적으로 의존한다. 사용자 결정: + **게이팅을 먼저 만든다.** +2. **`DT-4`(Debounce/Throttle)** — 공개 `Blocker` API 위에는 시간 기반 게이트를 + 못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용 + `Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, M3에서 Blocker를 + 만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다. + +**사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가: +*"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐 +후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer +가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가 +되면 이를 전혀 못 지키기 때문."* → 게이트가 **emit 경로 자체에 끼어들어야** +하고, 바깥에서 관측만 해서는 순서를 보장할 수 없다. + +**공개 여부**: 사용자 판단 — *"이 API가 비공개일 이유는 없어보인다."* 즉 +내부 배관이 아니라 공개 프리미티브로 낸다. + +## 제안된 모양 (사용자 스케치 그대로) + +```lua +Gate(function(emit) + -- 이 `emit`은 setup 밖으로 캡처해 **언제든** 부를 수 있다(타이머 콜백 등). + return function() + -- 상류 emit이 도착할 때마다 호출된다. + -- 여기서 `emit()`을 부를지 말지는 정책 마음. + end +end) +``` + +- `setup(emit) -> onUpstreamEmit` 2단 구조. 바깥 함수는 게이트 인스턴스가 + 만들어질 때 1회, 반환된 함수는 상류 emit마다. +- `Blocker`는 이 위의 정책 하나가 된다 — "켜져 있으면 `emit()`을 안 부르고 + 플래그만 세워두고, 꺼질 때 한 번 부른다"(`HasBlockedEmit`이 그 플래그). +- `Debounce`/`Throttle`도 정책 — 타이머를 걸고 창이 끝날 때 `emit()`. + +## 아직 안 정한 것 (사용자 판단 필요) + +1. **⭐ 이름.** 사용자 지적: *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 + 약간의 문제."* — 코퍼스 관례가 `Blocker`/`Modifier`/`Observer`처럼 `-er`가 + 많아서 형태만 맞추면 `Gater`인데 영어로 어색하다. + **에이전트 권고: `Gate` 그대로.** `blocker`/`modifier`와 달리 `gate`는 이미 + **행위자가 아니라 장치를 가리키는 명사**라 `-er`를 붙일 이유가 없다 + (`Source`/`Ref`/`Slot`/`Tween`도 전부 `-er` 없는 명사). 대안 후보: + `Valve`(밸브 — 흐름 제어라는 뜻은 더 정확하지만 코퍼스 어휘와 멀다), + `Relay`(전기 릴레이 — "받아서 다시 보낸다"는 뜻은 맞으나 "중계"로 오독 여지). +2. **`:Apply` 팩토리인가, 독립 생성자인가.** `Debounce`/`Throttle`이 + `state:Apply(Debounce{...})` 관용구로 확정돼 있으므로 `state:Apply(Gate(setup))`가 + 자연스럽다. 그런데 `Blocker()`는 **여러 state에 공유되는 외부 객체**라 모양이 + 다르다 — `Blocker`가 `Gate` 위에 어떻게 얹히는지(`blocker`가 각 gated state마다 + `Gate`를 하나씩 만들어 자기 정책을 심는 형태?)를 같이 정해야 한다. +3. **`Get()`과의 관계.** `Blocker`는 `:Get()`에 영향이 없고(`base/blocker-plan.md`), + `Debounce`/`Throttle`도 emit-gate로 확정됐다(`base/debounce-throttle-plan.md` §4). + `Gate`도 **값이 아니라 통지만 막는다**로 통일하는 게 맞는지 확인 필요 — + 맞다면 "게이트를 통과하지 않은 값도 `:Get()`으로는 보인다"가 공개 계약이 된다. +4. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 — + 지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적, + `debounce-throttle-plan.md` 5-4). `Gate` 자체에 `Flush`/`Cancel` 같은 표면을 + 둘지, 그건 정책(Debounce)만의 것으로 둘지. +5. **재진입.** `Blocker`의 "재진입 의도적 미지원"(`blocker-plan.md`)이 `Gate` + 레벨의 계약으로 올라가는지 — 즉 `onUpstreamEmit` 안에서 같은 게이트의 + `emit()`을 재귀적으로 부르는 경우. +6. **⭐ 소비자가 하나 더 있다 — `Effect(fn, ...deps)`의 최초 1회 억제.** + 2026-08-21 5라운드 `C-6`에서 확정된 다중 의존성 `Effect`는, 의존성마다 구독을 + 걸면 각 구독의 "등록 즉시 1회 실행"이 N번 발화하므로 **설치 구간 동안 발화를 + 눌러뒀다가 마지막에 한 번만 실행**해야 한다(`base/effect-plan.md`의 그 절). + 즉 `Gate`(또는 `Blocker`의 직접 사용)가 **"설치 구간을 감싸 최초 발화를 한 + 번으로 접는" 용례까지 커버해야** 한다 — 설계할 때 이 소비자를 같이 볼 것. +7. **M2 범위.** M2에 `Gate`만 넣고 `Blocker`는 M3에 그대로 둘지, 아니면 + `Blocker`까지 같이 앞당길지. `Dispatch.drive`의 배치 등록이 실제로 쓰는 건 + `blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절), **최소한 + 그 세 메서드가 도는 형태까지는 M2에 필요**하다. + +## 관련 문서 + +- `base/blocker-plan.md` — 현행 `Blocker` 확정(이 문서가 일반화하려는 대상). +- `base/debounce-throttle-plan.md` — "공개 `Blocker` API 위엔 못 얹음" 절이 + `Gate` 일반화를 처음 권고한 자리, 그리고 정책 쪽 설계 전량. +- `base/dispatch-core-plan.md` — "배치 등록을 안전하게 만드는 Blocker 게이팅" + 절이 M2가 실제로 요구하는 표면. +- `ROADMAP.md` M2/M3. diff --git a/.claude/research/state-epoch-validation.md b/.claude/research/state-epoch-validation.md new file mode 100644 index 0000000..1dddfa5 --- /dev/null +++ b/.claude/research/state-epoch-validation.md @@ -0,0 +1,153 @@ +# State 재계산 판정을 "소스 에포크" 비교로 바꾸는 안 (2026-08-21 신설) + +**상태**: research — **사용자 제안, 에이전트 분석 완료, 세부 두 건은 2026-08-21에 추가 확정(중복 통지도 접음 / 선언 안 된 의존성 조항 기각). 채택 자체는 여전히 미정.** +구현 전 QA 5라운드(`SS-2`/`SS-3`)에서 나왔고 사용자 스스로 *"더 생각해볼 +이야기라 백로깅이나 리서치에 들어가야할듯"*이라 함. 회신 원문은 +`qa-request/pre-implementation-qa-round5-response.md`. + +**⚠️ `base/source-state-plan.md`가 여전히 정본이다** — 이 문서는 아무것도 +확정하지 않는다. 다만 **State 내부 표현을 바꾸는 제안이라 M3(=State 구현) +착수 전에 결론이 나야 한다.** + +## 1. 사용자가 지목한 문제 (실재함) + +``` +A ──> B ──┐ + └──> C ──┴──> D +``` + +`A:Set()` 한 번에 대해 지금 모델(push-invalidate + pull-recompute)에서: + +1. `A`가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라 **`B` 쪽 가지가 먼저 + 끝까지 내려간다** — `D`가 `B`를 통해 신호를 받고, 그 아래 Observer가 발화한다. +2. 그 Observer가 `D:Get()`을 부른다. `D`는 상류를 `Get()`하는데, **`C`는 아직 + 신호를 못 받아 `invalid`가 아니다** → `C`는 **옛 캐시를 그대로 반환**한다. +3. `D`는 `(B_new, C_old)`라는 **섞인 값**을 계산해 캐시하고 `invalid`를 끈다. +4. 뒤늦게 `C` 가지의 전파가 도착해 `D`가 다시 무효화되고, Observer가 또 울고, + 그때서야 `(B_new, C_new)`로 교정된다. + +즉 **(a) 한 사이클 안에서 잘못된 값이 한 번 관측되고**(그 값으로 이미 프로퍼티가 +써지는 등 부작용이 나간다), **(b) 같은 계산이 두 번 돈다.** 리액티브 문헌에서 +말하는 전형적인 **glitch**이고, 지금 quad 문서 어디에도 이 현상이 서술돼 있지 +않다. `base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절은 +**중복 재계산이 없다**고만 말하는데, 그 논증은 *"누군가 `d:Get()`을 부르는 +시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에 +발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다. + +## 2. 사용자 제안 (최종 정리형) + +각 State가 **자기에게 영향을 주는 루트 `Source`들의 에포크**를 들고 있다가, +`Get()` 때 그것과 실제 Source의 현재 에포크를 비교해 재계산 여부를 정한다. + +``` +State + sourceList : { [source (weak key)] : count } -- 상류에서 복사, :With에서 합침 + rawInvalid : boolean -- emit이 오면 그냥 true(값싼 캐시) + invalid : rawInvalid가 true일 때만 sourceList를 훑어 "정말 계산이 필요한가" 판정 +``` + +- `emit`은 **발행한 source와 그 count를 같이 전달**한다(값은 여전히 안 싣는다). +- `Source:Set()`/`:Emit()`은 자기 count를 증가시킨다. +- 캐시를 채울 때 그 시점의 각 source count를 같이 기록해두고, `Get()` 때 + 하나라도 다르면 재계산한다. +- count가 싫다면 `[source] -> {}` 처럼 **유니크 테이블 identity**로 같은 판정이 + 가능하다(사용자 대안). + +## 3. 에이전트 분석 — 이게 실제로 무엇을 고치는가 + +**결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라 +`Get()`의 의미론을 바꾸는 결정이다.** + +- **고쳐진다 — 섞인 값.** 위 3단계에서 `D`가 `C:Get()`을 부르면, `C`도 자기 + `sourceList`에서 `A`의 count가 자기가 기록한 것보다 앞선 걸 보고 **신호가 + 아직 안 왔어도 스스로 재계산**한다. 그래서 `D`는 항상 `(B_new, C_new)`를 + 얻는다. **`Get()`이 "지금 이 순간의 일관된 값"을 반환한다는 보장이 처음으로 + 성립**한다. +- **고쳐진다 — 중복 재계산.** 뒤늦게 `C` 쪽 신호가 도착해 `rawInvalid`가 켜져도, + count 비교가 "이미 최신"이라 재계산이 안 일어난다. +- **⭐ [2026-08-21 후속 확정] 중복 *통지*도 같이 접는다.** 처음엔 "값만 + 고쳐지고 통지는 두 번 그대로"로 정리했는데, **사용자 판정으로 통지도 + 같은 장치로 접기로 했다**: *"중복 통지는 단순히, count 목록을 순회해보고 + 이미 모든 소스가 최신이면 무시하는게 맞아보인다. 특히 emit 은 자신 소스를 + 주게 되므로, 자신 소스 카운트만 빠르게 비교가 쉽다. 우리에게 중요한 점은, + 앞단의 변경이 뒤로 흘렀냐일 뿐이라서 해당 방식을 택하는데 있어 문제가 없다."* + - **판정은 O(1)이다** — emit이 `(source, count)`를 실어 오므로 **그 소스 + 하나만** 비교하면 된다(전체 목록 순회가 아님). + - **⚠️ 이건 2026-08-14에 뒤집힌 옛 dedup과 다른 장치다.** 그때 폐기된 건 + *"이미 `invalid`면 더 안 내려보낸다"*로, `:Get()`을 안 부르는 Observer가 + **한 번 울고 영구히 침묵**하는 실패 모드가 있었다 + (`archive/invalidate-dedup-propagation-reversed.md`). 에포크 비교는 그 + 모드가 없다 — 매 `Set`마다 카운트가 **새 값**이라 항상 통과하고, 접히는 건 + **같은 에포크가 두 경로로 도착한 두 번째**뿐이다. + - **그래서 노드가 기록해야 하는 카운트가 둘이다**: 전파 dedup용 + "이 소스의 이 에포크를 이미 내려보냈다"(`seen`)와, 캐시 검증용 "이 값을 + 계산할 때의 에포크"(`computedAt`). 하나로 합치면 전파 시점에 갱신되는 + 쪽이 캐시를 신선한 것으로 오인하게 만든다 — **구현 시 반드시 분리할 것.** + - 비교는 `incoming <= seen[source]`면 삼킨다(단조 증가라 안전). +- **선례가 있다** — 값 자체를 비교하는 게 아니라 **버전/에포크를 비교해 + lazy하게 검증**하는 건 MobX(global state version + observing 검사), + Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준 + 기법이다. Fusion처럼 **그래프를 위상정렬해 eager로 미는 방식**(quad가 이미 + 기각한 것)의 대안으로 자주 쓰인다 — 즉 이 제안은 quad가 이미 택한 + pull 모델과 **결이 같다**. + +## 4. 비용 + +사용자 추산(*"해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해 +인덱싱 하는 정도"*)에 동의한다. 덧붙일 것 둘: + +- `sourceList`의 크기는 **그 노드 상류에 있는 서로 다른 루트 Source의 수**다. + 체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가 + 큰 경우는 드물다. +- `rawInvalid`가 false면 훑지도 않으므로, **정말 안 바뀐 흔한 경로는 지금과 + 같은 비용**(플래그 하나)이다. + +## 5. 열린 질문 (채택 전에 답이 필요) + +1. **[해소, 2026-08-21] 선언 안 된 의존성 — 별도 UB 조항을 만들지 않는다.** + 에이전트가 "새 모델은 더 강한 약속을 하니 예외를 UB로 못 박아야 한다"고 + 제안했으나 **사용자가 기각**: *"그건 아니다. 이 동작으로 인해 이제 정말로 + 항상 state 는 get 이 최신을 던지는게 맞다. 상류의 상태를 물어보므로 그러함. + 이전과 다른게 없다고 생각한다."* — 선언 안 한 Source를 클로저로 읽는 건 + **지금 모델에서도 똑같이 stale**이고 이 변경이 악화시키는 게 없으므로, + 새 조항 없이 기존 "의존성은 선언한다"는 관례 그대로 둔다. +2. **동적 의존성.** 조건에 따라 다른 상류를 읽는 계산이면 `sourceList`가 + 보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다. + 그대로 감수할지 확인. +3. **`Blocker`/`Gate`와의 상호작용.** 게이트는 **통지만** 막고 `Get()`엔 영향이 + 없다는 게 확정 계약이므로(`base/blocker-plan.md`), 게이트 아래에서 `Get()`하면 + 에포크 비교가 "갱신 필요"로 판정해 **막아둔 값이 그대로 보인다**. 지금도 + 같은 동작이지만, 새 모델에선 그게 더 또렷해지므로 문서에 명시할 것. +4. **`:Emit()`(값을 제자리에서 mutate하고 알리는 경로)** — count만 올리면 되므로 + 그대로 동작한다. 확인만. +5. **weak 키.** `[source] -> count`를 weak-key로 두면 source가 죽을 때 항목이 + 사라진다. 다만 `base/relate-plan.md`가 경고하듯 **값이 키를 되참조하면 안 + 된다** — count는 숫자라 문제없다(테이블 identity 방식을 택하면 그 테이블이 + source를 참조하지 않게 할 것). +6. **`Get`의 계약 문구.** 사용자 지적(*"Get 이 항상 최신 상태를 가져온다라는 + 말이 여기서 무력화되는 부분"*)의 방향은 사실 **반대**다 — 지금 모델이 + "최신"을 못 지키고 있었고, 이 제안이 그걸 지키게 만든다. 채택하면 + `base/source-state-plan.md`의 전파 모델 절을 그렇게 다시 써야 한다. + +## 6. 곁가지 — 폴링용 sugar + +사용자 제안: *"폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다. +옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게 +Ref 로 변환해주는 등"*. 즉 "항상 최신 값을 필드로 들고 있는 박스"를 만드는 +순수 슈가. **이건 이 문서의 결정과 독립**이고(에포크를 채택하든 안 하든 쓸 수 +있다), `research/operator-sugar-plan.md`의 콤비네이터 계열에 더 가깝다 — +채택되면 그쪽으로 옮길 것. + +## 7. 권고 + +- **채택 방향에 찬성.** "선제 최적화가 아니라 확정 동작으로의 승격"이라는 + 사용자 판단에 동의한다 — 이건 성능이 아니라 **정확성** 결정이고, State + 내부 구조를 정하는 M3보다 **뒤에 하면 되돌리는 비용이 크다**. +- **[2026-08-21] 에이전트가 붙였던 조건(선언 안 된 의존성을 UB로 명문화)은 + 사용자 기각으로 빠졌다** — §5의 1번 참고. +- **채택 시 같이 확정되는 것: 중복 통지도 접는다**(§3) — 그러면 다이아몬드에서 + 값도 통지도 한 번씩만 간다. 대신 노드가 `seen`/`computedAt` 두 카운트를 + 들어야 한다는 게 구현 요구사항으로 추가된다. +- 실측은 `luau-test`에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서 + (a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 + 사라지는지 대조. diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 073d540..ded2df6 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1653,3 +1653,35 @@ quad-제작 Instance는 **GC 폴백이 없다**는 걸 근거로 보존 주체 분해** — "부모에게 미는 길이는 최종값"과 "부기가 물리보다 먼저"가 한 함수 안에선 동시 만족 불가라는 진단이 근거였고, 사용자가 "지금 의사코드를 건들이는 비용이 추후 실수가 누적되는 비용보다 싸다"로 확정. + +## 2026-08-21-02 — QA 5라운드(문항지·회신·1차 처리) + `Gate`/에포크 리서치 신설 + +원문: `session/2026-08-21-02-qa-round5-and-gate-epoch-research.md` + +4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설 — +**4라운드에 문항이 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, +그리고 문서가 아닌 **실제 커밋된 M1 코드**), **그 이후 확정된 것** +(`Detach`/`KeyGone`/`Owned`/`attachSlot` 분해), **큰 문서의 심화**로 범위를 +좁힌 205문항. 같은 세션에 회신까지 받아 14건 즉시 반영 — `slot._detached` +lazy화, `KeyGone`엔 새 값 반환도 error, **`Owned=false`에서 `Detach`는 +`_detached`에 안 들어감**, 조상 파괴 시 unowned도 같이 죽는다는 계약 신설, +`groupClaimKeys` 키 확정, `Tween:Mapped` 확정, 4라운드가 빠뜨렸던 `E-10` +실반영, **"게이팅 먼저"(M2로 앞당김)**. 새로 열린 두 갈래는 `research/`로 — +공용 **`Gate`** 노드(emit을 가로채는 정책 노드, `Blocker`/`Debounce`가 그 위)와 +**State 에포크 검증**(DFS 전파 중 `Get()`이 섞인 값을 캐시하는 glitch를 +정확성 문제로 다룸). **2차 회신으로 되물은 6건도 같은 세션에 전부 확정** — +`Slot:Replace` 신설(교체가 시프트 2회 → 0회), 물리 조작을 주입 op로 +(`mountInst`/`unmountInst`, base는 `Parent`를 모른다), `rawAdd` 의사코드 신설, +`rawAdd`의 `Length:Set` 제거, **래핑/언래핑 한 쌍**(`wrapElement`/`unwrapElement`), +**`setLength`에 `anchor` 인자**(4라운드 `D-56` 역전 → `isBoundAlive` 세 번째 +분기 항목까지 닫힘), `Effect(fn, ...deps)`(`Ref`도 의존성). **3·4차에선 `mountInst`가 삽입 위치를 못 받는다는 지적에서 +시작해 offset 부기 모델이 정리됨** — `None`의 뜻을 "발행 채널 없음"으로 좁히고 +`Dispatch.getOffsetAt` 신설(pull), 그 과정에 **중첩 offset이 부모 베이스를 못 +받던 결함**(depth ≥ 2에서 통째로 밀림)과 **재마운트가 `Offset` Source를 새로 +만들던 결함**(포탈이 깨지는 자리)까지 발견·수정. **커밋 전 감사 2라운드가 또 실질적인 걸 잡았다** — 확정한 `Owned`가 +`Slot:List` 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, `effect-plan.md`가 +역전 배너 없이 자기모순이던 것, 그리고 **손대지 않은 문서**(`ROADMAP` 백로그 +문단·`debounce-throttle-plan.md`)가 "Gate는 M3에서"로 남아 있던 사각지대. +감사 회신 자리에서 **`raw*`의 index 통일**(오래 열린 캐비엇 종결), **래핑은 +`raw*` 바깥**, **`getOffsetAt` 접두합 캐시**까지 확정. **`Gate`만 사용자 지시로 +다음 세션 — M2를 막는 유일한 항목.** diff --git a/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md b/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md new file mode 100644 index 0000000..3780810 --- /dev/null +++ b/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md @@ -0,0 +1,189 @@ +# 2026-08-21-02 — 구현 전 QA 5라운드(문항지 → 회신 → 1차 처리), `Gate`/에포크 리서치 신설 + +**요약**: 사용자 요청으로 **QA 5라운드 문항지(205문항)** 를 만들고, 같은 +세션에 회신을 받아 **1차 처리까지** 끝냈다. 즉시 반영 14건, 되물은 것 7건, +그리고 **새 research 문서 둘**(`gate-primitive.md`, +`state-epoch-validation.md`)이 나왔다. 처리 결과의 소스는 +`qa-request/pre-implementation-qa-round5-followup.md`. + +## 1. 5라운드가 왜 생겼나 — 4라운드 처리 때의 판단을 뒤집음 + +4라운드 종결 시점엔 사용자 지시(*"이후 stale 만 잡는것으로 끝낼 수 +있어보임"*)로 **5라운드를 안 만들기로** 했고 그 문장이 세 곳 +(`todos.md` 00번, `README.md` qa-request 행, 4라운드 followup H-7)에 +적혀 있었다. 같은 날 사용자가 5라운드를 요청 — *"4차에서 예로 넘어갔던건 +스킵하고, 새로운 부분들이나 다른 깊은 부분을 예가 나와야 정상인 질문들을 +쌓아보자."* → 세 곳 전부 정정하고 문항지를 신설했다. + +**범위를 셋으로 좁힌 게 이번 라운드의 설계**: (1) 4라운드에 문항이 아예 +없던 영역(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 +아니라 실제 커밋된 M1 코드**), (2) 4라운드 회신 **이후** 확정된 것 +(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해), (3) 큰 문서의 +심화(예: `debounce-throttle-plan.md`는 1100줄인데 4라운드 문항이 9개뿐이었음). + +## 2. 문항지 작성 중 이미 잡힌 것 + +문항을 쓰는 과정 자체가 감사가 됐다: + +- **`EF-3`** — 4라운드 followup이 "반영 완료"로 적은 `E-10`(dedup 대칭)이 + `effect-plan.md`에 **실제로는 안 들어가 있었다**. → 일반 교훈으로 + `CR-1`("followup 표를 신뢰 소스로 쓰면 안 된다, 소스는 `base/` 본문")을 + 문항화했고, 사용자가 "예"로 확인해 이번에 실제로 반영했다. +- **`DE-9`** — `KeyGone` 소멸 루프가 새 값을 반환받으면 `settle`의 교체 + 분기로 들어가 `rawAdd(self, result, 0)`(범위 밖 인덱스)로 터진다. +- **`IM-1`** — `architecture.md`는 "지금은 `New()` 미노출 싱글톤"이라는데 + 실제 코드는 `module.New = New` + `return New()`. +- **`CR-2`/`CR-9`** — `Tween:Map` vs `Mapped` 이름과 `isBoundAlive`의 세 번째 + 분기가 **결정이 필요한데 어느 추적 목록에도 없었다.** +- **`DC-9`** — Blocker 게이팅이 `recompute`를 O(N²)→O(N)으로 줄였는데 + `setOffsetSource`의 즉시 계산이 그 자체로 O(N²)라 상쇄된다. + +## 3. 회신으로 확정된 것 (즉시 반영) + +- **`slot._detached`는 lazy** — 모든 Slot이 빈 테이블을 미리 갖지 않는다 + (사용자: *"테이블 생성 비용을 모든 slot 이 가져야하나는 의문 … if 확인으로 + nil 이면 스킵이 훨씬 싸게 먹히지 않는가?"*). `getDetached(slot)` getOrCreate. +- **`prev` 없이 `Detach`를 반환해도 nop** — 사용자가 prev 유무를 추적할 + 의무 없음. +- **`KeyGone`엔 `nil`/`None`/`Detach`만** — prev도 새 값도 error(*"KeyGone 을 + 받은 요소는 오직 … 캐싱 이외의 새로운 마운트나 생성을 거부한다"*). +- **⭐ `Owned = false`에서 `Detach`는 `_detached`에 안 들어간다** — 남의 것에 + 소유권을 유지하는 건 모순이라 `rawUnmount`로 처리하고 다음 `prev`는 `nil`. + 부수로 unowned에선 `Detach`와 `nil`이 같은 동작이 되고 `_detachCleanup`의 + `_owned` 분기가 도달 불가가 되어 삭제됐다. +- **조상이 죽으면 unowned 요소도 엔진 재귀 파괴로 같이 죽는다** — `Owned=false`가 + 약속하는 건 "quad가 안 죽인다"뿐. 이 계약이 코퍼스에 없어서 신설. +- **`groupClaimKeys` 키 = `(inst, groupValue) → k`**, `nameClaims`보다 위치 + claim이 먼저. `Frame { a, a }` 갭이 이걸로 닫힘. +- **`Tween:Mapped`** 확정(`-ed` 관례). +- **⭐ "게이팅 먼저"** — M2가 M3의 `Blocker`에 의존하던 순서 문제를 로드맵 + 순서 유지가 아니라 **앞당기는 쪽**으로 결정. 단 앞당기는 대상이 `Blocker`가 + 아니라 그 아래 공용 `Gate` 노드로 바뀌었다(아래 4절). + +## 4. 새로 열린 것 — research 문서 둘 + +- **`research/gate-primitive.md`** — `DT-4`에서 사용자가 정리: 시간 기반 + 게이트를 공개 `Blocker` API 위에 못 얹는 이유가 **순서 보존**이다 + (*"이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이 + 먼저이냐가 문제"*). 그래서 emit을 **가로채는** 공용 노드가 필요하고, + 공개 API로 낸다. 사용자 스케치는 `Gate(function(emit) return function() + ... end end)`. 남은 건 이름(`Gater`가 어색하다는 지적 — 에이전트 권고는 + `Gate` 그대로), `:Apply` 팩토리 여부, `Blocker`의 얹힘 방식, M2 범위. +- **`research/state-epoch-validation.md`** — `SS-2`/`SS-3`에서 사용자가 + 제기한 **glitch**: DFS 전파 중 Observer가 `Get()`을 부르면 아직 신호를 못 + 받은 다른 가지의 옛 캐시가 섞여 들어간다. 제안은 각 State가 상류 루트 + Source들의 **에포크(count)** 를 들고 `Get()` 때 비교하는 것. 에이전트 + 분석: 진단·방향 타당(MobX/Adapton류 선례), **정확성 결정이지 최적화가 + 아님**, 다만 중복 *통지*는 안 고쳐지고 "선언 안 된 의존성"을 UB로 명문화 + 해야 함. **M3(State 구현) 전에 결론 필요.** + +## 5. 2차 회신 — C절 전량 확정 (같은 세션) + +되물은 6건이 그 자리에서 전부 닫혔다(처리 전량은 followup **F절**): + +- **`Slot:Replace` 신설**(`B-5`) — 사용자가 "교체는 제거+삽입이 아니라 replace가 + 나아 보인다"고 제시. `:List`의 교체가 `spliceArraysDown`+`spliceArraysUp` + 쌍(시프트 2회·`recompute` 2회)에서 **`rawReplace` 한 번(시프트 0)** 으로 + 바뀌었고, 그 부수로 `C-7`(`:Single` 교체 시 Length가 잠깐 줄었다 느는 것)이 + 같이 사라졌다. +- **에포크 안에서 중복 통지도 접기로**(`B-7`) — emit이 `(source, count)`를 + 실어오므로 판정이 O(1). **2026-08-14에 폐기된 옛 dedup과 다른 장치**임을 + 문서에 못 박았다(그건 `invalid` 플래그 기반이라 Observer 영구 침묵 모드가 + 있었고, 에포크는 매 `Set`마다 새 값이라 그 모드가 없다). 구현 요구로 + **`seen`/`computedAt` 두 카운트 분리**가 추가됐다. 에이전트가 붙였던 "선언 + 안 된 의존성을 UB로 명문화" 조건은 **사용자 기각**(*"이전과 다른게 없다"*). +- **물리 조작은 주입 op**(`C-1`) — *"slot 의 해당 동작은 base 이므로 parent 를 + 모른다"*. 의사코드 전체의 `element.Parent = ...`/`element:Destroy()`를 + `mountInst`/`unmountInst`/`disposeInst`로 정정(9곳)하고 경계 절을 신설. + **`rawAdd` 의사코드도 이때 처음으로 문서에 들어갔다.** +- **`rawAdd`의 `Length:Set` 제거**(`C-2`) — `Length`는 `recompute`만 쓴다. +- **래핑/언래핑을 Slot 전체 연산으로**(`C-3`) — `wrapElement`/`unwrapElement` + 한 쌍 + 래퍼의 `_wrapped` 역참조. 사용자가 예상한 대로 `wrappers[key]` / + `mounted[key]` 분리가 **필요 없어졌다**. +- **`setLength`에 `anchor` 인자 신설**(`C-4`) — 부기 키와 생명주기 앵커 분리. + 4라운드 `D-56`이 역전돼 `archive/bindlifetime-slot-owner-reversed.md`로 갔고, + 형태 미정으로 열려 있던 **`isBoundAlive` 세 번째 분기 항목이 같이 닫혔다.** +- **`Effect(fn, ...deps)` 확정**(`C-6`) — `Ref`도 의존성이 될 수 있고, 최소 + 1회 실행(useEffect 동일), trailing lazy 위치 인자, `Ref`는 `Set`될 때만 발화. +- **`Gate`만 다음 세션으로** — *"고칠것이 많으므로 … 지금 세션 상 지식만 + 이전될 수 있게 두세요."* 그래서 `research/gate-primitive.md`는 재료만 모아둔 + 상태로 두고 `question.md`/`todos.md`에 **M2를 막는 유일한 항목**으로 남겼다. + +## 6. (1차 시점 기록) 되물었던 것 + +파급 큰 순서로: **`rawAdd`의 `Length:Set` 제거**(지금 서술이 "기여도 합" +정의와 충돌하고 `recompute`와 이중 기록), **`updateFn`이 State를 반환할 때의 +래핑/`prev` identity**(래핑이 공개 `Slot:Add`에만 있어 reconcile 경로엔 +없다는 실제 갭), **`setLength`의 Observer 앵커를 물리 target으로 되돌리기** +(사용자 문제 제기 — *"우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지"*, +되돌리면 4라운드 `D-56`의 백엔드 요구사항과 `isBoundAlive` 세 번째 분기가 +통째로 불필요해짐), `rawAdd` 의사코드 초안 승인(문서에 정의가 아예 없었음), +`Gate` 이름·표면, `Effect`의 다중 의존성(`Ref` 포함) 안. + +## 6-1. 3·4차 — `mountInst`의 삽입 위치에서 시작해 offset 모델이 정리됨 + +2차까지 끝난 뒤 사용자가 `mountInst`/`unmountInst`가 index를 안 받는 걸 문제 +삼았다(*"웹에서는 어떻게 되냐가 모호함. 어디 둘지 어떻게 아느냐는것"*). 이 +질문이 결국 offset 부기 모델 전체를 정리하게 만들었다: + +- **`None`이 두 뜻을 겸하고 있었다** — 정의는 "실제 마운트를 하지 않는 위치"인데 + **plain 요소가 `None` + `setLength(1)`로 등록**되고 있었다. 그래서 그 자리의 + offset 숫자가 계산조차 안 됐고, DOM류 백엔드가 삽입 위치를 알 방법이 없었다. + → `None`의 뜻을 **"발행 채널 없음"**으로 좁히고(참여 여부는 `lengthList`가 답), + 숫자가 필요한 쪽은 **`Dispatch.getOffsetAt(ownerKey, i)`로 pull**한다. +- **⭐⭐ 그 자리를 파다 별개 결함 발견 — 중첩 offset이 부모 베이스를 못 받았다.** + `recompute`가 `sum = 0`으로 시작하고 `ownerKey.Offset`을 읽는 자리가 없어서, + **depth ≥ 2에서 자식 offset이 부모 베이스만큼 통째로 밀려 있었다**(depth 1만 + 쓰던 동안 로컬==절대라 안 드러남). → `base`를 시드하고, 중첩 Slot이 자기 + `Offset`을 관측해 자식 offset을 다시 미는 구독을 추가. +- **⭐ 반영하다 하나 더 — 재마운트가 `Offset` Source를 새로 만들고 있었다.** + 언마운트가 `slot.Offset`을 일부러 보존하는 이유(`SL-75`/`DC-6`: 이미 렌더된 + 요소들이 그 Source를 **구독한 채 딸려 나감**)가 재마운트에서 무너지고 있었음 + → `slot.Offset or Source(0)`으로 identity 재사용. +- **에이전트 제안(`bk.offsetList` push)은 사용자안(pull + `getOffsetAt`)에 + 밀려 폐기**됐고, 이어서 **`bk.base` 필드도 사용자 지적으로 걷어냈다** + (*"이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데"* — 같은 값을 두 곳에 두는 + 중복 상태). `isSlot` 분기는 남는데, 그건 타입 분기가 아니라 **duck-typing이 + 금지돼 있어서**(Roblox userdata의 미정의 키 인덱싱 에러, `brand-plan.md`) + 브랜드 검사가 유일한 안전 경로이기 때문. + +**이 라운드의 패턴**: 세 결함 전부 *"사용자가 표면적인 질문 하나를 던졌더니 그 +아래에서 나왔다"* — `mountInst`의 인자 하나가 offset 모델의 두 결함을 끌어냈다. + +## 6-2. 커밋 전 감사 2라운드 — 실제 결함 다수 + +핸드오버 준비로 `quad-doc-auditor`를 각도를 바꿔 두 번 돌렸다(1: diff와 그걸 +인용하는 base / 2: 인덱스 레이어·히스토리). **둘 다 실질적인 걸 잡았다.** + +- **⭐ `Owned`가 코드에 도달하지 못하고 있었다** — `settle`/`destroySlotTree`/ + `releaseElement`가 `self._owned`를 9곳 넘게 읽는데 `Slot:List`/`Slot:Single` + 의사코드가 **`opts` 인자를 안 받고 있었다.** 확정한 옵션이 **문서 안에서 + 배선이 끊긴 채** 있었던 셈. +- **⭐ `effect-plan.md`가 자기 자신과 모순** — 최상단 시그니처와 "trailing args + sugar는 의도적으로 안 만듦" 확정 문단이 그대로인 채 `Effect(fn, ...deps)` + 절이 추가돼 **역전 배너 없이 정반대 두 서술이 공존**하고 있었다. +- **⭐ 손대지 않은 문서의 사각지대** — `ROADMAP.md` 백로그 문단과 + `debounce-throttle-plan.md`가 "Gate 추출은 M3에서"라고 여전히 말하고 있었다 + (이번에 M2로 앞당겨졌는데 diff에 안 잡히는 파일이라 그대로 남음). 이건 + `conventions.md`가 경고하는 "변경한 세션 자신은 자기가 뭘 안 건드렸는지 + 모른다"의 교과서적 사례. +- **⭐ 인덱스에 옛 결론이 남음** — State-에포크의 "중복 통지는 안 고쳐진다 / + UB 명문화 필요"가 같은 날 정반대로 확정됐는데 `question.md`/`README.md`만 + 안 따라왔다. +- 그 외: `indexOfRaw` 미정의, `mountSlotTree`의 전제 미명시, `Replace`의 + `destroyOld` 미서술, `Gate` 이름이 용어 대기열에 없음, 주입 op 목록 미갱신, + M2 체크박스 문장 깨짐 등. + +**그리고 감사 회신 자리에서 결정 셋이 더 확정됐다** — `raw*`를 **index로 통일** +(오래 열려 있던 캐비엇 종결), **래핑은 `raw*` 바깥**, 그리고 `getOffsetAt`의 +**접두합 캐시**(`offsetDirtyFrom`). offset 물리 재배치 질문도 "DOM은 insert가 +알아서 밀어낸다"로 닫혔다. + +## 7. 남긴 파일 + +- `qa-request/pre-implementation-qa-round5.md` — 문항지(205문항) +- `qa-request/pre-implementation-qa-round5-response.md` — 회신 원문 +- `qa-request/pre-implementation-qa-round5-followup.md` — **처리 결과의 소스** + (A~E절 1차, **F절이 최신**) +- `archive/bindlifetime-slot-owner-reversed.md` — 역전된 `D-56` 원문 +- `research/gate-primitive.md`, `research/state-epoch-validation.md` diff --git a/.claude/todos.md b/.claude/todos.md index 276687b..bc7ce01 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -5,8 +5,10 @@ (`.claude/question.md`, `luau-test/STATUS.md` 등). -00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~4라운드 전부 `base/` - 반영 완료로 종결. 열린 항목 없음.** +00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~5라운드 전부 `base/` + 반영 완료. 열린 항목은 **`Gate`(공용 게이트 노드) 하나뿐**이고, 그게 지금 + **M2 착수를 막는 유일한 항목**이다**(사용자 지시로 설계는 다음 세션 — + 재료는 `research/gate-primitive.md`). **4라운드 — [2026-08-21] 종결.** 문항지는 `.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은 @@ -16,8 +18,31 @@ **`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해** (`materializeSlotTree` + `mountSlotTree`, 근거는 `research/slot-attach-decomposition.md`)가 전부 확정·반영됐다. - **5라운드 문항지는 만들지 않는다** — 사용자 지시("이후 stale 만 잡는것으로 - 끝낼 수 있어보임"). 아래는 그 회신 전 서술: + **[2026-08-21 정정] 5라운드 문항지를 만들었다** — 4라운드 처리 때는 사용자 + 지시("이후 stale 만 잡는것으로 끝낼 수 있어보임")로 안 만들기로 했으나, 같은 + 날 사용자가 5라운드를 요청("4차에서 예로 넘어갔던건 스킵하고, 새로운 + 부분들이나 다른 깊은 부분")해 `qa-request/pre-implementation-qa-round5.md`를 + 신설했다(**회신 대기**). 범위는 셋 — (1) 4라운드에 문항이 아예 없던 영역 + (`base/project-setup-plan.md`/`base/quad-types-plan.md`, 그리고 문서가 아니라 + **실제 커밋된 M1 코드**), (2) 4라운드 회신 이후 새로 확정된 것 + (`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해), (3) 큰 문서의 심화. + 문항 수와 분포는 그 문서 자신이 소스. + **[2026-08-21 후속] 5라운드 회신 도착·처리 완료**(라운드 수는 여기서 안 셈) — 회신 원문은 + `-round5-response.md`, 처리 결과의 소스는 **`-round5-followup.md`**. + 즉시 반영된 것은 `slot._detached` lazy화, `KeyGone`에 새 값 반환도 error, + **`Owned = false`에서 `Detach`는 `_detached`에 안 들어감**, 조상 파괴 시 + unowned 요소도 같이 죽는다는 계약 신설, `groupClaimKeys` 키 확정 + (`(inst, groupValue) → k`), `Tween:Mapped` 이름 확정, 4라운드가 반영을 + 빠뜨렸던 `E-10` dedup 대칭 결론 실반영, 그리고 **"게이팅 먼저"(M2로 앞당김)**. + **아직 회신 대기인 것은 그 followup의 C절**(`rawAdd` 의사코드 승인, + `rawAdd`의 `Length:Set` 제거, `updateFn`이 State를 반환할 때의 래핑/`prev`, + `setLength` 앵커를 물리 target으로 되돌리기, `Gate` 이름·표면, `Effect` + 다중 의존성) — **[2026-08-21 2차 회신으로 전량 처리 완료]**, 소스는 그 + 파일의 **F절**. 그중 **`Gate`(공용 게이트 노드) 설계만 사용자 지시로 다음 + 세션으로 미뤄졌다**(*"고칠것이 많으므로 Gate 는 다음 세션에 다루겠음 … + 지금 세션 상 지식만 이전될 수 있게 두세요"*) — 재료는 + `research/gate-primitive.md`에 모아뒀고 **M2 착수를 막는 유일한 항목**이다. + 아래는 4라운드 회신 전 서술: **(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로 diff --git a/ROADMAP.md b/ROADMAP.md index b83df25..60ce6e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -207,9 +207,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M3 체크박스 참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라 이 게이트를 그대로 탐 -- [ ] `Dispatch.setLength(inst,i,len:number|State)`/ - `Dispatch.setOffsetSource(inst,i,offset:Source|None)` — - array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브 +- [ ] `Dispatch.setLength(ownerKey,i,len:number|State,anchor?)`/ + `Dispatch.setOffsetSource(ownerKey,i,offset:Source|None)`/ + **`Dispatch.getOffsetAt(ownerKey,i)`** — + **[2026-08-21 구현 전 QA 5라운드 반영]** `setLength`의 4번째 인자 + `anchor`(생명주기 앵커, 항상 물리 Instance — 부기 키와 분리, **생략 시 + `ownerKey`로 폴백**이라 최상위 호출부는 3-인자 그대로), + `setOffsetSource`는 `None`이면 얼리 리턴(그 `None`은 "발행 채널 없음"이지 + "참여 안 함"이 아니다 — 참여 여부는 `setLength`가 답), + 숫자가 필요한 쪽(예: 물리 삽입 위치)은 `getOffsetAt`으로 pull. + `recompute`는 owner의 베이스(Slot이면 자기 `.Offset`, 최상위면 0)에서 + 시작하고 중첩 Slot은 자기 `Offset`을 관측해 자식 offset을 다시 민다. + **이 셋이 하는 일**: array part 형제 순서 보장(Length/Offset 누적합→ + `LayoutOrder` 리액티브 바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략 UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 @@ -227,11 +237,20 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만 호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에 있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는 - `Blocker`를 참조하게 됨.** M2 착수 전 `Blocker`의 최소 표면 - (`On`/`Off`/`IsOn`/`OffWithoutEmit`)을 M3보다 먼저(또는 M2와 병행) - 만들 필요가 있는지 사용자 판단 필요 — + `Blocker`를 참조하게 됨.** + **✅ [해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] "게이팅 먼저"로 + 결정 — 게이팅을 M2로 앞당긴다**(사용자: *"게이팅 먼저. 게이팅을 + base 에 만들 준비를 해야한다. 실질적 모양 정의가 필요함"*). 단 + **앞당기는 대상이 `Blocker` 자체가 아니라 그 아래의 공용 `Gate` + 노드**로 바뀌었다(같은 라운드 `DT-4` — 시간 기반 게이트는 공개 + `Blocker` API 위에 못 얹히므로, emit을 가로채는 노드를 한 겹 + 일반화해 `Blocker`/`Debounce`/`Throttle`이 그 위의 정책이 되게 + 한다). **표면/이름은 아직 미정** — `research/gate-primitive.md`가 + 소스이고 `question.md` 3번에 열린 항목으로 올라가 있다. 최소한 + 배치 등록이 실제로 쓰는 `On`/`IsOn`/`OffWithoutEmit`이 도는 + 형태까지는 M2에 필요하다. 경위는 `qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤 - 정합성" 절. + 정합성" 절과 `pre-implementation-qa-round5-followup.md`. - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 @@ -321,6 +340,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `luau-test`의 `15-type-compute-trailing-deps-typepack.luau`로 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 되면 동종 타입 dep 1개로 한정) +- [ ] **[2026-08-21 5라운드 — 순서 주의]** 아래 `Blocker.luau`가 올라서는 + **공용 게이트 노드는 M2에서 이미 만들어져 있다**("게이팅 먼저" 결정, + 위 M2 각주 + `research/gate-primitive.md`). M3에서 `Blocker`를 짤 때는 + 그 노드를 **다시 만들지 말고 그 위의 정책으로** 얹을 것. +- [ ] **[2026-08-21 5라운드 — 착수 전 확인]** State 재계산 판정을 "소스 + 에포크 비교"로 바꿀지가 **미정인 채 열려 있다**(`research/state-epoch-validation.md`, + `question.md` 3번). 채택하면 **State 내부 표현이 바뀌므로**(노드가 + `seen`/`computedAt` 두 카운트를 들고, `Get`이 상류 에포크를 검증) + 아래 `Source.luau`/`State.luau`를 짜기 **전에** 결론이 나야 한다 — + 그 문서 자신이 "M3 뒤로 미루면 되돌리는 비용이 크다"고 경고한다. - [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) @@ -497,7 +526,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔 교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개 mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/ - `IndexOf`는 순수 읽기라 가드 대상 아님). base/roblox 경계에 + `IndexOf`는 순수 읽기라 가드 대상 아님). + **[2026-08-21 5라운드]** `Replace(index, newElement)` 추가(그 자리 교체 + + 이전 것 **파괴** — `Extract`의 파괴 짝, `Remove` ↔ `Extract`와 같은 축)와 + `rawReplace`/`rawAdd` 의사코드 확정, 그리고 **래핑/언래핑 한 쌍** + (`State`를 요소로 받으면 내부적으로 `:Single` 래퍼 Slot이 되는데, + `Get`/`IndexOf`/`Extract`가 돌려주는 값과 `:List`의 `prev`는 전부 + **언래핑된 원래 값**이다). base/roblox 경계에 mount/unmount 외 reposition 훅 추가됨. **`Slot()` 제네릭화, 요소 타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/ @@ -598,6 +633,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/ `removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은 불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절). + **[2026-08-21 5라운드]** 그 web 경로가 실제로 삽입 위치를 알 수 있도록 + 물리 조작이 주입 op(`mountInst(target, element, index)`/`unmountInst`/ + `disposeInst`)로 정리됐고, 중첩 offset이 부모 베이스를 못 받던 결함과 + 재마운트가 `Offset` Source를 새로 만들던 결함도 같이 수정됐다. **`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로 해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).** **[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** — @@ -970,9 +1009,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 시간 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`) — 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을 추가하지 않는 **순수 슈가**로 확인됨(`Blocker`의 gated state + `Ref` + - 아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **M3에서 `Blocker`를 - 구현할 때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할 - 것**(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함). + 아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **[정정, 2026-08-21 5라운드] + 그 공용 `Gate` 추출은 M3가 아니라 M2로 앞당겨졌다** — "게이팅 먼저" + 결정(위 M2 각주)으로 `Dispatch.drive`의 배치 등록이 쓰는 게이팅부터 + 만들기로 했고, 그때 `Blocker`/`Debounce`/`Throttle`이 공유할 노드를 + 같이 빼둔다(따로 하면 같은 설계를 두 번 함). 표면/이름은 아직 미정 — + `research/gate-primitive.md`. 프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음. 주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`, Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라