diff --git a/.claude/README.md b/.claude/README.md index 9e895ed..57751e6 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,7 +24,7 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — 1~3라운드와 달리 **작성만 끝났고 사용자 회신 대기 중**, 회신 후 "아니오"만 남긴 근거 기록으로 재편 예정. 문항 수/문서별 분포는 그 파일 자신이 소스). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — 1~3라운드와 달리 **작성만 끝났고 사용자 회신 대기 중**, 회신 후 "아니오"만 남긴 근거 기록으로 재편 예정. 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20]** 그 회신 처리 결과 — 바로 반영한 것 / 설명이 부족해 풀어 쓴 재질문 / 사용자 판단이 더 필요한 것 / 업스트림 병합으로 stale해진 문항으로 갈라 정리, **B·C절 회신 대기**). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `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` | diff --git a/.claude/qa-request/pre-implementation-qa-round4-followup.md b/.claude/qa-request/pre-implementation-qa-round4-followup.md new file mode 100644 index 0000000..819173e --- /dev/null +++ b/.claude/qa-request/pre-implementation-qa-round4-followup.md @@ -0,0 +1,659 @@ +# 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문 + +**상태**: **[2026-08-20] 1차 처리 완료, 아래 B/C절 회신 대기.** + +**입력**: `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 그 +파일이 소스이고 여기서 전문을 반복하지 않음). 이 문서는 그 회신을 (a) 바로 +반영한 것, (b) 설명이 부족해 다시 풀어 쓴 것, (c) 사용자 판단이 더 필요한 것, +(d) 조사해서 답이 나온 것으로 갈라 정리한 것이다. + +**같이 일어난 일 — 업스트림 스캐폴딩 병합.** 문항지를 쓰는 동안 업스트림에 +M0 스파이크 검증 + M1 스캐폴딩이 12커밋 올라와 있었고, 이 세션에서 `pull` 후 +문항지 커밋을 그 위로 rebase했다. **그래서 문항지 일부가 이미 stale하다** — +어느 문항이 그런지는 아래 E절. + +--- + +## A. 반영 완료 — 바로 고친 것 + +전부 이번 세션에 `base/`(+`HUMAN_TODO.md`/`question.md`/`todos.md`)에 +반영했고 `doc-check.py` ERROR 0을 유지했다. **회신에서 판단이 명확했던 것만** +손댔고, 조금이라도 애매하면 아래 B/C로 뺐다. + +| 항목 | 무엇을 고쳤나 | 대상 | +|---|---|---| +| `LP-1` | `Connected`는 "계산된 속성"이 아니라 그냥 `RBXScriptConnection`의 네이티브 필드. 실제 판정은 "gcconn 없음" / "있는데 `Connected==false`" 두 상태뿐이고, quad가 참조를 `nil`로 끊는 자리는 없음. rbvm에서 가져오는 건 gcconn 관용구뿐이고 `__index` 계산 속성 구현은 **안 가져옴** | `lifecycle-pattern.md` | +| `LP-2` | "예상보다 적을 수 있다"가 아니라 **지금은 정확히 `Effect` 하나뿐** | `lifecycle-pattern.md` | +| `LP-4` | 커스텀 Destroy-time 처리의 정상 경로는 `[Event "Destroying"]` 직접 바인드가 아니라 **`Effect`(+슈가 `OnDestroyed`)** | `lifecycle-pattern.md` | +| `D-56` | **`bindLifetime`의 첫 인자가 물리 Instance가 아닐 수 있다**(Slot-in-Slot의 `ownerKey`)를 백엔드 요구사항으로 신설. gcconn 트릭이 안 통하는 이유와, `isBoundAlive`에 세 번째 분기가 필요하다는 것까지 명시 | `lifecycle-pattern.md` | +| `D-8`/`ML-9` | `Dispatch.listHandlers()`는 `Quad.debug`와 무관하게 **항상 호출 가능**(목록을 반환만 하고 스스로 출력 안 함). 게이팅 대상은 "라이브러리가 스스로 콘솔에 쓰는" 동작뿐 | `dispatch-core-plan.md`, `module-lifecycle-plan.md` | +| `D-60`/`SL-75` | `Slot.Offset`은 마운트 전에도 `nil`이 아니라 **`0`**, 언마운트해도 **`nil`로 되돌리지 않음**. `nil`로 갈아치우면 그 Source를 이미 구독 중인 다운스트림이 영구히 끊겨 **포탈이 깨짐** | `slot-plan.md`, `dispatch-core-plan.md` | +| `SL-74` | `SetAndDispose`는 **`source:SetAndDispose(value)` 콜론 메서드로 확정**(`:Set`과 한 세트). `Apply` 오버라이딩은 `Source`→`State` 단방향 때문에 타입이 안 성립해서 애초에 불가 → `state:Apply` 시그니처 영향 없음, 열린 항목에서 제거 | `slot-plan.md`, `question.md`, `todos.md` | +| `SL-4` | 예시를 `slot:Add(Frame { Ref = myRef })` → **`MyComponent { Ref = myRef }`** 로. `Frame`은 코퍼스에서 인스턴스 리터럴을 가리키는 이름이라 정반대 오해를 부름 | `slot-plan.md` | +| `SL-72` | 조기 해제의 사용자 경로는 `unbindLifetime`이 **아님**(그건 Handler 작성자용 내부 배관) — `State`에 `nil`을 emit하는 게 정상 경로 | `slot-plan.md` | +| `E-10` | dedup 대칭 **확인 완료** — `relate`로 이전 값을 들고 `old ~= v` / `nextValue ~= v` 두 분기 **안**에서만 bind/unbind(+내부 Observer cascade)가 일어나므로 성립. 남은 건 구현 시 회귀 확인뿐 | `effect-plan.md` | +| `E-11` | leaf 바인딩된 `EffectHandle`엔 `:Unsubscribe()`가 **아예 안 먹는 것**으로 Observer와 통일. 옛 "(3) 이후 leaf가 죽어도 중복 호출 안 됨"은 이중 바인딩 게이트상 **성립할 수 없는 문장**이라 삭제 | `effect-plan.md` | +| `M-5` | **`None`만이 명시적 unsetter** — `mod:X(nil)`은 "그 필드가 없는 새 Modifier", `mod:X(None)`은 "`None`으로 채워진 Modifier". 차이가 `Overridden`/`Peek`에서 관측된다는 것까지 예시로 | `modifier-plan.md` | +| `M-9` | "Getter는 안 만든다"와 `:Peek`의 관계 명시(전자는 **필드별 setter 짝 getter**, `:Peek`은 키를 받는 범용 접근자라 모순 아님) | `modifier-plan.md` | +| `BK-9` | `HasBlocked`는 "영원히 안 만듦"이 아니라 **백로그** — 신설된다면 이름은 `HasBlockedState` 쪽 | `blocker-plan.md` | +| `R-11` | 등록은 선형 탐색 함수가 아니라 그냥 **`table.insert`**(구멍을 알아서 되찾아 씀). 단 "`#t`가 항상 첫 `nil` 자리"는 Lua 명세상 보장이 아니므로 **실측 스파이크 항목 추가** | `ref-plan.md` | +| `AT-11` | 위치별 claim 레지스트리 이름 **`groupClaimKeys`로 확정**(키 설계는 여전히 미정) | `attribute-plan.md`, `question.md` | +| `AT-20` | 생존 이름 최적화는 "부품이 늘어나서 안 하는" 게 아니라 **원리적으로 불가능** — 이름이 같아도 값이 바뀌었을 수 있고, 값을 비교하려면 `:Get()`이 필요한데 그건 State 계약 위반 | `attribute-plan.md` | +| Attribute 생성자 | `Attribute(a, b, ...)` **자신의 이름 겹침 정책이 어디에도 없었음** — `Merged`(error)/`Overridden`(뒤가 이김)만 적혀 있었다. 생성자는 **뒤가 이김**으로 명시 | `attribute-plan.md` | +| `TW-16` | `initValue`가 "에이전트 범위 밖"인데 `HUMAN_TODO.md`에 항목이 없던 것 — **10번으로 신설** | `HUMAN_TODO.md` | +| `UI-5` | **해소** — `process` 위임을 하는 이상 gcconn/gchold 없으면 옵저버 바인딩부터 실패하므로 "조용히 미아"가 아니라 **즉시 드러나는 전제 조건**. `ensureManagedChild`가 일반 인스턴스 생성과 같은 경로를 타야 한다는 계약으로 승격 | `ui-shorthand-plan.md` | +| `UI-8` | `mapTweenValue` 로컬 헬퍼를 **`Tween:Map(fn)` 공개 메소드로 승격** | `tween-plan.md`, `ui-shorthand-plan.md` | +| `UI-11` | 자식 파괴 시 `retractFrom(child, prop, 1)`은 **"정석"이 아님** — 엔진이 Tween을 알아서 정리하고 PropertyHandler의 retractor는 애초에 no-op이라 두 겹으로 무의미 | `ui-shorthand-plan.md` | +| `LH` 백로그 | 진짜 `componentDidMount`(조상 체인까지 이어진 뒤)는 **백로그로 신설** — 자기 위를 알아야 해서 `drive` 한 콜스택 안에서 표현 불가, 사용 사례가 나오면 재검토 | `lifecycle-hooks-plan.md` | +| `D-3`/`D-10`/`LH-8` | 설명이 어렵다는 지적을 받아 **코드/다이어그램으로 풀어 씀**(아래 B절에 같은 설명 재수록) | 각 문서 | + +**부수로 확인한 것 — `TW-12`는 이미 맞게 적혀 있었음.** 문항이 +`CanAnimate`를 "단순 boolean"으로 축약한 게 문제였고, `tween-plan.md`는 +처음부터 `CanAnimate: State | boolean | nil`로 적어두고 `resolve`로 +`:Get()`한다 — **문서 정정 불필요, 문항만 부정확**했다. + +--- + +## B. 재질문 — 설명이 부족했던 것 (풀어 쓴 답 + 재확인) + +전부 base 문서에도 같은 설명을 반영해뒀다. **아래 설명이 맞는지만 +확인해주면 되고**, 어긋나면 그 지점을 알려주면 문서까지 같이 고친다. + +### B-1. `D-3` — "깊은 인덱스"가 뭔지, 그리고 철거 방향 + +**질문**: *"방향이 그게 맞나? … 실제 process 에선 retract 가 인덱스 1 부터 +5, 6, ... 순으로 작동함. 그런데 달라질 때 5, 4, 3, 2 ... 순이 되는건 아니지? +'깊은 인덱스' 라는게 무슨 의미?"* + +**답**: **"깊다" = 인덱스 숫자가 크다**는 뜻이다(트리 깊이가 아니라 **같은 +`(inst,k)` 체인 안에서의 재귀 깊이**). 그리고 **"5,4,3,2 순이 되는 게 +맞다."** + +``` +(inst, k) 체인 — State> 예시 + index 1 : StoreBind ← 바깥 State 구독. "얕음" + index 2 : StoreBind ← 안쪽 State 구독(1이 재귀로 만든 것) + index 3 : TagHandler ← 최종 Tag를 실제 반영. "깊음" +``` + +- **설치(`Dispatch.process`)는 1 → 2 → 3** — 사용자가 짚은 그대로. 각 + 레벨이 값을 한 겹 벗겨 `index + 1`로 재귀하므로 **인덱스가 커지는 방향**. +- **철거(`Dispatch.retractFrom`)는 그 반대인 3 → 2 → 1** — 의사코드가 + `for i = #list, index, -1`로 **꼬리부터 역순**으로 돈다. +- **왜 반대여야 하나**: index 2가 index 3을 *만들어낸* 주체다. 만든 쪽을 + 먼저 지우면 만들어진 쪽을 정리할 주체가 사라진다 — 스택을 쌓은 역순으로 + 푸는 것(LIFO)과 같은 이유. 그래서 각 retractor는 자기가 만든 하위 인덱스를 + 쫓아갈 필요가 없다(자기 차례엔 아래가 이미 비어 있음). +- **주의**: 이건 **한 `(inst,k)` 체인 안**의 이야기다. 서로 다른 키는 완전히 + 별개 배열이고 서로의 순서와 무관하다. + +→ **이 이해가 맞나?** + +### B-2. `D-10` — Luau 일반화 `for`는 이미 지원하는데 뭘 검증한 건가 + +**질문**: *"루아우의 일반화된 반복 for 이 이를 그냥 지원함. 어떻게 실제로 +이해했는지 확인 필요"* + +**답**: **맞다 — Luau가 배열 파트를 먼저 훑는다는 걸 의심한 적은 없다.** +문항이 "M0에서 순회 동작을 검증한다"고 적어 마치 언어 동작을 의심하는 +것처럼 읽힌 게 문제였다. 실제 구분은 이렇다: + +- **언어가 주는 것**: 일반화 `for`가 배열 → 해시 순으로 돈다. 2026-08-07에 + 사용자가 REPL로 직접 확인한 관찰. +- **그런데도 드라이버가 두 패스를 명시하는 이유는 순서를 못 믿어서가 아니라** + (1) **이식성** — 다른 백엔드가 props를 Lua 테이블이 아닌 자료구조로 + 표현하면 그 근거 자체가 없어짐, (2) 어차피 숫자 키/문자열 키를 다른 + 의미로 처리해야 해서 **구분 비용이 이미 들고**, 순서 고정이 거의 공짜. +- **스파이크 `01`이 검증한 것**: 언어 동작이 아니라 **"우리가 짠 두 패스 + 드라이버가 계약대로 도는가"**. 그 스파이크 주석도 "사용자가 이미 REPL로 + 확인했었지만(우연한 관찰), base는 이 우연한 동작에 기대지 않고 … 명시적으로 + 강제하기로 확정함"이라고 같은 구분을 적어두고 있다. +- **[업스트림 반영] 이미 통과했다** — `luau-test/done/01-two-pass-array-hash-order.luau`. + 문항의 "M0에서 검증할 항목이다"는 **stale**이라 base 문서에서 닫았다. +- **`nil`-hole로 배열 파트가 통째로 해시 취급이 되는 문제**는 이것과 별개이고 + `02`/`06` 스파이크가 담당한다. + +→ **이 구분이 맞나?** + +### B-3. `D-32` — "재위임 핸들러는 (A) 분기에서도 반드시 다시 재위임"이 무슨 말인가 + +**질문**: *"무슨말인지 확인 필요"* + +**답**: **"조건부로만 재위임하는 핸들러를 새로 만들면 고아 체인이 생긴다"**는 +경고다. 지금 존재하는 핸들러엔 위반 사례가 없어서 추상적으로만 적혀 있었다. + +가상의 위반 예시로 설명하면: + +```lua +-- ⚠️ 이런 핸들러를 새로 만들면 위험하다는 뜻 +function MaybeWrapHandler.process(inst, k, v, index) + if v.enabled then + Dispatch.process(inst, k, v.inner, index + 1) -- 재위임함 + end + -- enabled가 false면 아무것도 안 함 ← 여기가 문제 + return function() end +end +``` + +- 1차 사이클에 `v.enabled == true` → index 2에 하위 체인이 설치됨. +- 2차 사이클에 같은 핸들러로 `v.enabled == false`가 오면 **(A) 분기**를 탄다 + (핸들러가 같으므로 `retractFrom`이 안 불림 — (A)는 **아래를 안 건드리는 게 + 핵심**). +- 그런데 이번엔 재위임을 안 했으므로 **index 2에 옛 하위 체인이 그대로 + 남는다.** 아무도 그걸 지우지 않고, 옛 값에 대한 구독/부작용이 계속 산다. +- **해법**: 재위임을 건너뛰는 그 자리에서 + `Dispatch.retractFrom(inst, k, index + 1)`을 직접 불러 아래를 비운다. + +`StoreBind`/`NoneHandler`는 **항상** 재위임하므로 이 함정에 안 걸린다 — +그래서 "지금 위반 사례는 없다"고 적혀 있는 것이다. + +→ **이 설명이 의도한 내용이 맞나?** (맞다면 base 문서에도 이 예시를 넣겠다 — +지금은 규칙만 있고 예시가 없어서 읽고 이해가 안 되는 게 정상이다.) + +### B-4. `BR-7` — duck-typing 기각 근거를 더 + +**질문**: *"더 설명좀 필요"* + +**답**: `Brand` 대신 `type(x) == "table" and x.Compute ~= nil` 같은 모양으로 +판별하지 않는 이유가 두 가지다. + +1. **false positive** — `Modifier` 필드나 `:Peek`가 돌려주는 `T`는 **사용자가 + 넣은 임의의 값**이다. 사용자가 우연히 `Compute`라는 필드를 가진 테이블을 + 넣으면 quad가 그걸 `State`로 오인한다. 브랜드는 quad가 만든 값에만 찍히므로 + 이 오인이 원천적으로 없다. +2. **인덱싱 자체가 터질 수 있음** — 일부 Roblox userdata는 **정의되지 않은 키를 + 인덱싱하는 것만으로 에러를 던진다**(`x.Compute`를 읽는 순간 throw). 그래서 + duck-typing을 하려면 판별 코드를 전부 `pcall`로 감싸야 하고, 그건 + "판별은 부작용 없이 빠르게"라는 `isHandlable` 계약(`base/dispatch-core-plan.md`)과 + 정면으로 부딪힌다. 최악의 경우엔 엔진 자체가 죽는 상황도 있다. + +즉 (1)은 **정확성** 문제, (2)는 **안전성+비용** 문제라 서로 독립된 두 근거다. +weak-key 레지스트리 조회는 포인터 해싱 한 번이라 `pcall`도, 오인도 없다. + +→ **이 두 근거로 충분한가? 빠진 게 있나?** + +### B-5. `SL-5` — `_mounted` 트리거 시점을 더 풀어서 + +**질문**: *"더 풀어 서술해주길 바람. 판단 보류"* + +**답**: 요지는 **"`_mounted`를 언제 `true`로 세우는가"**이고, 후보가 둘 +있었다. + +```lua +-- 후보 A (채택): Dispatch가 이 Slot을 실제로 처리하기 시작한 시점 +function SlotHandler.process(inst, k, slotValue, index) + ... + attachSlot(slotValue, inst, inst, k) -- 이 안에서 _mounted = true +end + +-- 후보 B (기각): 물리 Parent 대입이 끝난 시점 +element.Parent = physicalTarget +slot._mounted = true -- ← 여기서 세우는 모양 +``` + +- **왜 A인가**: quad의 다른 "마운트됐다" 판정이 **전부 dispatch-process + 시점 기준**이다 — `PreRef`가 소진되는 시점, `Ref` 콜백이 fire되는 시점, + `claimOwnerAt`이 소유권을 잡는 시점이 전부 그렇다. 여기 하나만 "물리 + Parent 대입 이후"로 두면 **같은 사이클 안에서 어떤 판정은 이미 마운트됨, + 어떤 판정은 아직 아님**인 구간이 생긴다. +- **오탐 걱정이 없는 이유**: "컴포넌트가 Slot을 prop으로 받아 저장만 하고 + 실제 트리에 안 놓는" 경로에서는 `Dispatch.process`가 애초에 안 불린다 — + A 기준으로도 `_mounted`가 안 켜진다. +- **⚠️ 단, 이 서술은 그 뒤에 한 번 더 정밀해졌다**(`SL-55`) — 지금 + `attachSlot`은 `_mounted = true`를 **함수 맨 위가 아니라 `activateList` + 뒤**에 둔다. `activateList`가 도는 동안 `_mounted`가 `false`여야 + reconcile의 `rawAdd`가 "아직 마운트 전"(= `_elements`에만 넣고 끝) 경로를 + 타기 때문. **즉 "`process`가 불린 순간"이 아니라 "`process`가 불려 들어간 + `attachSlot` 안에서, `activateList`가 끝난 직후"가 정확한 시점**이다. + 원칙(dispatch-process 시점 기준, 물리 Parent 기준 아님)은 그대로. + +→ **이 정리가 맞나?** 특히 마지막 ⚠️(정확한 시점이 `activateList` 직후)까지 +포함해서 확인 부탁. + +### B-6. `SL-59` — 재귀적 `Clear()` 금지가 어떤 상황인가 + +**질문**: *"실 상황에 대한 설명 더 필요함. 이 글만 보아서는 어떤 상황인지 +정확히 판단 어려워보임. 판단 보류."* + +**답**: **중첩 Slot을 파괴할 때 "각 요소를 `Remove`로 하나씩 지우는" 순진한 +구현을 하면 O(n²)가 된다**는 얘기다. + +``` +outer(position 3에 마운트됨) + └ inner Slot (요소 500개) +``` + +`outer`가 파괴될 때 두 가지 방식: + +- **순진한 방식(금지)** — `inner:Clear()`가 요소마다 `rawRemove`를 부른다. + `rawRemove` 하나당 (a) `spliceArraysDown`으로 뒤 배열을 한 칸씩 당기고 + (b) `recompute(inner, bk)`로 전체 순회를 돈다. 500개면 **당기기 500번 × + 평균 250칸 + recompute 500번 × 500칸**. 어차피 통째로 죽을 서브트리인데 + 그 안에서 위치 계산을 500번 다시 하는 셈. +- **채택한 방식** — `destroySlotTree`는 **부기를 전혀 안 건드리고** 요소를 + 훑으며 파괴만 한다(`spliceArraysDown`도, `recompute`도 안 부름). 어차피 + 이 Slot 자체가 사라지므로 그 안의 위치 계산은 의미가 없다. **바깥에서 + 딱 한 번** — `outer`가 차지하던 position 3에 대해 + `setOffsetSource(None)` → `setLength(0)`을 부르고, 그때 `recompute`가 + 한 번 돈다. + +즉 "재귀적 `Clear()` 금지"는 **API를 금지한다는 게 아니라, 파괴 경로가 +CRUD를 재사용하면 안 된다**는 구현 지침이다. + +→ **이 이해가 맞나?** + +### B-7. `SL-63` — `recompute`가 owner의 Length를 직접 Set하는 게 이상하다 + +**질문**: *"(b) 가 뭔가 이상함. 오너의 length 를 직접 설정하지는 않을것임. +자신 length 를 변경하면, 자동으로 observer 에 등록된것으로 인해 length 가 +업데이트 되는 방식일텐데 … 최종 리컴퓨팅 결과가 length 가 되는거 아니였음?"* + +**답**: **사용자 이해가 맞고, 문항의 표현이 나빴다.** "owner"라는 말이 두 가지로 +읽혀서 생긴 혼동이다. + +```lua +local function recompute(ownerKey, bk) + ... + if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then + ownerKey.Length:Set(sum) + end +end +``` + +- 여기서 `ownerKey`는 **부모가 아니라, 지금 자식들의 합을 구하고 있는 그 Slot + 자신**이다. 즉 `recompute(innerSlot, bk)`가 `innerSlot.Length`를 세운다 — + **"최종 리컴퓨팅 결과가 자기 Length가 된다"는 사용자 이해 그대로.** +- **부모는 이 값을 직접 안 받는다** — 부모는 `attachSlot`에서 + `Dispatch.setLength(부모ownerKey, position, innerSlot.Length)`로 그 **State + 객체 자체**를 등록해뒀고, 값이 바뀌면 **Observer로 통지받아** 자기 + `recompute`를 돈다. +- 그래서 실제 흐름은 `innerSlot.Length:Set(sum)` → 부모의 observer 발화 → + `recompute(부모, ...)` → 부모의 뒤 형제 offset 갱신, 이고 **"부모 length를 + 직접 설정"하는 코드는 어디에도 없다.** + +문항(그리고 base 문서의 "(b) owner가 Slot이면 그 `.Length`")이 `ownerKey`를 +그냥 "owner"로 줄여 써서 부모로 읽히게 만든 게 원인이다. + +→ **이 정리가 맞나?** 맞다면 base 문서의 그 표현도 "그 recompute의 `ownerKey` +자신"으로 명확히 고치겠다(아직 안 고쳤음 — 확인 후 반영 예정). + +### B-8. `SL-76` — `recompute`가 `nil`을 관대하게 넘기는 게 왜 필요한가 + +**질문**: *"애초에 해제에서 nil이 관측 될 일이 없다고 생각하는데, 그게 +아니라면 다시 더 자세히 말을 해주길 바람."* + +**답**: **지금 설계에서 도달 가능한 경로를 저도 못 찾았다.** 그 방어 조항은 +2026-08-13(`bk.N` 정의가 확정되기 전, Blocker 게이팅이 생기기 전)에 들어간 +것이고, 그 뒤 두 변경으로 전제가 바뀌었다: + +- `bk.N`이 "그때그때 실제 개수"로 확정돼(2026-08-18), `lengthList`/`sourceList`가 + 아직 안 채워진 위치를 `recompute`가 읽을 일 자체가 없어짐. +- 배치 등록 중엔 Blocker 게이팅으로 `recompute`가 아예 안 돎. +- 해제는 `setOffsetSource(None)` → `setLength(0)`이라 `None`이지 `nil`이 아님. +- `spliceArraysDown`은 배열을 **압축**하므로 중간에 구멍을 안 남김. + +**→ 그래서 선택지 셋 중 하나를 골라야 한다(사용자 판단 필요, C절 `C-6`으로 +옮김).** 지금은 문서를 안 고치고 그대로 뒀다. + +### B-9. `SL-78` — nested Slot의 Length만큼 건너뛰는 얘기가 뭘 섞고 있나 + +**질문**: *"애초에 slot 내의 index 와, length 로 구해진 offset 은 다른 +개념인데, 너무 섞어 말하는것 아닌지 … Length 업데이트는 상위 slot 이 +observe 하기에 형제 slot 갱신에 무관한데, 그 이야기가 아닌것임?"* + +**답**: **섞어 말한 게 맞다. 두 얘기를 분리하면 이렇다.** + +1. **형제 Slot 사이의 offset** — 사용자 말대로 **자동이고 이 캐비엇과 + 무관하다.** nested Slot의 `Length`가 바뀌면 부모가 observe해서 `recompute`가 + 돌고 뒤 형제 offset이 갱신된다. 여기엔 문제가 없다. +2. **같은 `:List` 안에서 `updateFn`에 넘어가는 `index`** — 이쪽이 캐비엇의 + 대상이다. `reconcile`은 `pos`를 훑으며 각 아이템의 `index`를 계산하는데, + 어떤 아이템이 nested Slot(Length=3)을 반환하면 다음 아이템의 `index`는 + 1이 아니라 3만큼 건너뛴다. **그런데 `index`는 그 사이클의 raw 스냅샷이라**, + `:List`가 다시 reconcile되지 않은 채 그 nested Slot의 Length가 3→5로 + 바뀌면 **뒤 아이템들의 `index`는 3 기준으로 멈춰 있다.** + +즉 캐비엇은 **offset이 아니라 `index`에 대한 것**이고, "형제 Slot 갱신"이 +아니라 **"한 `:List` 안 아이템들끼리"**의 얘기다. 그리고 이건 이미 확정된 +"`index`는 State가 아니라 raw number"의 당연한 연장이라 새 문제가 아니다 — +실시간 정확성이 필요하면 `updateFn`이 `result.Length`를 직접 구독해야 한다. + +→ **이 분리가 맞나?** 맞다면 base 문서의 그 캐비엇 문단도 "offset이 아니라 +`index` 얘기"임이 드러나게 고치겠다. + +### B-10. `LH-8` — "자기 아래 vs 자기 위"를 그림으로 + +**질문**: *"사람이 너무 이해하기 어려운 표현임 … 풀어 보여줘야함."* + +**답**: `Frame { Frame { TextLabel {}, OnRendered(fn) } }`에서 안쪽 `Frame`의 +`fn`이 불릴 때: + +``` +바깥 Frame { ... } ← ⑤ 이 호출은 아직 시작도 안 함 + └ 안쪽 Frame { ... } ← ④ 이 drive가 지금 끝나는 중 + ├ TextLabel {} ← ① 이미 완성돼 안쪽 Frame에 붙어 있음 + └ OnRendered(fn) ← ③ fn이 여기서 불림 + (프로퍼티/이벤트) ← ② 이미 전부 세팅됨 +``` + +- **①② = "자기 아래"** — 자식(과 그 서브트리 전체) + 자기 프로퍼티/이벤트. + `fn`이 불릴 때 **전부 끝나 있다.** +- **⑤ = "자기 위"** — 안쪽 `Frame`이 바깥 `Frame`의 자식이 되는 일. Lua + 표현식 평가 순서상 **안쪽 `Frame{...}` 호출이 끝나야** 바깥 `Frame`의 props + 테이블이 완성되므로, `fn`이 불릴 때 바깥 `Frame`은 **존재하지도 않는다.** +- **그래서 "화면에 올라간 뒤"가 아니다** — 화면에 올라가려면 루트까지 이어져야 + 하는데 그건 ⑤ 이후 일이다. +- **원래 뭐가 헷갈렸나**: 경계를 "이 인스턴스의 프로퍼티만이냐(a) / 자식 + 서브트리까지냐(b)"로 놓았는데, 배열 파트 루프가 각 자식을 **동기적으로 + 끝내고** 넘어가므로 ①이 공짜로 따라온다 — (a)/(b)는 갈리는 지점이 아니었고 + 실제로 갈리는 건 ①②(자기 아래)와 ⑤(자기 위)였다. + +→ **이 그림이 맞나?** (base 문서에도 이 그림을 넣어뒀다.) + +--- + +## C. 사용자 판단 필요 — 임의로 처리하지 않은 것 + +**전부 아직 문서를 안 고쳤다.** 판단이 갈리거나 파급이 커서 임의 결정이 +위험한 것들이다. + +### C-1. ⭐ `SL-40`/`SL-43`/`SL-45` — `KeyGone` 센티널 신설 + +**사용자 제안**: 키가 데이터에서 사라지면 `updateFn`을 `KeyGone`으로 한 번 더 +불러 처분을 묻고(`T | KeyGone`), `userdata`를 지울지도 사용자가 정하게 위임. +이걸로 `SL-45`(Detach 홀드 중 키 소멸)가 닫힌다. + +**방향은 좋아 보인다** — `SL-45`가 "파괴도 반환도 안 되고 참조만 끊긴다"는 +어정쩡한 상태였던 근본 원인이 "키가 사라지는 순간엔 `updateFn`에게 물어볼 +방법이 없다"였고, `KeyGone`이 정확히 그 구멍을 메운다. 다만 **정하지 않으면 +구현이 못 나가는 세부가 넷** 있다: + +1. **`updateFn`이 `KeyGone`을 받았을 때 반환값의 의미**는? + 제안: `nil` → 파괴(기본), `Detach` → 언마운트만(사용자가 `ud`로 홀드), + `prev` 그대로 반환 → ? (키가 없는데 계속 마운트해두는 건 모순으로 보임 — + **error가 맞나, 아니면 파괴로 취급하나?**) +2. **`userdata` 엔트리는 언제 지워지나?** "유저가 결정"이면 `updateFn`이 + `(result, nil)`을 반환할 때만 지운다는 뜻인데, 그러면 **사용자가 `ud`를 + 계속 반환하는 한 `userdata[key]` 엔트리가 영원히 남는다**(키는 이미 + 사라졌으므로 다시 물어볼 기회도 없음). 의도된 것인가, 아니면 "한 번 + 물어본 뒤엔 무조건 지운다"인가? +3. **다음 사이클의 소멸 루프가 무엇을 순회하나?** 지금은 직전 + `keyIndex`를 순회하는데, `ud`만 남은 키는 `keyIndex`에 없다. `userdata` + 키까지 합집합으로 순회해야 하나(그러면 매 사이클 그 키를 계속 `KeyGone`으로 + 다시 물어보게 됨), 아니면 한 번 물어본 키는 목록에서 빼나? +4. **`index`/`offset` 인자는 뭘 넘기나?** 사라진 키엔 위치가 없다. + `nil`? 마지막으로 알던 값? (`updateFn` 시그니처가 `index: number`로 + 확정돼 있어서 `nil`을 넘기면 타입이 바뀐다.) + +**추가로 이름/배치**: `Detach`와 같은 급의 sentinel이므로 **패키지 최상위 +export**가 일관적이다(`None`/`Detach` 선례). 이름은 `KeyGone`이 의미는 +명확한데, 코퍼스가 "이 자리에서 무엇을 하라"는 지시형(`None`=세팅 안 함, +`Detach`=떼되 죽이지 마라)을 쓰는 것과 달리 **`KeyGone`은 상태 서술형**이라 +결이 조금 다르다 — 그게 오히려 맞을 수도 있다(이건 지시가 아니라 통지이므로). +**그대로 갈지 확인 부탁.** + +### C-2. ⭐⭐ `SL-43` vs `SL-51` — "밀려난 `prev`는 dispose"와 `state` 의미론이 충돌한다 + +**이번 회신에서 나온 것 중 파급이 가장 크다.** 두 답변이 서로 반대 방향을 +가리킨다: + +- **`SL-43` 답변**: *"'updateFn이 새 값을 반환하면 밀려난 prev는 언마운트만' + 는 이상한듯. 새 값으로 밀려난 prev 는 dispose 되는게 맞음. updateFn 은 직접 + destroy 를 호출 못함 … 그래서 지울 방법이 존재하지 않고, 지워주는게 맞다고 봄."* +- **`SL-51` 답변**: *"state -> slot {frame} 형태가 될 때 이전 state 에서 + 변경으로 다른게 와도, slot 이 이전 frame 을 destroy 해버리면 안 됨."* + +**둘 다 맞는데 서로 다른 경우다** — 갈리는 축은 **누가 그 요소를 만들었는가**: + +| 경로 | 요소를 만든 주체 | 밀려난 `prev`의 올바른 처분 | +|---|---|---| +| `:List(data, updateFn)` — `updateFn`이 `Frame{...}`을 만들어 반환 | **`updateFn`** | **dispose** — 만든 쪽이 자기 손으로 못 지우니(reconcile 중이라 `dispose`가 거부됨) reconcile이 대신 지워야 함 | +| `Slot:Add(state)` 라 sugar(`:Single` + identity `updateFn`) | **사용자**(state에 담아 넘김) | **언마운트만** — `state`가 이전 값을 안 죽인다는 확정 의미론 그대로 | + +지금 설계는 **후자를 표현할 방법이 없다.** `Detach`는 "이 자리를 비우되 죽이지 +마라"라서 *교체*와 같이 못 쓴다(새 값을 반환하는 순간 `result ~= nil` 경로로 +가고, 그 경로가 dispose가 됨). + +**선택지 셋**: + +- **(a) `:List`/`:Single`에 소유권 옵션을 둔다** — 예: + `Slot:Single(state, updateFn?, opts?)`의 `opts.Owned = false`. 설치 시점에 + 한 번 정하고, `Slot:Add(state)` sugar가 `Owned = false`로 설치한다. + *장점*: 갈리는 축(누가 만들었나)이 실제로 **설치 단위 속성**이라 의미가 + 정확하고, 매 사이클 반환값에 부담을 안 준다. *단점*: 옵션 파라미터가 + 하나 늘고, "한 `:List` 안에서 어떤 아이템은 내가 만들고 어떤 건 사용자 + 것"인 혼합은 표현 못 함(그런 사례가 있는지는 모르겠음). +- **(b) `Detach`를 호출 가능하게 만들어 `Detach(newValue)`로** — "교체하되 옛 + 것은 떼기만" 을 반환값 프로토콜 안에서 표현. *장점*: 국소적이고 per-cycle로 + 정확. *단점*: **2026-08-19에 확정한 `Detach` 배치 결정과 부딪힌다** — + 그때 "sentinel 상수 하나 때문에 callable-table+메타테이블 구조를 들이는 건 + 과함"이라고 판단해 최상위 순수 sentinel로 뒀다. +- **(c) 새 sentinel 하나 더** — 예: 반환값을 `(newValue, ud, DetachPrev)`처럼 + 세 번째 슬롯으로 신호. *단점*: 반환 프로토콜이 복잡해짐. + +**추천: (a)**. 갈리는 기준이 per-cycle이 아니라 per-installation이라는 게 +분석의 핵심이고, (b)는 방금 내린 결정을 뒤집는 비용이 있다. + +**같이 확인 부탁**: `SL-51`에서 *"list 슈거에서 Detach 가 사용중인지 확인이 +필요해보임"* 이라 하셨는데 — **지금 `:Single`의 기본 identity `updateFn`은 +`Detach`를 전혀 안 쓴다**(그냥 `item`을 반환). 그래서 위 충돌이 실재한다. + +### C-3. `M-2` — flatten이 소진한 Modifier 자리에 구멍이 남는다 (⭐ 실제 갭) + +**사용자 제안**: *"이것도 이 이후 ProcessedModifier / ProcessedModifierHandler +를 만들면 될듯. Post/Pre Ref 와 유사히 처리 가능하다고 생각함."* + +**조사 결과 — 이건 문서의 실제 갭이 맞다.** `modifier-plan.md`는 flatten이 +"`isModifier(v)`가 참인 항목만 필드를 뽑아 merge하고 나머지는 안 건드린다"고만 +적어두고, **뽑아낸 그 자리를 어떻게 하는지를 한 번도 안 적었다.** 그냥 지우면 +배열에 구멍이 생기고, 그건 `PreRef` pre-pass가 `ProcessedPreRef`로 소진하는 +바로 그 이유(sparse 테이블이 되면 배열 파트 전체가 순서 보장을 잃음)에 정확히 +걸린다. + +**선택지 둘**: + +- **(a) `ProcessedModifier` 센티널 + 전담 nop Handler** — `Pre`/`PostRef`와 + 완전히 대칭. 그 Handler가 `setOffsetSource(None)`/`setLength(0)`을 등록하니 + Length/Offset 계약도 특수 취급 없이 만족된다. **일관성 최고.** +- **(b) flatten이 결과 배열을 압축(compact)** — flatten은 이미 `flatten(nonFlatten) + -> flattened`라는 **새 테이블을 만드는 순수 변환**이라, Modifier 자리를 아예 + 안 넣고 뒤를 당겨 담으면 구멍 자체가 안 생긴다. 센티널도 Handler도 불필요. + *단, 이러면 배열 위치가 원본과 달라진다* — `flattened`만 보는 `drive` + 입장에선 무해해 보이지만, 확인이 필요하다. + +**추천: (b)** — flatten이 어차피 새 테이블을 만드는 이상 가장 부품이 적다. +`Pre`/`PostRef`가 센티널을 써야 했던 이유는 **pre-pass가 `flattened`를 +제자리에서 소진**하고 그 뒤 두 패스가 **같은 테이블**을 다시 돌기 때문인데, +flatten은 그 앞 단계라 그 제약이 없다. 다만 사용자가 (a)를 제안하신 만큼 +**어느 쪽인지 정해주면 그대로 반영**하겠다. + +### C-4. `destroySlotTree`가 자식 소유권을 명시적으로 반납해야 하는가 + +**사용자 의문**: *"Destroy 된 요소는 다른곳에 원래 마운트 못하는게 보통 엔진 +정상이고, 또, 릴리즈 안 되어 다른곳에 마운트 막혀도 상관 없고, 그게 정상 동작일 +수 있어보임."* + +**분석 — 사용자 쪽이 더 맞아 보인다.** 명시적 `releaseOwner`가 들어간 원래 +근거(2026-08-13 감사)는 *"`elementOwner`가 weak라 GC 타이밍에 따라 '이미 +마운트돼 있음' error가 **비결정적으로** 터진다"*였는데, 다시 보면: + +- **그 error가 나는 상황 자체가 버그다** — 파괴된 요소를 다른 곳에 다시 + 넣으려는 코드이기 때문. 즉 "비결정적으로 실패"의 반대는 "성공"이 아니라 + **"항상 실패"**이고, 그게 더 나은 동작이다. +- **명시적 반납은 오히려 그 버그를 통과시킨다** — 파괴된 요소를 재마운트하는 + 코드가 조용히 성공하고, 그 다음에 죽은 Instance를 다루다 엉뚱한 데서 터진다. +- **비결정성만 없애면 된다** — 반납을 안 하면 GC 전엔 error, GC 후엔 통과라 + 여전히 비결정적이다. 이걸 결정적으로 만들려면 오히려 **파괴된 요소에 + "파괴됨" 표식을 남기는** 쪽이 맞는데, 그건 새 부기다. + +**선택지**: +- **(a) 명시적 `releaseOwner` 제거** — 단순해지고, "파괴된 걸 재사용하면 + (대개) 막힌다"는 동작이 남는다. 비결정성은 그대로. +- **(b) 유지** — 지금 문서 그대로. +- **(c) 반납 대신 "파괴됨" 마킹** — 결정적으로 항상 error. 부기가 하나 는다. + +**추천: (a)**, 다만 "비결정적"이라는 성질이 남는 걸 문서에 명시. (c)는 +`conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고 구조를 +복잡하게 만들지 않는다" 기준으로 지금은 과해 보인다. + +### C-5. `SL-58` — 배치 밖 재마운트의 낭비가 실제로 있나 (+ 개선안) + +**사용자 질문**: *"offset 이 전부 같아 set 안 일어나고 가벼운거로 아는데, +아님?"* + +**분석 — 뒤에 형제가 없으면 맞고, 있으면 아니다.** + +steady state에서 position `k`의 `state`이 교체될 때: + +1. `attachSlot`이 `setOffsetSource(ownerKey, k, ...)` → `1..k-1` 합이라 + **안 바뀜**(사용자 말대로 여기까진 무해). +2. `Dispatch.setLength(ownerKey, k, slot.Length)` → `slot.Length`는 아직 + **flush 전이라 0**. Observer "등록 즉시 1회 실행" → 부모 blocker가 꺼져 + 있으니 `recompute(부모)`가 **즉시 돈다.** +3. 그 `recompute`는 `lengthList[k] = 0`으로 계산하므로 **`k+1..N` 위치의 + offset이 전부 줄어든 값으로 `:Set`된다** → 그 아래 `LayoutOrder` 캐스케이드. +4. flush가 끝나고 `slot.Length:Set(최종)` → 다시 `recompute` → offset 원복. + +즉 **`k` 뒤에 형제가 하나도 없으면 낭비가 정말 없고**(offset이 바뀔 대상이 +없음), **뒤에 형제가 있으면 그 형제들의 offset이 두 번 `Set`되고 다운스트림도 +두 번 돈다.** 값은 결국 맞으므로 크래시나 영구 오류는 아니다. + +**개선안(제안)**: `attachSlot`에서 **`Dispatch.setLength`를 flush 루프 +*뒤*로 옮기면** 이 왕복이 사라진다 — 그 시점엔 `slot.Length`가 이미 최종 +값이라 부모 `recompute`가 한 번만 돈다. 배치 안에서는 어차피 부모 blocker가 +켜져 있어 차이가 없고, 배치 밖에서만 이득이다. + +- **확인 필요한 것**: `setOffsetSource` → `setLength` **순서 계약**은 유지된다 + (둘 다 flush 앞뒤로 갈릴 뿐 상대 순서는 그대로). `bk.N`을 `setLength`가 + 올리는 규칙도 한 position 안에서는 영향이 없어 보인다. +- **판단 부탁**: 이건 확정된 의사코드의 순서를 바꾸는 것이라 임의로 안 고쳤다. + **옮길지, 지금처럼 두고 "한 프레임 낭비 허용"으로 남길지.** + +### C-6. `SL-76` — `recompute`의 `nil` 관대 처리를 어떻게 할지 + +B-8의 조사 결과, 지금 설계에서 **도달 경로를 못 찾았다.** 선택지: + +- **(a) 유지 + 근거 갱신** — "지금은 도달 경로가 확인되지 않지만 전이 구간 + 방어로 남긴다"로 문구만 정직하게 고침. +- **(b) 삭제** — `None`만 처리하고 `nil`은 `bk.N` 계약 위반이므로 자연히 + 터지게 둠. +- **(c) `error`로 승격** — `nil`이 관측되면 **부기가 깨졌다는 신호**이므로 + 조용히 skip하지 말고 즉시 error(코퍼스의 "매치 실패는 즉시 error", + `releaseOwner` 불일치 error와 같은 톤). + +**추천: (c)** — 관대한 skip은 "위치 하나가 조용히 순서 계산에서 빠지는" +디버깅 어려운 오작동이 되고, 지금은 그게 정상 경로로 생기지 않는다는 게 +분석 결과이므로 error가 더 안전하다. + +### C-7. "Length로 먼저 밀어내고 그 공간에 넣는다"가 다른 CRUD/`:List`에도 통하는가 + +**사용자 제기**: *"만약 밀어내고 당기지 않은 상태에서 그 공간에 넣는다 하면, +밀어내는걸 구현해야하는 백엔드에서 골치아파짐. 지금 어떤 상황인지 확인해볼것"* + +**조사 결과 — `rawAdd`만 순서가 명시돼 있고 나머지는 안 적혀 있다.** + +- **`rawAdd`(문서화됨)**: `self.Length:Set(newCount)`(→ 뒤 형제 offset 갱신이 + 여기서 동기적으로 끝남) → `element.Parent = target`. 즉 **"밀어낸 뒤 넣는다"**. +- **`rawRemove`/`rawUnmount`(문서화 안 됨)**: 의사코드는 + `unbindLifetime` → `releaseOwner` → 파괴/언마운트 → `spliceArraysDown` → + `recompute` 순서다. 즉 **"빼고 나서 당긴다"**. `rawAdd`의 거울상이라 일관돼 + 보이지만 **명시적으로 계약화돼 있지는 않다.** +- **`Splice`(문서화 안 됨)**: "shift+recompute 1회로 묶는다"만 있고 물리 + detach/attach와 부기의 **선후가 안 적혀 있다.** 제거분과 삽입분이 겹치는 + 구간이라 특히 애매하다. +- **`:List`의 `rawMove`(문서화 안 됨)**: "Parent를 안 건드린다"만 확정돼 있어 + 물리 이동이 없으니 이 문제에서 빠지는 것으로 보인다. + +**판단 부탁**: 이걸 **일반 계약으로 승격**할지 — 예컨대 *"부기(Length/offset) +갱신이 물리 트리 조작보다 항상 먼저 끝난다"* 를 `dispatch-core-plan.md`나 +`slot-plan.md`에 한 줄로 못박고 모든 `raw*`가 따르게 할지. 그러면 백엔드 +작성자가 "내가 물리적으로 밀어낼 때 부기는 이미 정확하다"를 전제할 수 있다. +**승격에 찬성하시면 문구를 써서 반영하겠다.** + +### C-8. `SL-48` — Slot-in-Slot에서도 Destroy 후 정리가 성립하나 + +**사용자 메모**: *"더 나아가 slot in slot 에서도 유효한가 생각해보아야함. +아마 그런것으로 알고있음. 피지컬 홀더랑 오너가 다르거든."* + +**분석 — 성립하지만, `D-56`이 해결돼야 성립한다.** 중첩에서 둘이 갈린다: + +- **physicalTarget**(물리 Instance) — `attachSlot`이 자식 요소를 실제로 + `Parent`하는 대상. 최상위든 중첩이든 **같은 물리 부모**다. +- **ownerKey**(부기 소유자) — 최상위는 `inst`, 중첩은 **부모 Slot 자신**. + +Destroy 시나리오를 따라가면: + +1. physicalTarget이 Destroy → gcconn 끊김 → 그 `inst`에 `bindLifetime`된 + 것들은 `canExecute`가 거짓이 되어 발화가 멈춘다. ✅ +2. 중첩 Slot의 `setLength` Observer는 `bindLifetime(ownerKey=부모Slot, + observer)`로 묶여 있다 — **`inst`가 아니다.** 그래서 1번만으론 안 끊기고, + **부모 Slot이 unreachable해질 때** 같이 죽어야 한다. +3. 부모 Slot은 `gchold`(physicalTarget의)에 강참조로 매달려 있으므로, + physicalTarget이 GC되면 부모 Slot도 참조가 끊긴다 → 그 아래 `Relate(부모 + Slot)` 부기도 weak-keyed라 같이 사라진다. ✅ + +**결론: 체인이 성립한다.** 다만 **2번이 성립하려면 `bindLifetime`이 Slot을 +첫 인자로 받는 걸 실제로 처리해야 한다** — 그게 A절에서 신설한 `D-56` +요구사항이다. 즉 `SL-48`은 "이미 맞다"가 아니라 **`D-56`이 구현되면 맞다.** + +→ **이 분석이 맞나?** 특히 3번(physicalTarget → 부모 Slot → 중첩 부기의 +연쇄 GC)이 실제로 그렇게 도는지. + +### C-9. `AT-13` — `retractor`를 부르는 주체 + +**사용자 확인 요청**: *"정확히는 같은 핸들러 재프로세스는 retractor 를 +process 에서 굴리고 자기 작업을 함. 따라서 process → calls retractor(v) → +process new one 이 맞는걸로 보이는데 … 실제 구현은 저렇게 알고 있는게 맞음?"* + +**답: 맞다.** 의사코드 그대로다: + +```lua +function Dispatch.process(inst, k, v, index) -- ← 오케스트레이터 + ... + if slot ~= nil and slot.handler == h then + slot.retractor(v) -- ① 여기서 굴림 + slot.retractor = NOOP + local retractor = h.process(inst, k, v, index) -- ② 자기 작업 + ... +``` + +즉 **`Dispatch.process`(오케스트레이터)가 ①과 ②를 둘 다 부른다.** 문항의 +"`retractor(v)` → `process`"는 그 둘의 **순서**만 적고 **누가 부르는지**를 +생략한 축약이었고, 사용자 이해가 정확하다. `handler.process`(개별 핸들러)는 +자기가 retractor를 부르지 않는다 — 그건 오케스트레이터의 일이다. + +→ 문서엔 이미 이렇게 적혀 있어 **정정 불필요**. 확인만. + +--- + +## D. 문항지 자체가 stale해진 것 (업스트림 병합 결과) + +문항지는 `pull` 전 `base/`를 기준으로 썼다. 업스트림 12커밋이 들어오면서 +아래가 이미 달라졌다 — **문항이 틀린 게 아니라 시점이 지났다.** + +| 문항 | 무엇이 달라졌나 | +|---|---| +| `ML-5` | 사용자가 회신에서 직접 짚음(*"새로운 커밋에서 이것이 달라짐"*). 파일마다 `Relate()`+`INITED` 센티널을 두는 설계가 **`RunInit` 하나로 통합**됐다 — **함수 자기 자신을 릴레이션 키로** 쓰므로 센티널이 불필요. `quad-base/src/init.luau`에 실제 구현 + `test/smoke.init.luau`로 검증까지 끝. **문항 `ML-5`는 폐기.** | +| `ML-8` | 위 변경에 맞춰 `RunInit`(함수 identity) vs `_initializedBy`(문자열 마커) 구분으로 재서술됨. **결론은 그대로**(둘은 다른 층위, 재사용 안 함)이라 문항 자체는 여전히 유효. | +| `ST-3` | "M0에서 실측 확인할 것"이 **실측 완료**됨 — `luau-test/done/21-type-store-undeclared-key-rejected.luau`가 미선언 키 접근 2건이 정확히 `TypeError`로 거부됨을 확인. **⚠️ 열린 항목이 아니게 됨.** | +| `D-10` | 위 B-2 — 스파이크 `01`이 이미 통과. | +| `A-2` | 패키징이 **wally → pesde**로 바뀌었고 `mise`/`selene`가 도입됨. 문항의 "wally 패키지" 표현이 stale(모놀리식 모노레포라는 **결론 자체는 유지**). | +| 전반 | `M0`/`M1`이 **완료**됐다 — 문항지 여기저기의 "M0 착수 전/M0에서 확인" 표현은 이제 "M2 착수 전"으로 읽어야 맞는 것들이 섞여 있다. | + +**신규 문서 2개는 문항이 아예 없다** — `base/project-setup-plan.md`(pesde/mise/ +selene/Rojo/darklua 경계)와 `base/quad-types-plan.md`(`AddPlugin`/ +`CheckedQuad`/`type-version-check`). `architecture.md`와 +`typing-limits.md`도 이 라운드에 내용이 늘었다. **원하시면 이 넷만 대상으로 +5라운드 문항지를 따로 만들겠다** — 지금 4라운드 회신 처리가 먼저라 착수하지 +않았다. + +--- + +## E. 회신 방법 + +- **B절(재질문)** — 각 항목의 "이 이해가 맞나?"에만 답해주면 된다. 맞으면 + 그대로 두고, 어긋나면 그 지점만 알려주면 base까지 같이 고친다. +- **C절(판단 필요)** — 선택지에 번호를 달아뒀으니 고르거나, 더 나은 안을 + 주면 된다. **`C-1`(KeyGone)과 `C-2`(dispose vs `state` 충돌)가 + 가장 파급이 크고 나머지를 막고 있다.** +- **D절** — 5라운드를 만들지만 알려주면 된다. diff --git a/.claude/session/2026-08-20-01-qa-round4-response-processing.md b/.claude/session/2026-08-20-01-qa-round4-response-processing.md new file mode 100644 index 0000000..0759174 --- /dev/null +++ b/.claude/session/2026-08-20-01-qa-round4-response-processing.md @@ -0,0 +1,104 @@ +# 2026-08-20-01 — QA 4라운드 회신 1차 처리 + 업스트림 스캐폴딩 병합 + +**한 줄**: 사용자가 남긴 `pre-implementation-qa-round4-response.md`를 받아 +판단이 명확한 것 20여 건을 `base/`에 반영하고, 나머지를 +`pre-implementation-qa-round4-followup.md`로 정리했다. 같은 세션에 업스트림 +12커밋(M0 검증 + M1 스캐폴딩)을 pull해 문항지 커밋을 그 위로 rebase했다. + +## 순서 + +1. **업스트림 병합 먼저** — `git fetch` 결과 `origin/main`이 12커밋 앞서 + 있었음(pesde 전환, mise/selene, RunInit 재설계, quad-types/type-version-check + 신설, M0 스파이크 21~23 추가, ROADMAP/CLAUDE.md stale 정정). 전 세션 + 산출물(QA 4라운드 문항지)을 먼저 커밋한 뒤 `git rebase origin/main` — + **충돌 없이 통과**(문항지가 신규 파일이고, 인덱스 2줄만 수정했기 때문). +2. **회신 읽고 분류** — 회신은 문항 번호별로 자유서술이라, (a) 바로 반영, + (b) 설명 보강 후 재질문, (c) 사용자 판단 필요, (d) 조사해서 답이 나옴으로 + 갈랐다. +3. **(a)만 문서에 반영** — (b)/(c)는 손대지 않고 followup에만 적었다. + +## 반영한 것 중 설계가 실제로 바뀐 것 + +목록 전체는 followup A절이 소스(여기서 반복 안 함). 그 중 "표현 정정"이 아니라 +**동작이 달라지는** 것만: + +- **`SL-75`/`D-60` — 언마운트 시 `slot.Offset = nil` 금지.** 옛 서술은 + "마운트 전 상태로 복원"이었는데, 사용자가 **포탈이 깨진다**고 지적. 이유가 + 깔끔했다 — `updateFn`이 `layoutOrder:With(offset):Compute(...)`처럼 그 + Source를 파생 그래프에 엮어둔 상태에서 필드를 갈아치우면, 그 구독은 옛 + Source를 계속 보는데 Slot은 새 Source를 등록하게 되어 **영영 갈라진다.** + 재마운트가 "값이 다시 채워지는 일"이 아니라 "연결이 끊긴 채 조용히 멈추는 + 일"이 되는 것. 부수로 `Slot.Offset`의 마운트 전 기본값도 `nil` → `0`. +- **`E-11` — leaf 바인딩된 Effect엔 `:Unsubscribe()`가 아예 안 먹음.** + 옛 서술의 "(3) 이후 leaf가 실제로 죽어도 중복 호출되지 않는다"가 사용자 + 눈에 이상해 보였는데, 따져보니 **성립할 수 없는 문장**이었다 — 이중 바인딩 + 게이트가 `:Subscribe()`(전역)와 `bindLifetime`(leaf)을 상호 배타로 막아두므로 + `:Subscribe()`된 핸들은 애초에 leaf에 붙어 있을 수가 없고, "그 leaf가 죽는" + 사건 자체가 없다. Observer와 같은 규칙으로 통일하는 것으로 정리. +- **`AT-20` — 생존 이름 최적화는 "안 하는" 게 아니라 "못 하는" 것.** + 옛 서술은 "이론상 가능하지만 부품이 늘어나서 기본은 균일 철거"였는데, + 사용자 지적대로 **이름이 같아도 값이 바뀌었을 수 있어** 이름 목록 비교만으로는 + 아무것도 못 건너뛴다. 값을 비교하려면 `:Get()`이 필요하고 그건 State 계약 + 위반 — 즉 설계 원칙상 애초에 막힌 길이다. `Tag`가 되는 이유(자원이 이름 + 집합이라 겹쳐도 합집합)와의 대조까지 같이 적었다. +- **`D-56` — `bindLifetime`의 첫 인자가 Instance가 아닐 수 있다.** + `Dispatch.setLength(ownerKey, ...)`의 `ownerKey`가 Slot-in-Slot에선 Slot + 자신인데, 구현 스케치는 `InstData`에서 gcconn을 찾는 걸 전제하고 있었다. + gcconn 트릭은 엔진 객체에만 걸 수 있으므로 **백엔드가 별도 경로를 제공해야 + 한다**는 요구사항으로 신설. `isBoundAlive`에 세 번째 분기가 필요하다는 것 + (지금은 gcconn 없으면 `.Subscribed` 폴백으로 떨어져 **살아있는데 `canBound`가 + 참으로 잘못 나옴**)까지 같이 짚었다. + +## 사용자가 새로 찾아낸 갭 — 문서에 아예 없던 것 둘 + +1. **`Attribute(a, b, ...)` 생성자의 이름 겹침 정책이 어디에도 없었다.** + `Merged`(error)/`Overridden`(뒤가 이김)만 적혀 있었고, 생성자가 인자들을 + 하나의 이름 맵으로 평탄화하는데 겹치면 어떻게 되는지가 공백. 배열 순서 + 규칙과 결을 맞춰 **뒤가 이김**으로 명시했다. +2. **flatten이 소진한 Modifier 자리를 어떻게 하는지가 없었다**(`M-2`). + "`isModifier(v)`인 항목만 필드를 뽑아 merge"만 적혀 있고 그 자리를 지우는지 + 센티널로 채우는지가 공백 — 그냥 지우면 `PreRef` pre-pass가 `ProcessedPreRef`로 + 소진하는 바로 그 이유(sparse 테이블)에 걸린다. 사용자는 `ProcessedModifier` + 센티널을 제안했고, 나는 **flatten이 어차피 새 테이블을 만드는 순수 변환이라 + 압축(compact)이 더 싸다**고 봐서 두 안을 followup C-3에 나란히 올렸다. + +## followup으로 넘긴 것 중 가장 큰 둘 + +- **`C-1` `KeyGone` 센티널** — 사용자 제안(키 소멸 시 `updateFn`을 한 번 더 + 불러 처분을 묻기)이 `SL-45`의 어정쩡한 상태를 정확히 메운다. 다만 반환값 + 의미/`userdata` 수명/소멸 루프 순회 대상/`index` 인자 넷이 안 정해지면 구현이 + 못 나가서 그대로 물었다. +- **`C-2` "밀려난 `prev`는 dispose" vs `state` 의미론** — 회신의 두 + 답변(`SL-43`, `SL-51`)이 서로 반대를 가리켰다. 따져보니 **둘 다 맞고 갈리는 + 축이 "누가 그 요소를 만들었는가"**였다 — `:List`의 `updateFn`이 만든 것은 + reconcile이 지워줘야 하고(만든 쪽이 자기 손으로 못 지움), `Slot:Add(state)` + sugar로 들어온 것은 사용자 소유라 죽이면 안 된다. 지금 설계엔 후자를 표현할 + 방법이 없어서(‌`Detach`는 교체와 같이 못 씀) 선택지 셋을 제시했고, + **per-installation 소유권 옵션**을 추천했다 — 갈리는 기준이 per-cycle이 + 아니라 설치 단위 속성이기 때문. + +## 조사만으로 닫은 것 + +- **`E-10`(dedup 대칭)** — 의사코드를 다시 따라가니 `bindLifetime`(내부 + Observer cascade 포함)이 `old ~= v` 분기 안에, `unbindLifetime`이 + `nextValue ~= v` 분기 안에 있어 이미 대칭이었다. 사용자가 말한 + "relate로 old를 들고 있다가 process에서 조회해 같으면 dedup"이 정확히 지금 + 코드다. 설계 갭 없음, 구현 시 회귀 확인만. +- **`D-10`** — 사용자 지적("Luau 일반화 for가 그냥 지원함")이 맞고, 문항이 + "M0에서 순회 동작을 검증"이라 적어 언어 동작을 의심하는 것처럼 읽힌 게 + 문제였다. 스파이크 `01`이 검증한 건 언어가 아니라 **우리가 짠 두 패스 + 드라이버**이고, 두 패스를 명시하는 진짜 이유는 이식성이다. 업스트림에서 + 이미 통과했으므로 base의 "M0에서 검증할 것"도 닫았다. +- **`SL-76`** — 사용자 의문("해제에서 nil이 관측될 일이 없다")대로 지금 + 설계에서 도달 경로를 못 찾았다. `bk.N` 정의 확정 + Blocker 게이팅 + + `spliceArraysDown`의 압축이 각각 그 경로를 막는다. 그래서 "관대한 skip"을 + 유지/삭제/error 승격 중 고르는 문제로 바꿔 올렸고 **error 승격을 추천**했다. + +## 안 한 것 + +- **감사 루프 안 돌림** — 이번 변경이 크긴 하지만 아직 **회신 처리가 절반** + (B/C절이 남음)이라, 감사는 그 라운드까지 끝난 뒤 한 번에 도는 게 맞다고 봄. + `doc-check.py`는 매 편집 후 돌려 ERROR 0 유지. +- **5라운드 문항지** — 업스트림이 들여온 신규 문서 둘(`project-setup-plan.md`, + `quad-types-plan.md`)과 이번에 내용이 늘어난 `architecture.md`/`typing-limits.md`는 + 문항이 아예 없다. followup D절에 적어두고 **사용자 지시 대기**. diff --git a/.claude/todos.md b/.claude/todos.md index 3e0a206..29eb779 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -8,8 +8,16 @@ 00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — 1·2·3라운드는 전부 `base/`에 반영 완료, [2026-08-19 신설] 4라운드는 문항지 작성만 끝나고 사용자 회신 대기 중.** - **4라운드(`.claude/qa-request/pre-implementation-qa-round4.md`) — 회신 - 대기.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 + **4라운드 — [2026-08-20] 회신 1차 처리 완료, 재질문 대기.** 문항지는 + `.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은 + `-response.md`, 처리 결과·재질문·판단 대기 항목은 **`-followup.md`가 + 소스**(여기서 목록을 세지 않음). 회신 중 판단이 명확했던 것은 그 자리에서 + `base/`에 반영했고, **설명 부족으로 되물어야 하는 것(B절)과 파급이 커서 + 임의 결정이 위험한 것(C절)이 남아 있다** — 특히 `C-1`(`KeyGone` 센티널 + 신설)과 `C-2`(`:List`의 "밀려난 prev는 dispose"가 `state` + 의미론과 충돌)가 나머지를 막고 있다. 아래는 그 회신 전 서술: + + **(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로 `base/` 전 문서를 한 맥락에서 읽으며 확정 주장을 전수 문항화한 것. **아직 아무것도 정정하지 않았음** — 사용자가 "아니오"인 항목을 회신하면