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