diff --git a/.claude/README.md b/.claude/README.md index 57751e6..ba6a4de 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -90,6 +90,7 @@ | `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 못 고치는 트레이드오프(의도된 설계) 정리. **[2026-08-12 열여덟 번째 세션]** "고칠 만한 것" 절에 남아있던 마지막 두 항목(use-after-destroy 검증 안전망 부재, `:With` 동적 의존성 미지원)도 사용자가 "고칠 필요 없음"으로 최종 판단해 3번 절(못 고치는 트레이드오프)로 이전 — 2번 절은 이제 해소된 항목만 남음 | 하 — 배경 자료, 더 이상 사용자 판단 대기 상태 아님 | | `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 | +| `slot-attach-decomposition.md` | **[2026-08-21 신설]** `attachSlot`의 책임 분해 **논의 준비 자료**(확정 아님). QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 flush 앞/뒤 어디에 둘지가 갈렸는데, 사용자가 그건 자리 선택 문제가 아니라 *"attachSlot 의 기능이 너무 다양해진게 문제"* 라고 짚어 확장 논의로 넘어간 것. 지금 `attachSlot`이 지고 있는 책임 일곱(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치 게이팅, 자식 배치, 재귀)과 순서 제약 일곱(각각 `RC-1`/`RC-3`/`RC-4` 등 실제로 밟은 버그 출처까지)을 모아두고, **C6(길이 최종값은 flush 뒤에야 정해짐)와 C7(부기가 물리보다 먼저)이 단일 함수로는 동시 만족 불가능**함을 보인 뒤 분해 후보 넷(현행 유지 / `prepare`+`mount` 2단 / 3단 / 문서만)을 대조. 정본은 여전히 `base/slot-plan.md` | 중 — M2/M3는 안 막지만 **M6(`:List`) 구현 전엔 정해두는 게 나음**. 선행으로 `qa-request/pre-implementation-qa-round4-followup.md`의 `F-3`(`Detach` 보관 위치 + `KeyGone`)이 먼저 닫히면 요구사항이 완전해짐 | | `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리(**[2026-08-19]** 그 질문은 `base/debounce-throttle-plan.md`로 전부 해소·승격 완료), `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State|T>` → `State` 평탄화 항목 신설(백로그) — `State>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | | `quad-recursive-acronym.md` | **[2026-08-14 신설]** GNU/WINE류로 `Quad`를 재귀 약어화하는 카피 브레인스토밍 — 설계 결정도 착수 게이팅도 아니고 나중에 README.md 헤딩 등에 쓸 캐치프레이즈 후보 모음. 자학 개그 방향(기각)과 지연평가/재귀·커링/펑터/클로저를 자랑하는 방향(채택 후보, 미확정) 정리 | 하 — 카피 소재, 설계 상의 필요 없음. 사용자가 최종 문구 고르면 반영 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 276eb86..57a6747 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -384,15 +384,40 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다 먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`, `b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고, - **base 드라이버가 명시적으로 두 패스로 나눠 돌기로 계약화**한다 — 숫자 - 키(children)를 먼저 index 순서대로 처리하고, 그 다음 나머지 키를 처리. - 이유: (1) 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 아닌 - 다른 자료구조로 표현할 수도 있어서 "Lua 테이블의 우연한 내부 동작"에 - 기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열 - 키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는 - 참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열 - 슬롯에 놓인 어떤 값이든 모든 프로퍼티/이벤트 세팅보다 항상 - 먼저 처리된다는 게 base 자체의 보장**이 됨. + **base 드라이버가 이 순서를 계약으로 보장**한다 — 배열 슬롯에 놓인 어떤 + 값이든 모든 프로퍼티/이벤트 세팅보다 항상 먼저 처리된다. + + **⚠️ [구현 방식 정정, 2026-08-21 구현 전 QA 4라운드 `F-4-1`] "계약으로 + 보장한다"와 "루프를 두 번 돈다"는 다른 얘기다 — 실제 구현은 일반화 `for` + **한 번**이다.** 옛 서술은 "명시적으로 두 패스로 나눠 돌기로 계약화"라고 + 적어 **구현까지 2회 순회로 못박은 것처럼** 읽혔고, 실제로 M0 스파이크 + `01`도 숫자 `for` + 일반화 `for` 두 루프로 짜여 있었다. 사용자 판정으로 + 단일 순회로 정정: *"ipairs, pairs 를 따로 사용하게 되는게 아닌 단순 + 일반화 for 로써 얻어지는게 맞는 상태라면, 맞는 구현이다 … `__pairs`/ + `__ipairs` 직접 구현체를 담은 ud 등을 받는 `flattened` 는 없고, luau + 테이블만 사용하는게 맞음."* + - **`flattened`는 항상 평범한 Luau 테이블이다** — props는 사용자가 쓴 + Lua 테이블 리터럴에서 오고, `flatten`도 그걸 제자리에서 뮤테이션할 뿐 + (`base/modifier-plan.md`의 "flatten의 정확한 형태" 절). 메타테이블로 + 순회를 갈아끼운 userdata 같은 게 들어올 경로가 **없다.** + - **그래서 일반화 `for k, v in flattened do`가 배열 → 해시 순서를 그대로 + 준다** — 두 층위는 `type(k) == "number"`로 가르면 된다. 순회 1회 절약. + - **옛 근거 (1)은 과했다** — "다른 백엔드가 props를 Lua 테이블이 아닌 + 자료구조로 표현할 수도"는 **`inst`에는 해당해도 `flattened`에는 해당하지 + 않는다**(백엔드가 뭐든 사용자는 Luau로 props를 쓴다). 근거 (2)(숫자/문자열 + 키를 어차피 다르게 취급해야 함)는 그대로 유효하고, 그건 **한 루프 안의 + 분기**로 충분하다. + - **계약 자체는 안 바뀐다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히 + base가 보장하는 것이고, 백엔드가 자기 드라이버를 짜더라도 지켜야 한다. + 바뀐 건 quad-base 자신의 구현이 그 보장을 **몇 번의 순회로 얻는가**뿐. + - **`nil`-hole 위험은 어느 방식이든 동일** — 구멍이 생기면 숫자 키 일부가 + 해시 파트로 밀려 순서가 섞이는데, `#flattened`를 쓰던 옛 방식도 똑같이 + 깨진다. 방어는 그대로 `02`/`06` 스파이크의 nil-hole 규율(`None` 관용구)에 + 맡긴다. + - **⚠️ 스파이크 `01` 재작성 필요** — `luau-test/done/01-two-pass-array-hash-order.luau`가 + 두 루프 버전이라 지금 계약의 구현과 안 맞는다. 단일 일반화 `for` 버전으로 + 재작성하면서 **"배열 파트 전체가 해시보다 먼저 + 배열 안에서는 index + 순서"** 를 그대로 확인할 것. **[정정, 2026-08-18 구현 전 QA] `PreRef`/`PostRef`는 이 보장 위에서 성립하는 게 아니다** — 옛 서술은 `ref-plan.md`의 "PreRef" 절이 "이 보장 위에서 성립"한다고 적었는데, 실제로는 **두 패스 순회보다 더 위의 별도 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index ce18097..35640c0 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -1320,6 +1320,57 @@ return Detach, { old = prev, source = ... } 지금은 `:List` reconcile 한 곳에서만 쓰이지만 `None`도 처음엔 그렇게 시작해 이후 재사용됐으므로 최상위에 두는 게 자연스럽다. 정확한 파일 배치는 M6 구현 시점에 확정. +### ⭐ 소유권은 설치 시점에 정해진다 — `Owned` 옵션 (2026-08-21 구현 전 QA 4라운드 확정) + +위 표("`nil` → 파괴")는 **`:List`가 그 요소를 만든 경우**를 전제한다. 그런데 +`Slot:Add(state)` 라 sugar(`:Single` + 기본 identity `updateFn`, 아래 "반응형 +raw 요소" 절)로 들어온 요소는 **사용자가 `state`에 담아 넘긴 것**이라 Slot이 +죽이면 안 된다 — `state` 교체가 이전 값을 파괴하지 않는다는 확정 +의미론(아래 "`State` 교체는 파괴가 아니라 언마운트" 절)과 정면으로 +부딪히기 때문. 두 답을 다 만족시키는 축이 **"누가 그 요소를 만들었는가"**이고, +그건 사이클마다 달라지는 게 아니라 **설치 시점에 고정되는 속성**이다 +(**사용자 확정, 2026-08-21**: *"unowned 로 나오는 경우가 state 등을 +주는 경우이므로 설치 시점이다에 동의함"*). + +```lua +Slot:List(data, updateFn, keyFn?, opts?) -- opts.Owned: boolean? (기본 true) +Slot:Single(state, updateFn?, opts?) +``` + +| | `Owned = true`(기본) | `Owned = false` | +|---|---|---| +| 누가 요소를 만드나 | `updateFn`(= `:List` 소유) | 사용자(`state`에 담아 넘김) | +| `updateFn`이 새 값 반환 | 밀려난 `prev` **파괴** | **언마운트만** | +| `updateFn`이 `nil`/`None` 반환 | **파괴** | **언마운트만** | +| 키가 데이터에서 사라짐 | **파괴** | **언마운트만** | +| owner가 죽음(`destroySlotTree`/`dispose`) | **파괴**(재귀) | **언마운트만** | +| `Slot:Add(state)` sugar | — | **이걸로 설치됨** | + +- **`Detach`와는 직교하는 축이다** — `Detach`는 "지금은 안 쓰지만 **내 것**" + (사이클마다 달라지는 판단), `Owned = false`는 "**애초에 내 것이 아님**" + (설치 시점에 고정). 그래서 반환값 계열에 "unowned replace" 센티널을 하나 더 + 만들지 않는다 — 만들면 두 의미가 한 이름에 섞인다(사용자 지적: *"의미론이 + 분화한다"*). +- **`destroySlotTree`/`dispose`도 이 플래그를 봐야 한다** — `Owned = false`인 + Slot을 파괴할 땐 자기 요소를 죽이지 않고 언마운트만 한다. 그래서 플래그는 + 클로저 업밸류가 아니라 **Slot 필드**(`slot._owned`)여야 파괴 walk가 읽을 수 + 있다. +- **수동 CRUD와 안 부딪힌다** — 이 플래그는 `:List`/`:Single` 설치 시에만 + 생기고, 그 Slot은 `_listed`라 수동 CRUD가 이미 막혀 있다(위 "`_crudUsed` ↔ + `_listed` 대칭"). +- **혼합은 표현 못 함** — 한 리스트 안에 "내가 만든 것"과 "사용자 것"이 섞이는 + 경우는 이 플래그로 못 가른다. 실사용 사례가 안 보여 지금은 안 다루고, + 필요해지면 `Detach` + 수동 관리로 우회할 수 있다. +- **⚠️ 이름은 `Owned`로 잠정** — `elementOwner`/`claimOwner`/`releaseOwner`와 + 같은 뿌리라 골랐다. 다른 가칭들과 함께 용어 정리 대기열(`question.md` 1번). + +**⚠️ 아직 안 닫힌 짝 항목** — `Detach`로 홀드된 요소를 **어디에 보관하고 +언제 파괴하는지**(`slot._detached` 필드 + owner 죽을 때 `Effect`로 정리)와 +`KeyGone` 센티널은 별개로 확인 대기 중이다. +`qa-request/pre-implementation-qa-round4-followup.md`의 `F-3` 절이 소스 — +**그게 닫히기 전엔 이 절의 "파괴" 칸을 구현하지 말 것**(무엇을 파괴 대상으로 +훑을지가 거기서 정해짐). + - **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가 이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가 직접 부르는 명령형 경로**다. `Detach`는 같은 일을 **reconcile 안에서 @@ -1516,6 +1567,16 @@ nil/None 금지)는 그대로. ### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용 +> **🔭 [2026-08-21] `attachSlot`의 책임 분해가 확장 논의 대기 중** — 아래 +> 의사코드가 지금 정본이고 그대로 유효하지만, 이 함수 하나가 지고 있는 책임이 +> 일곱 개(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치 +> 게이팅, 자식 배치, 재귀)라는 사용자 지적이 있었다 — *"attachSlot 의 기능이 +> 너무 다양해진게 문제같음"*. 특히 **"부모에게 알리는 길이의 최종값은 flush가 +> 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 단일 함수로는 동시에 +> 만족되지 않는다.** 책임 목록·순서 제약의 출처·분해 후보는 +> `research/slot-attach-decomposition.md`에 정리해뒀다. **M2/M3를 막지는 +> 않지만 M6(`:List`) 구현 전엔 결론이 나 있어야 한다.** + **✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는 `Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할 수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md`의 diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 36502cc..d852be9 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -102,7 +102,7 @@ | 파일 | 확인된 것 | |---|---| -| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 | +| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive`의 순서 계약과 `PreRef` 호이스팅의 전제. **⚠️ [2026-08-21 재작성 필요]** 이 스파이크는 숫자 `for` + 일반화 `for` **두 루프** 버전인데, 구현이 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — 검증 대상(순서 계약)은 그대로이고 스파이크 코드만 그 형태로 다시 쓸 것 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 | diff --git a/.claude/qa-request/pre-implementation-qa-round4-followup.md b/.claude/qa-request/pre-implementation-qa-round4-followup.md index d1b6f94..7ff5ec3 100644 --- a/.claude/qa-request/pre-implementation-qa-round4-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round4-followup.md @@ -1,6 +1,10 @@ # 구현 전 QA **4라운드 followup** — 회신 처리 결과 + 재질문 -**상태**: **[2026-08-21] 2차 처리 완료 — 아래 F절이 최신.** +**상태**: **[2026-08-21] 3차 처리 완료 — 아래 G절이 최신.** +`F-4`는 전부 닫혔고(`F-4-3`은 `research/slot-attach-decomposition.md`로 넘어감), +**남은 건 `F-3`의 (1)~(3)과 "+"뿐**(요약은 `G-3`). + +**[2026-08-21] 2차 처리 — 아래 F절.** B절은 전부 확인됐고 C절 결정도 대부분 반영됐다. **지금 열려 있는 건 F절의 `F-3`(`KeyGone`/`Owned` 설계 제안에 대한 확인)과 `F-4`(새로 발견한 불일치 2건 + `setLength` 위치 재질문)뿐이다.** A~E절은 그 처리 과정의 기록. @@ -993,3 +997,75 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" = **안 고친 것**: `C-1`/`C-2`(F-3 동의 대기), `C-5`(F-4-3 판단 대기), `B-7`의 base 표현(`ownerKey` vs "owner" — F-3/F-4가 정리되면 같이). + +--- + +# G. 3차 회신 처리 (2026-08-21) + +## G-1. 반영 완료 + +| 항목 | 회신 | 반영 | +|---|---|---| +| `F-3` (4) | **확인** — *"unowned 로 나오는 경우가 state\ 등을 주는 경우이므로 설치 시점이다에 동의함"* | `slot-plan.md`에 **"소유권은 설치 시점에 정해진다 — `Owned` 옵션"** 절 신설. `Owned=true`(기본)/`false` 대조표, `Detach`와 직교하는 축이라는 것, `destroySlotTree`/`dispose`도 이 플래그를 봐야 하므로 클로저가 아닌 **Slot 필드**(`slot._owned`)여야 한다는 것까지 | +| `F-4-1` | **맞음** — *"`__pairs`/`__ipairs` 직접 구현체를 담은 ud 등을 받는 flattened 는 없고, luau 테이블만 사용하는게 맞음"* | `dispatch-core-plan.md`의 "props 순회 순서" 절을 **"계약은 순서 보장, 구현은 일반화 `for` 한 번"**으로 정정. 옛 근거 (1)("다른 백엔드가 props를 Lua 테이블이 아닌 자료구조로")이 **`inst`엔 해당해도 `flattened`엔 해당 안 됨**을 명시. 스파이크 `01`은 두 루프 버전이라 **재작성 필요**로 `STATUS.md`에 표시 | +| `F-4-2` | **확인** | 이미 반영돼 있던 역순 정정 유지(`modifier-plan.md`) | + +## G-2. `F-4-3` → 확장 논의 자료 준비 완료 + +**회신**: *"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 attachSlot 되는게 +맞을지도. attachSlot 의 기능이 너무 다양해진게 문제같음. 이 부분에 있어서는 +확장 논의를 하게 준비해두자."* + +→ **`research/slot-attach-decomposition.md` 신설.** `setLength`를 어느 줄에 +둘지 고르는 문제가 아니라 분해 문제라는 진단에 동의하고, 논의가 바로 시작될 수 +있게 재료만 모아뒀다(**아무것도 확정 안 함**, `base/slot-plan.md`가 여전히 정본): + +- **책임 일곱(R1~R7)** — 부모 등록(offset)/`:List` 실체화/마운트 상태 전이/ + 부모 등록(length)/배치 게이팅/자식 배치/재귀. 서로 다른 축 넷이 섞여 있음. +- **순서 제약 일곱(C1~C7)과 그 출처** — 전부 실제로 밟은 버그에서 나온 + 것이라(`RC-1`/`RC-3`/`RC-4`, 해제 순서 계약, `C-7` 일반 계약) 분해안이 + 하나라도 깨면 그 버그가 되돌아온다는 걸 표로. +- **⭐ C6 ↔ C7 충돌이 근본** — "부모에게 알리는 길이의 최종값은 flush가 + 끝나야 정해짐"(C6)과 "부기가 물리보다 먼저"(C7)는 **단일 함수 안에서 R4의 + 자리가 하나뿐이라 동시 만족이 불가능**하다. 그래서 `F-4-3`이 자리 선택으로는 + 안 풀렸던 것. +- **분해 후보 넷** — (A) 현행 유지 / **(B) `prepare`+`mount` 2단**(회신의 + "액티베이션 먼저 → length 얻어 밀고 → attach"를 구조화한 것, R6를 부기와 + 물리로 쪼개면 **C6·C7을 둘 다 만족**) / (C) 3단(+`Dispatch.drive`까지 같은 + 모양으로 수렴) / (D) 문서만. +- **같이 정해야 하는 것 6가지** — `activateList`의 Observer `bindLifetime`이 + 어느 단계인지, 얇은 `attachSlot` 래퍼를 남길지, `Dispatch.drive`도 맞출지, + prepare만 하고 mount 안 한 중간 상태 처리, `_mounted` 소비처가 새 정의로도 + 맞는지, 그리고 **`Detach` 정리용 `Effect`를 어디에 설치할지**. +- **순서 권고**: 마지막 항목 때문에 **`F-3`이 먼저 닫히는 게 낫다** — 그 + 결정이 이 분해의 요구사항을 하나 더 얹는다. + +## G-3. 아직 확인 안 된 것 — `F-3`의 나머지 + +**`F-3`은 (4)번만 확인을 받았다.** (1)~(3)과 "+"(dispose 재귀)는 아직 +답이 없어서 **base에 아무것도 안 넣었다.** 요지만 다시 줄이면: + +1. **detached 요소가 영구 누수인 이유** — gcconn 트릭 때문에 quad-제작 + Instance는 자기 시그널 커넥션이 자기를 살린다("참조를 놓는 것만으로는 + 회수되지 않고 반드시 `Destroy`로 회수된다", `lifecycle-pattern.md`) → + **GC 폴백이 아예 없다.** 명시적 정리 경로가 필수. +2. **그래서 detached는 `userdata`가 아니라 `slot._detached` 필드가 들고 + 있어야 한다** — `userdata`는 `:List`에게 opaque라 뭘 죽여야 할지 모르고, + 소유권이 Slot에 남아야 남이 못 가져가며, `destroySlotTree`의 walk가 + 닿아야 한다. 부수 이득으로 **다음 사이클에 `prev`로 그대로 돌려줄 수 + 있어 `ud` 홀드가 불필요**해진다. +3. **owner 죽음 처리는 `Effect`**(제안하신 그대로), 단 **소유 층위는 + `activateList`가 아니라 `attachSlot`/`unmountSlotTree` 쌍** — Slot이 다른 + `physicalTarget`에 재마운트되면 Effect도 옮겨야 하고, 안 그러면 옛 target이 + 죽을 때 **살아있는 Slot의 detached를 파괴**한다. +4. **`KeyGone` 후 "다시 안 묻기"는 자동 성립** — 소멸 루프가 `keyIndex`만 + 도니까 사라진 키는 다음 사이클 대상이 아니다. 남은 미결은 **`KeyGone`에 + `prev`를 그대로 반환하면?**(→ `error` 추천)과 **`index` 인자**(→ `0` 추천). +5. **"+" `dispose` 재귀** — 중첩 Slot 재귀는 **이미 된다**. 안 되는 건 + `_detached`(walk가 안 닿음)와 `userdata` 안의 quad-제작 Instance. 후자는 + `SL-38`의 "GC만으로 정리되는 값만" 제약에 **"quad Instance는 GC로 안 + 죽는다"를 예시로 추가**해야 한다(지금은 `:Subscribe()` Observer만 예시라 + Instance는 안전해 보인다). + +→ **여기 동의가 나오면 `C-1`/`C-2`/`SL-45`/"+"가 한 번에 닫히고, 그때 +`base/` 반영과 `attachSlot` 분해 논의를 이어서 하면 된다.** diff --git a/.claude/question.md b/.claude/question.md index da20231..8164ad1 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -175,6 +175,14 @@ 2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전 필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절. +- **[신설, 2026-08-21 구현 전 QA 4라운드] `attachSlot` 책임 분해** — 한 + 함수가 부모 등록(offset/length) / `:List` 실체화 / 마운트 상태 전이 / 배치 + 게이팅 / 자식 배치 / 재귀를 다 지고 있어서, **"부모에게 알리는 길이의 + 최종값은 flush가 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 동시에 + 만족되지 않는다**(지금은 후자를 지키고 전자를 포기 — 부모 `recompute`가 + 1회 헛돎). 사용자 판단으로 확장 논의 대기 — 책임 목록/순서 제약 출처/분해 + 후보 넷은 `research/slot-attach-decomposition.md`. **M6(`:List`) 착수 전 + 필요**, 선행으로 아래 `Detach` 보관 위치가 먼저 닫히는 게 나음. - **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** — 같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별 claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼 diff --git a/.claude/research/slot-attach-decomposition.md b/.claude/research/slot-attach-decomposition.md new file mode 100644 index 0000000..70f9b2b --- /dev/null +++ b/.claude/research/slot-attach-decomposition.md @@ -0,0 +1,191 @@ +# `attachSlot` 책임 분해 — 확장 논의 준비 자료 + +**상태**: research — **논의 전 준비 자료.** 아무것도 확정하지 않았고, 다음 +논의가 바로 시작될 수 있게 **지금 무엇이 얽혀 있는지**를 한 곳에 모은 것. + +**왜 생겼나**: 2026-08-21 구현 전 QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 +flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가 +아니라고 짚었다 — *"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 +attachSlot 되는게 맞을지도. **attachSlot 의 기능이 너무 다양해진게 +문제같음.** 이 부분에 있어서는 확장 논의를 하게 준비해두자."* + +정본은 여전히 `base/slot-plan.md`의 "재귀 메커니즘" 절 — +**이 문서가 그 확정을 대체하지 않는다.** + +--- + +## 1. `attachSlot`이 지금 하는 일 — 책임 일곱 개 + +`base/slot-plan.md`의 의사코드를 책임 단위로 쪼개면 이렇다(코드는 그 문서가 +소스, 여기선 라벨만 붙임): + +| 라벨 | 하는 일 | 층위 | +|---|---|---| +| **R1** | `offsetSource` 생성 → `Dispatch.setOffsetSource(ownerKey, position, ...)` → `slot.Offset` 공개 | **부모에 대한 자기 등록** | +| **R2** | `slot._listed`면 `activateList` — `:List` reconcile이 `_elements`를 채움(물리 마운트 없음) | **내용 실체화** | +| **R3** | `slot._mounted = true`, `slot._mountedInst = physicalTarget` | **자기 상태 전이** | +| **R4** | `Dispatch.setLength(ownerKey, position, slot.Length)` | **부모에 대한 자기 등록** | +| **R5** | `getBlocker(slot):On()` … `:OffWithoutEmit()` + 마지막 `recompute` | **배치 게이팅** | +| **R6** | flush 루프 — 각 요소의 부기 등록(`setOffsetSource`/`setLength`) **+ 물리 마운트(`Parent` 대입)** | **자식 배치** | +| **R7** | 중첩 Slot 요소에 대해 `attachSlot` 재귀 | **재귀** | + +**서로 다른 축이 넷 섞여 있다** — (a) 부모에게 나를 알리는 일(R1/R4), +(b) 내 내용을 만드는 일(R2), (c) 내 자식을 실제로 붙이는 일(R6), (d) 상태 +플래그와 배치 게이팅(R3/R5). 사용자가 "기능이 너무 다양해졌다"고 한 게 이것. + +**호출부는 셋**: +- `SlotHandler.process`(최상위) — `bindLifetime` 후 `attachSlot(slotValue, inst, inst, k)` +- R7(중첩) — flush 루프 안에서 재귀 +- `rawAdd`(런타임 단건) — 이미 마운트된 Slot에 나중에 nested Slot을 `Add`할 때 + +--- + +## 2. 순서 제약과 그 출처 — 왜 지금 모양이 됐나 + +**이 제약들은 전부 실제로 밟은 버그에서 나왔다.** 분해안을 평가할 때 하나라도 +깨면 그 버그가 되돌아온다. + +| # | 제약 | 왜 | 출처 | +|---|---|---|---| +| **C1** | `slot.Offset`(R1)이 `activateList`(R2)보다 **먼저** | `:List`의 `updateFn`이 `offset`을 인자로 받는 계약 | `slot-plan.md` `:List` 파라미터 | +| **C2** | `activateList`(R2)는 `_mounted == false`인 상태에서 돌아야 함 → **R2가 R3보다 먼저** | 아니면 reconcile의 `rawAdd`가 매 항목마다 즉시 물리 마운트 + `setLength`를 태워 (a) Blocker 없이 `recompute`가 돌고 (b) nested Slot에 `attachSlot`이 **두 번** 불림 | `RC-3`/`RC-4`(QA 3라운드) | +| **C3** | `attachSlot`이 **반환될 때는** `_mounted == true`여야 함 | 런타임 `rawAdd`가 이 플래그로 "지금 붙일까 `_elements`에만 넣을까"를 가름 | `slot-plan.md` `rawAdd` | +| **C4** | `setOffsetSource`가 `setLength`보다 **먼저** | `setLength` 끝의 `gatedRecompute`가 죽는 중인 Source에 `:Set`을 날림 | `dispatch-core-plan.md` 해제 순서 계약 | +| **C5** | R5의 Blocker가 R6 **전체**를 감쌈 | 없으면 등록마다 `recompute`가 돌아 O(N²) | `RC-1` 해결(QA 2라운드) | +| **C6** | R4가 넘기는 `slot.Length`의 **최종값**은 R5/R7이 끝나야 정해짐 | 중첩 Slot 요소의 `.Length`는 그 요소의 `attachSlot`이 돌아야 확정됨 | 구조적 | +| **C7** | 부기 갱신이 물리 트리 조작보다 **먼저** | 백엔드가 "내가 물리적으로 밀어낼 때 부기는 이미 정확하다"를 전제할 수 있어야 함 | `dispatch-core-plan.md` 일반 계약(QA 4라운드 `C-7`) | + +### ⭐ C6와 C7이 정면으로 부딪힌다 — 이게 `F-4-3`의 근본 + +- **C7을 지키려면** R4(부모에게 내 길이 알리기)가 R6(자식 물리 마운트)보다 + 먼저여야 한다. +- **C6를 지키려면** R4는 R6/R7이 끝난 **뒤**여야 최종값을 알 수 있다. + +**지금은 C7을 지키고 C6를 포기했다** — R4가 `slot.Length`(값이 아직 `0`인 +State **객체**)를 넘기고, flush가 끝나 `recompute`가 실제 값을 넣으면 부모가 +Observer로 다시 반응해 스스로 교정한다. 대가는 **배치 밖 재마운트에서 부모 +`recompute`가 2회 도는 것**(뒤에 형제가 있으면 그 offset들이 두 번 `Set`됨). + +**단일 함수로는 둘 다 만족할 수 없다** — 한 함수 안에서 R4의 자리가 하나뿐이기 +때문. 그래서 이건 "어느 줄에 놓을까"가 아니라 **분해 문제**다. + +--- + +## 3. 분해 후보 + +### (A) 현행 유지 — 단일 `attachSlot` + +- C7 지킴, C6 포기(자기 교정 1회). +- **비용**: 배치 밖 재마운트마다 부모 `recompute` 1회 낭비. 크래시도 영구 + 오류도 아님(`SL-58`에 이미 "손대지 않기로" 기록됨). +- **문제**: 사용자가 지적한 "기능이 너무 다양함"은 그대로 남는다. + +### (B) 2단 분리 — `prepare` / `mount` ⭐ 사용자 제안에 가장 가까움 + +*"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 attachSlot 되는게 맞을지도"* +를 그대로 구조화하면 이 모양이 된다. **핵심은 R6를 부기(R6a)와 물리(R6b)로 +쪼개는 것.** + +```lua +-- 개념 스케치. 이름/시그니처 전부 가칭 +local function prepareSlot(slot, physicalTarget, ownerKey, position) + -- R1 + local offsetSource = Source(0) + Dispatch.setOffsetSource(ownerKey, position, offsetSource) + slot.Offset = offsetSource + + -- R2 (여전히 _mounted == false — C2) + if slot._listed then activateList(slot, physicalTarget) end + + -- R5 + R6a + R7-prepare : 부기만, 물리 마운트 없음 + local blocker = getBlocker(slot) + blocker:On() + for i, element in ipairs(slot._elements) do + if isSlot(element) then + prepareSlot(element, physicalTarget, slot, i) -- 재귀 → element.Length 확정 + Dispatch.setLength(slot, i, element.Length) + else + Dispatch.setOffsetSource(slot, i, None) + Dispatch.setLength(slot, i, 1) + end + end + blocker:OffWithoutEmit() + recompute(slot, bk) -- 여기서 slot.Length가 **최종값**으로 확정 (C6 만족) +end + +local function mountSlot(slot, physicalTarget) + slot._mounted = true -- R3 (C3) + slot._mountedInst = physicalTarget + for i, element in ipairs(slot._elements) do + if isSlot(element) then mountSlot(element, physicalTarget) -- R7-mount + else element.Parent = physicalTarget end -- R6b + end +end + +-- 호출부(최상위) +prepareSlot(slotValue, inst, inst, k) +Dispatch.setLength(inst, k, slotValue.Length) -- R4 — 이제 최종값 (C6 + C7 동시 만족) +mountSlot(slotValue, inst) +``` + +- **C6와 C7을 둘 다 만족한다** — 부모에게 넘기는 길이가 처음부터 최종값이고, + 그 등록이 어떤 `Parent` 대입보다도 먼저다. +- **각 함수의 일이 하나로 좁아진다** — `prepareSlot` = "부기를 정확하게 + 만든다", `mountSlot` = "그 부기대로 트리에 붙인다". +- **`_mounted`의 의미가 정직해진다** — 지금은 "`activateList`는 지났고 flush는 + 아직"이라는 어정쩡한 중간 시점인데, 분리하면 문자 그대로 "mount 단계를 + 지났는가"가 된다. +- **비용**: `_elements` 순회가 2회로 늘고, 호출부가 **셋 다** 두 함수를 순서대로 + 불러야 한다(빠뜨리면 half-attached 상태). + +### (C) 3단 분리 — `register` / `activate` / `mount` + +R1(부모 등록)까지 따로 떼는 안. `Dispatch.drive`의 최상위 배열 파트도 같은 +3단으로 맞추면 "배치 등록 → 실체화 → 마운트"라는 하나의 모양이 코퍼스 전체에 +반복된다. + +- **이득**: `Dispatch.drive`와 `attachSlot`이 지금 서로 비슷한데 미묘하게 + 다른 구조(전자는 Blocker + 두 패스, 후자는 Blocker + flush)인 걸 하나로 + 수렴시킬 수 있음. +- **비용**: 단계가 하나 더 늘고, 호출부가 셋 → 셋 × 3단이 됨. (B)의 이득 + 대부분을 (B)만으로 이미 얻으므로 **추가 이득이 뭔지가 논의 대상**. + +### (D) 최소 변경 — 분리 없이 문서만 + +`attachSlot` 안을 R1~R7 주석 블록으로 명시하고 각 제약(C1~C7)을 그 자리에 +달아둠. 코드는 그대로. + +- **이득**: 위험 0. **비용**: 근본 문제(C6/C7 충돌, 책임 과다)는 안 풀림. + +--- + +## 4. 어떤 분해든 같이 정해야 하는 것 + +1. **`activateList`의 `data:Observer(fn)` `bindLifetime`은 어느 단계인가.** + 지금은 `activateList` 안에서 `bindLifetime(inst, observer)`를 부르는데, + (B)에서 그건 prepare 단계다 — 아직 아무것도 물리적으로 안 붙은 시점에 + `physicalTarget`에 생명주기를 묶는 게 맞는지. +2. **호출부를 감싸는 얇은 `attachSlot`을 남길지.** 남기면 `SlotHandler.process`/ + `rawAdd`가 지금처럼 한 줄로 끝나고 "한쪽만 부르는" 오용도 막힌다. 대신 + "결국 다시 한 함수"라 분해의 이득이 반쯤 희석된다. +3. **`Dispatch.drive`도 같은 모양으로 맞출지**(후보 (C)와 직결). +4. **prepare만 하고 mount 안 한 중간 상태를 어떻게 다룰지** — 방어할지, UB로 + 둘지. 코퍼스 기조상 UB + 문서화가 자연스러워 보이지만 확인 필요. +5. **`_mounted` 소비처가 새 정의로도 맞는지** — (a) `:List()`가 마운트 이후에 + 불릴 때 즉시 `activateList`하는 분기, (b) `rawAdd`의 "붙일까 말까" 분기. + (B)에서는 `_mounted`가 mount 단계에서 켜지므로, prepare와 mount 사이에 + `:List()`가 불리는 경로가 있는지 따져야 한다. +6. **`Detach` 정리용 `Effect`(QA 4라운드 `F-3`)를 어디에 설치할지** — 그건 + `physicalTarget`에 묶이므로 mount 단계가 자연스럽다. **`F-3`이 먼저 + 닫히는 게 순서상 낫다** — 그 결정이 이 분해의 요구사항을 하나 더 얹는다. + +--- + +## 5. 지금 상태 요약 + +- **확정된 건 없다.** `base/slot-plan.md`의 단일 `attachSlot`이 여전히 정본. +- **급하지 않다** — (A)로 두어도 동작은 맞고(자기 교정), M2/M3 착수를 막지 + 않는다. 다만 **M6(`:List`) 구현 전에는 정해두는 게 낫다** — 그 시점에 + `activateList`/`rawAdd`/`attachSlot`을 실제로 짜기 때문. +- **선행 항목**: 위 4-6번대로 `F-3`(`Detach` 보관 위치 + `KeyGone`)이 먼저 + 닫히면 이 논의의 요구사항이 완전해진다. diff --git a/.claude/todos.md b/.claude/todos.md index bcad2cc..7ce1437 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -8,16 +8,18 @@ 00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — 1·2·3라운드는 전부 `base/`에 반영 완료, [2026-08-19 신설] 4라운드는 문항지 작성만 끝나고 사용자 회신 대기 중.** - **4라운드 — [2026-08-21] 회신 2차 처리 완료, F절만 열려 있음.** 문항지는 + **4라운드 — [2026-08-21] 회신 3차 처리 완료, `F-3` 일부만 열려 있음.** 문항지는 `.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은 `-response.md`, 처리 결과·재질문·판단 대기 항목은 **`-followup.md`가 소스**(여기서 목록을 세지 않음). 회신 중 판단이 명확했던 것은 그 자리에서 `base/`에 반영했다. **B절(설명 보강 재질문)은 2차 회신으로 전부 확인됐고, C절 결정도 대부분 반영 완료** — 지금 열려 있는 건 followup **F절**뿐이다: - `F-3`(`KeyGone` 홀드 + `Owned` 설치 플래그 설계 제안에 대한 확인 — - 여기 동의가 나오면 `C-1`/`C-2`/`SL-45`/`dispose` 재귀가 한 번에 닫힘), - `F-4`(단일 일반화 `for` 전환 여부 / flatten 반복 방향 / `setLength` 위치). - 아래는 그 회신 전 서술: + `F-3`의 **(1)~(3)과 "+"**(detached 요소를 `slot._detached` 필드가 들고 + owner 죽을 때 `Effect`로 정리 / `KeyGone` 세부 / `dispose` 재귀) — 요약은 + followup `G-3`. `F-3`의 `Owned` 설치 플래그와 `F-4` 셋은 전부 닫혔고, + `F-4-3`(`setLength` 위치)은 **`attachSlot` 책임 분해**라는 더 큰 논의로 + 넘어가 `research/slot-attach-decomposition.md`에 준비 자료를 만들어뒀다 + (M6 착수 전 필요). 아래는 그 회신 전 서술: **(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로