qa: 구현 전 QA 5라운드 — 문항지·회신·전량 반영 + 감사 2라운드
4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설(205문항). 범위를 셋으로 좁힘 — (1) 4라운드에 문항이 아예 없던 영역(project-setup / quad-types, 그리고 문서가 아니라 실제 커밋된 M1 코드), (2) 그 이후 확정된 것 (Detach/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의 심화. 회신을 4차에 걸쳐 받아 전량 반영했고, 커밋 전 감사를 각도를 바꿔 2라운드 돌렸다. 주요 확정/역전: - slot._detached lazy화, KeyGone엔 새 값 반환도 error, Owned=false에서 Detach는 _detached에 안 들어감(rawUnmount) - Slot:Replace 신설 + rawReplace/rawAdd 의사코드 신설(문서에 정의가 없었음) - raw* 인자를 index로 전부 통일 — 오래 열려 있던 캐비엇 종결. 래핑은 raw* 바깥에서만(공개 표면 + settle), raw*는 물리 요소만 다룸 - 물리 조작을 주입 op로(mountInst/unmountInst/disposeInst, 이름 가칭) — base는 Parent를 모른다는 지적. mountInst는 0-based 절대 offset을 받음 - Dispatch.setLength에 anchor(생략 시 ownerKey) — 부기 키와 생명주기 앵커 분리, 4라운드 D-56 역전(archive로) - Dispatch.getOffsetAt 신설(pull) + 접두합 캐시(offsetDirtyFrom), setOffsetSource(None)은 얼리 리턴, None의 뜻을 "발행 채널 없음"으로 정정 - recompute가 owner 베이스에서 시작(중첩 offset이 부모 베이스를 못 받던 결함), _baseObserver로 깊은 전파, Offset Source identity 재사용(포탈), bk.N or 0(빈 Slot 크래시) - Effect(fn, ...deps) 확정 — Ref도 의존성(옛 "trailing args sugar 안 만듦" 역전), Tween:Mapped, groupClaimKeys 키 = (inst, groupValue) → k - 게이팅 먼저(M2로 앞당김) — 다만 대상이 Blocker가 아니라 공용 Gate 노드로 바뀌었고, 설계는 사용자 지시로 다음 세션(M2 착수를 막는 유일한 항목) 새 research 둘: gate-primitive.md(다음 세션이 이어받을 재료), state-epoch-validation.md(전파 중 Get이 섞인 값을 캐시하는 glitch — 정확성 결정이라 M3 전 결론 필요). 감사가 잡은 것 중 큰 것: 확정한 Owned가 Slot:List 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, effect-plan.md의 역전 배너 없는 자기모순, 그리고 손대지 않은 문서(ROADMAP 백로그·debounce-throttle-plan)가 "Gate는 M3에서"로 남아 있던 사각지대. doc-check.py ERROR 0. 상세는 qa-request/pre-implementation-qa-round5-followup.md (A~K절, 마지막이 최신). Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
d5e5e1a1b9
commit
c6fdf1b348
22 changed files with 3708 additions and 194 deletions
|
|
@ -24,7 +24,7 @@
|
|||
| `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) |
|
||||
| `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 |
|
||||
| `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 |
|
||||
| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음** — 사용자 지시로 **5라운드 문항지는 만들지 않는다**). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 |
|
||||
| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 research 문서 둘(`gate-primitive.md`·`state-epoch-validation.md`)까지. **처리 결과의 소스는 이 파일**). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 |
|
||||
| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) |
|
||||
| `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-19 기준] 폴더 자체가 아직 없음**(M0/M1 스캐폴딩만으론 안 생기고 실제로 렌더링해보고 쓰는 단계부터, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 |
|
||||
| `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` |
|
||||
|
|
@ -92,6 +92,8 @@
|
|||
| `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 |
|
||||
| `slot-attach-decomposition.md` | **[2026-08-21 신설, 같은 날 확정]** `attachSlot`의 책임 분해 근거 기록 — **결론은 후보 (B) 분해 채택**이고 `base/slot-plan.md`에 반영 완료(`materializeSlotTree`+`mountSlotTree`+두 줄짜리 `attachSlot` 래퍼). 아래는 그 논의의 출발점. QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 flush 앞/뒤 어디에 둘지가 갈렸는데, 사용자가 그건 자리 선택 문제가 아니라 *"attachSlot 의 기능이 너무 다양해진게 문제"* 라고 짚어 확장 논의로 넘어간 것. 지금 `attachSlot`이 지고 있는 책임 일곱(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치 게이팅, 자식 배치, 재귀)과 순서 제약 일곱(각각 `RC-1`/`RC-3`/`RC-4` 등 실제로 밟은 버그 출처까지)을 모아두고, **C6(길이 최종값은 flush 뒤에야 정해짐)와 C7(부기가 물리보다 먼저)이 단일 함수로는 동시 만족 불가능**함을 보인 뒤 분해 후보 넷(현행 유지 / `prepare`+`mount` 2단 / 3단 / 문서만)을 대조. 사용자 확정 논거는 *"지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 생각함"*. 정본은 여전히 `base/slot-plan.md` | 하 — **[2026-08-21] 결론 확정·반영 완료**, 이제 근거 기록용 |
|
||||
| `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리(**[2026-08-19]** 그 질문은 `base/debounce-throttle-plan.md`로 전부 해소·승격 완료), `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State<State<T>|T>` → `State<T>` 평탄화 항목 신설(백로그) — `State<State<T>>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 |
|
||||
| `gate-primitive.md` | **[2026-08-21 신설]** `Blocker`가 쓰는 게이티드 State 노드를 공용 `Gate`로 일반화 — 상류 emit을 가로채 내려보낼지 정책이 정하는 노드. `Blocker`/`Debounce`/`Throttle`이 그 위의 정책이 됨. **방향은 사용자 확정(게이팅을 M2로 앞당김), 이름(`Gate` vs `Gater`)·표면·`Blocker`와의 결합 방식이 미정** | **상 — M2 착수 전 필요**(`Dispatch.drive`의 배치 등록이 이 게이팅을 전제) |
|
||||
| `state-epoch-validation.md` | **[2026-08-21 신설]** State 재계산 판정을 `invalid` 플래그에서 **루트 Source 에포크 비교**로 바꾸는 안(사용자 제안) — DFS 전파 도중 `Get()`이 섞인 값을 캐시하는 glitch를 없앰. 성능이 아니라 **정확성** 결정이고 State 내부 표현을 바꾸므로 M3 전에 결론 필요. 에이전트 분석: 진단·방향 모두 타당. **[같은 날 후속 확정] 중복 *통지*도 같은 장치로 접고**(emit이 `(source, count)`를 실어오므로 판정 O(1)), "선언 안 된 의존성 UB 명문화"는 사용자 기각으로 빠짐 — 대신 노드가 `seen`(전파 dedup)/`computedAt`(캐시 검증) 두 카운트를 따로 들어야 한다는 구현 요구가 생김 | **상 — M3(State 구현) 착수 전 필요** |
|
||||
| `quad-recursive-acronym.md` | **[2026-08-14 신설]** GNU/WINE류로 `Quad`를 재귀 약어화하는 카피 브레인스토밍 — 설계 결정도 착수 게이팅도 아니고 나중에 README.md 헤딩 등에 쓸 캐치프레이즈 후보 모음. 자학 개그 방향(기각)과 지연평가/재귀·커링/펑터/클로저를 자랑하는 방향(채택 후보, 미확정) 정리 | 하 — 카피 소재, 설계 상의 필요 없음. 사용자가 최종 문구 고르면 반영 |
|
||||
| `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 |
|
||||
| `doc-include-plan.md` | **[2026-08-14 신설]** 문서 stale 감소용 include 도구 `doc-include.py`(가칭) — 원본 파일에 `<!--#summary-->` 류 마커로 요약 구간을 표시해두면 인용하는 문서가 그 구간을 기계적으로 추출해 붙여넣게 하는 도구. `doc-check.py`(사후 탐지)와 짝을 이루는 사전 차단 장치. AsciiDoc tagged include/markdown-magic이 선례, build vs buy 검토 후 Python 표준 라이브러리로 직접 제작(~100줄) 채택. 파일럿은 `.claude/session-summary.md` ← `.claude/session/*.md` 요약 마커부터(CLAUDE.md 분할로 목적지가 "통째로 생성되는 파일"이 돼 단방향 생성으로 단순화됨) | 하 — M0/설계 게이트와 무관한 메타 도구. **[2026-08-16 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) |
|
||||
|
|
@ -124,6 +126,7 @@
|
|||
| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(2026-08-14 다섯 번째 세션에 폐기됐다가 열한 번째 세션에 별도 진입점으로 재도입 — `canExecute`와 판정 로직만 공유)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State<Slot?>` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 |
|
||||
| `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` |
|
||||
| `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State<State<T>>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 |
|
||||
| `bindlifetime-slot-owner-reversed.md` | **[역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`]** "`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하고 `isBoundAlive`에 세 번째 분기를 둬라"(4라운드 `D-56`) — `Dispatch.setLength`가 **부기 키와 생명주기 앵커를 따로 받도록** 바뀌면서 요구사항 자체가 사라짐(앵커는 언제나 물리 target, 모든 호출부가 이미 그 값을 안다). 부수로 형태 미정이던 "세 번째 분기" 항목도 같이 닫힘. 현행은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단 |
|
||||
| `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime`이 `.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`(**2026-08-14 열한 번째 세션에 별도 진입점으로 재도입 — 이 문서 하단에 addendum**)와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` |
|
||||
| `tag-attribute-load-time-registration-reversed.md` | **[역전됨, 2026-08-14 열두 번째 세션 신설, 2026-08-18 재역전으로 원래 결론 쪽이 다시 현행]** "`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 quad-base 모듈 로드 시점에 스스로 등록한다"(열한 번째 세션 "네 번째, 최종 정정") — 2026-08-14 열두 번째 세션엔 `base/lifecycle-pattern.md`가 거부한 `InitNamespace`류 top-level 부작용 패턴과 같은 클래스라며 틀렸다고 보고, 등록 주체를 백엔드 팩토리로 정정했었음. **그런데 2026-08-18 구현 전 QA에서 다시 뒤집힘(`D-7`)** — 백엔드 팩토리가 등록 주체면 quad-roblox를 아예 안 붙인 상태에서 "provider가 초기화됐는지" 안내 경로 자체가 안 돌기 때문. **지금 현행은 이 문서가 역전이라 부르던 원래 결론(quad-base 자신이 로드 시점에 등록)과 같은 방향** — 상세는 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절, 재역전 논의 원문은 `qa-request/pre-implementation-qa-round1.md`의 "D-7" 절 |
|
||||
|
||||
|
|
|
|||
57
.claude/archive/bindlifetime-slot-owner-reversed.md
Normal file
57
.claude/archive/bindlifetime-slot-owner-reversed.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# [역전됨, 2026-08-21] `bindLifetime`의 첫 인자가 Slot일 수 있다는 요구사항
|
||||
|
||||
**상태**: 역전됨. 2026-08-20 구현 전 QA 4라운드 `D-56`에서 확정돼
|
||||
`base/lifecycle-pattern.md`의 `(1-1)` 절로 들어갔다가, **2026-08-21 구현 전 QA
|
||||
5라운드 `C-4`에서 통째로 뒤집혔다.**
|
||||
|
||||
**왜 뒤집혔나**: 사용자 지적 — *"애초에 Slot 이 effect 나 다른 요소들을 소유할
|
||||
수가 없다 … 실제 observer/effect 는 실제 inst 에 불림. slot in slot 에서 slot 을
|
||||
유지하는건 이미 slot 의 강참조 배열이 해결해주는데, 우리가 왜 slot 을 소유 대상으로
|
||||
둘 수 있게 한거였는지 다시 생각해봐야할 부분인듯?"* 검토 결과 **부기 키와
|
||||
생명주기 앵커를 한 인자가 겸하고 있던 게 원인**이었고, `Dispatch.setLength`가
|
||||
`anchor`를 따로 받도록 바꾸는 것만으로 이 요구사항 전체가 사라졌다. 지금 유효한
|
||||
결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단과
|
||||
`base/lifecycle-pattern.md`의 `(1-1)` 포인터.
|
||||
|
||||
**부수 효과**: 이 절이 요구하던 `isBoundAlive`의 "세 번째 분기"(gcconn도
|
||||
`.Subscribed`도 없는 Slot-owned 바인딩을 판정하는 분기)는 **형태가 미정인 채로
|
||||
열려 있던 항목**이었는데, 역전과 함께 필요 자체가 없어졌다.
|
||||
|
||||
---
|
||||
|
||||
## 역전 전 원문
|
||||
|
||||
#### (1-1) ⚠️ 첫 인자가 물리 Instance가 아닐 수도 있다 — 백엔드가 반드시 핸들링할 것 (2026-08-20 구현 전 QA 4라운드 `D-56`)
|
||||
|
||||
**위 구현 스케치는 `inst`가 항상 Roblox Instance라고 가정하고 `InstData`에서
|
||||
gcconn/gchold를 찾는데, 실제 호출부 중엔 `inst` 자리에 `Slot`이 오는 경로가
|
||||
이미 있다.** `Dispatch.setLength(ownerKey, i, len)`이 그것 —
|
||||
`base/dispatch-core-plan.md`의 "`setLength` 구현" 절이 `bindLifetime(ownerKey,
|
||||
observer)`를 부르는데, 그 `ownerKey`는 Slot-in-Slot 중첩에서 **Slot 자신**이다
|
||||
(`base/slot-plan.md`의 "재귀 메커니즘" 절 — `attachSlot`이 `ownerKey`로 자기
|
||||
자신을 넘겨 최상위/중첩을 같은 함수로 통합한 그 설계).
|
||||
|
||||
**사용자 판정(2026-08-20)**: *"ownerKey 가 Slot일 수도 있음. 각 엔진의
|
||||
bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong
|
||||
으로 gchold 비슷한걸 수행하면 됨."*
|
||||
|
||||
- **계약 두 개(위 절)는 그대로 유지된다** — 바뀌는 건 "그 계약을 무엇으로
|
||||
구현하는가"뿐. 물리 Instance면 gcconn 트릭이 두 계약을 다 만족시키고,
|
||||
Slot이면 **Slot 자신이 살아있는 동안 `value`를 붙잡는 강참조**(Slot 안의
|
||||
필드든, `Relate(slot)`에 `SetStrong`이든)와 **`value`가 그 Slot의 생존을
|
||||
되물을 수 있는 근거**를 백엔드가 제공하면 된다.
|
||||
- **왜 gcconn을 못 쓰는가**: gcconn 트릭은
|
||||
`inst:GetPropertyChangedSignal("ClassName")`에 의존하므로 엔진 객체가 아닌
|
||||
값(Slot은 평범한 Lua 테이블)엔 걸 수가 없다. Slot은 대신 **자기 자신이
|
||||
reachable한가**가 곧 생존이라, `Relate(slot)`가 weak-keyed인 것만으로
|
||||
"Slot이 죽으면 기록도 같이 사라진다"가 성립한다.
|
||||
- **`isBoundAlive`의 판정 분기도 이 경로를 알아야 함** — 지금 코드는
|
||||
gcconn이 없으면 곧바로 `.Subscribed` 폴백으로 떨어지는데, Slot-owned
|
||||
바인딩은 gcconn도 `.Subscribed`도 없어서 **살아있는데 `canBound`가 참으로
|
||||
잘못 나온다**(= 이중 바인딩 가드가 이 경로에선 안 걸림). 백엔드 구현이
|
||||
세 번째 분기를 추가하거나, Slot 쪽 홀더 존재 자체를 판정 근거로 삼아야 함.
|
||||
- **⚠️ 정확한 형태는 아직 미확정 — M2/M3 구현 시 확정할 것.** "Slot 안"(필드)
|
||||
이냐 "바깥"(`Relate`)이냐, `isBoundAlive`의 세 번째 분기를 어떤 모양으로
|
||||
둘지가 열려 있다. 지금 확정된 건 **"첫 인자가 Instance라고 가정하면 안
|
||||
된다"는 요구사항 자체**뿐.
|
||||
|
||||
|
|
@ -291,7 +291,7 @@ quad/
|
|||
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
|
||||
└── src/
|
||||
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
|
||||
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
|
||||
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드, 이름 가칭] mountInst(target,element,index)/unmountInst(element)**(물리 트리 조작 — base는 `Parent`를 모른다, 확정 시 이 목록에 정식 등재) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
|
||||
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
|
||||
├── Handlers/
|
||||
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)
|
||||
|
|
|
|||
|
|
@ -244,9 +244,23 @@ end
|
|||
바람. 다만 충분하다고 생각하는 이름임."*). `nameClaims`(이름 → 그 이름을
|
||||
잡은 키 객체)와 나란히 놓이는 두 번째 레지스트리이고, 이름 자체가
|
||||
"그룹이 claim한 키들"을 가리켜 역할이 드러남.
|
||||
- **⚠️ 키 설계는 여전히 미정** — 확정된 건 이름뿐이고, `(inst, groupValue) → k`
|
||||
인지 `groupKey` 단위인지, 그리고 `nameClaims`와 어떤 순서로 확인하는지는
|
||||
M10 구현 전에 정한다. `question.md` 참고.
|
||||
- **✅ [해소, 2026-08-21 구현 전 QA 5라운드 `AT-1`] 키는 `(inst, groupValue) → k`로
|
||||
확정.** **사용자 판정**: *"(inst, groupValue) → k 이면 충분하다. group 에 따라
|
||||
key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 필요가 없고, 그건 key->name
|
||||
이 유일성을 검증해준다. {a,a} 는 단순 그룹이 이미 할당된 키가 있나를 보기만
|
||||
위함임."*
|
||||
- **왜 이걸로 충분한가**: 이 레지스트리가 잡아야 하는 건 **같은 그룹 객체가
|
||||
같은 `inst`의 두 위치에 놓이는 것** 하나뿐이다. 서로 다른 그룹끼리의 충돌은
|
||||
`groupKey(v, name)`가 그룹별로 다른 키 객체를 만들고 그 키의 이름 claim을
|
||||
`nameClaims`가 검증하므로 이미 걸린다 — 여기서 다시 볼 필요가 없다.
|
||||
- **판정**: `process` 시점에 `groupClaimKeys[inst][groupValue]`를 보고, 비어
|
||||
있으면 `k`를 기록하고 통과, **이미 다른 `k`가 있으면 error**. retract에서
|
||||
자기 `k`일 때만 지운다(위 "해제 → 재클레임 순서" 항목의 순서 보장 위에서
|
||||
성립).
|
||||
- **`nameClaims`와의 순서**: 위치 claim이 **먼저**다 — 같은 그룹의 두 번째
|
||||
위치는 이름 claim까지 갈 것 없이 그 자리에서 거부되어야 하고, 그래야
|
||||
`nameClaims`에 절반만 기록되는 중간 상태가 안 생긴다.
|
||||
- 이걸로 `Frame { a, a }` 갭(구 `AT-11`/5라운드 `AT-2`)도 **같이 닫힌다.**
|
||||
- **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게
|
||||
**정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`)
|
||||
— 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹
|
||||
|
|
|
|||
|
|
@ -1101,8 +1101,11 @@ additional-primitives-plan.md`가 원래 "안 만들어도 된다"고 판단했
|
|||
|
||||
**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면(`setTimeout`/
|
||||
`clearTimeout`) + `Blocker`(gated state) + `Ref`. 전부 M3/M8 안에서
|
||||
확정되는 것들이라 그 이후 언제든 얹을 수 있다. **`Blocker` 구현
|
||||
시점(M3)에 게이트 노드를 공용으로 빼두는 것만은 여전히 그 시점에 해야
|
||||
함** — 1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번
|
||||
설계하게 됨. 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무
|
||||
확정되는 것들이라 그 이후 언제든 얹을 수 있다. **[정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를
|
||||
공용으로 빼는" 작업은 M3가 아니라 M2로 앞당겨졌다** — 사용자 결정
|
||||
"게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에 의존하므로).
|
||||
1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 설계하게 되는
|
||||
것은 그대로이고, 바뀐 건 **언제**뿐이다. 표면/이름은 아직 미정 —
|
||||
`research/gate-primitive.md`가 소스(이 문서의 1절이 그 일반화를 처음
|
||||
권고한 자리로 거기 인용돼 있다). 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무
|
||||
때나 나중에 얹으면 된다.
|
||||
|
|
|
|||
|
|
@ -741,6 +741,10 @@ end
|
|||
Dispatch 핸들러가 아니라 독립 탑레벨 유틸이지만, `isSlot`이 아닌 값은
|
||||
`elementOwner` 같은 순수 부기 판정 뒤에 마지막 한 줄만
|
||||
`disposeInst(inst: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) —
|
||||
**[2026-08-21 5라운드] 같은 계열로 `mountInst(target, element, index)`/
|
||||
`unmountInst(element)`가 추가될 예정**(base는 `Parent`를 모른다는 지적에서
|
||||
나옴, `base/slot-plan.md`의 "물리 조작은 주입 op다" 절). **이름은 아직
|
||||
가칭**이라 이 목록에 정식 등재하는 건 그 확정 시점에 한다 —
|
||||
2026-08-14 열 번째 세션에 같은 원칙으로 확정.
|
||||
- **backend 소유**: `Property`/`Event`/`OnChange`(Reflection·시그널 같은
|
||||
엔진 개념 자체가 로직), `InstanceChild`, `Slot`의 실제 부모 조작
|
||||
|
|
@ -1259,9 +1263,13 @@ Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드
|
|||
**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**:
|
||||
|
||||
```lua
|
||||
Dispatch.setLength(inst, i, len: number | State<number>)
|
||||
Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
|
||||
Dispatch.setLength(ownerKey, i, len: number | State<number>, anchor?)
|
||||
Dispatch.setOffsetSource(ownerKey, i, offset: Source<number> | None)
|
||||
Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 자리의 절대 offset
|
||||
```
|
||||
**[2026-08-21 5라운드]** `anchor`는 생명주기 앵커(생략 시 `ownerKey`, 자세한
|
||||
건 아래 "`setLength` 구현" 절), `getOffsetAt`은 발행 채널(`Source`) 없이
|
||||
숫자만 필요한 쪽(물리 삽입 위치 등)이 쓰는 pull 경로.
|
||||
|
||||
**[2026-08-11 세션] 첫 인자(`inst`)는 물리 Instance일 필요가 없음 —
|
||||
`Relate`가 weak table 기반이라 아무 테이블이나 키로 가능.** 이 사실을
|
||||
|
|
@ -1320,8 +1328,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
|
|||
강제하지 않음), 반응형이 필요하면 자기 `userdata` 안에 직접 `Source`를
|
||||
만들어 `Frame { LayoutOrder = layoutOrder:With(offset):Compute(fn) }`처럼
|
||||
써넣으면 됨 — 새 메커니즘 불필요. 상세는 `base/slot-plan.md`의
|
||||
`Slot:List` 절 참고. **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에
|
||||
참여할 게 없다는 명시적 선언. 대상은 일반 `Ref`뿐 아니라 **그 배열
|
||||
`Slot:List` 절 참고. **⭐ [정정, 2026-08-21 G절] `None`은 "발행 채널이 없다"는 뜻이다** —
|
||||
옛 서술은 "실제 마운트를 하지 않는 위치"였는데, **plain 요소도 `None`을
|
||||
등록**(마운트는 하지만 그 자리의 offset을 반응형으로 받아볼 소비자가 없음)하므로
|
||||
정확하지 않았다. **순서 계산에 참여하는지는 `setLength`가 답한다**(0이면 안
|
||||
차지). 숫자가 필요하면 채널 유무와 무관하게 `Dispatch.getOffsetAt(ownerKey, i)`을
|
||||
부르면 된다. 아래 "짝을 맞춰 `0`" 규칙은 **값이 정말 없는 자리**(Ref/`nil`)에만
|
||||
해당한다 — plain 요소는 `None` + `setLength(1)`이 정상이다. 대상은 일반 `Ref`뿐 아니라 **그 배열
|
||||
위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로
|
||||
캐우칭된 미전달 Ref) — `setLength`도
|
||||
같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의
|
||||
|
|
@ -1461,6 +1474,13 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
|
|||
거는 것처럼 순수하게 사용자 코드가 만드는 경우뿐인데, 이건 이미 확정된
|
||||
"일반적인 재진입/무한루프는 방어 안 함, provider/사용자 코드 버그로
|
||||
간주"(2026-08-04) 원칙 그대로 두면 됨 — 별도 가드를 만들 근거가 없음.
|
||||
**⭐ [2026-08-21 5라운드 `DC-14`] 게다가 그 마지막 경로조차 사용자에겐
|
||||
막혀 있다** — `updateFn`이 도는 Slot은 정의상 `_listed`이고, `_crudUsed` ↔
|
||||
`_listed` 상호 배타 가드(`base/slot-plan.md`) 때문에 그 Slot의 공개 CRUD가
|
||||
이미 error다(사용자 지적: *"외부 입장에서는 그럴 방법이 없어보인다. crud 가
|
||||
list 시에는 더이상 불가능해지기 때문"*). 즉 같은 `(ownerKey, bk)` 재진입은
|
||||
**정상 API로는 만들 수 없다** — `updateFn` 안에서 *다른* Slot을 건드리는 건
|
||||
다른 `bk`라 무관하다.
|
||||
**결론: `recompute`는 off-by-one만 고친 순수 버전으로 유지, 재진입
|
||||
가드 없음.**
|
||||
|
||||
|
|
@ -1477,12 +1497,98 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `
|
|||
아니라 이미 있는 단방향 흐름 원칙을 recompute라는 구체 지점에 적용한
|
||||
것뿐, 그래서 별도 방어 로직도 필요 없음.
|
||||
|
||||
**⭐ [2026-08-21 구현 전 QA 5라운드 G절, 사용자 확정] 이 절이 두 번 고쳐졌다.**
|
||||
`mountInst`에 물리 삽입 위치를 어떻게 주느냐는 질문에서 결함 둘이 드러났고,
|
||||
사용자가 제시한 방향으로 정리됐다:
|
||||
|
||||
1. **`sourceList`의 `None`은 "발행 채널 없음"만 뜻한다** — 예전엔 "실제 마운트를
|
||||
하지 않는 위치"라고 정의해놓고 정작 plain 요소를 `None` + `setLength(1)`로
|
||||
등록하고 있었다(그래서 그 자리의 offset 숫자가 **계산조차 안 됐고**, DOM류
|
||||
백엔드가 삽입 위치를 알 방법이 없었다). **참여 여부는 `lengthList`가 이미
|
||||
표현하므로** `sourceList`는 "반응형으로 받아볼 채널이 있나"만 답하면 된다.
|
||||
2. **숫자가 필요한 쪽은 `Dispatch.getOffsetAt(ownerKey, i)`로 직접 뽑는다**
|
||||
(사용자 제안: *"setOffsetSource 에선 source 를 받으면 그건 set 해주지만,
|
||||
아니면 그냥 얼리리턴에 None 으로만 둬주고, getOffsetAt 은 직접 호출하는걸로"*)
|
||||
— 모든 자리에 숫자를 밀어 넣는 네 번째 병렬 배열을 만들지 않고 **pull로**
|
||||
둔다("관측해야 실체화된다" 원칙과 같은 결).
|
||||
3. **`sum`이 owner의 자기 offset에서 시작한다** — 예전엔 `0`이라 **depth ≥ 2에서
|
||||
중첩 Slot의 자식 offset이 부모 베이스만큼 어긋났다**(depth 1만 쓰던 동안
|
||||
드러나지 않았음). **베이스를 따로 저장하지 않는다** — Slot이면 자기
|
||||
`.Offset`이 곧 그 값이고(부모가 먼저 설정해주므로 이미 정확), 최상위 물리
|
||||
inst엔 베이스라는 개념이 없어 항상 0이다. 한때 `bk.base` 필드에 복사해두는
|
||||
안을 적었다가 **사용자 지적으로 걷어냈다**: *"bk.base 가 왜 필요한거임? …
|
||||
이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데, 부모 slot 의 offset 읽는게
|
||||
이미 정확해 … 최상위에선 애초에 base자체가 없지 않아? 항상 0 일텐데."*
|
||||
같은 값을 두 곳에 두면 갈라진다는, 이 코퍼스가 반복해서 물린 패턴 그대로다.
|
||||
**`isSlot` 분기가 남는 건 타입 분기라서가 아니라 검사할 다른 방법이 없어서다**
|
||||
— `ownerKey.Offset`을 그냥 인덱싱해 확인하는 duck-typing은 Roblox userdata에서
|
||||
정의 안 된 키 인덱싱이 에러를 던질 수 있어 금지돼 있다(`base/brand-plan.md`의
|
||||
duck-typing 기각 근거).
|
||||
4. **깊은 전파를 위해 중첩 Slot은 자기 `Offset`을 관측한다** — 앞 형제의 길이가
|
||||
변해 자기 베이스가 밀리면 자기 자식들의 offset도 다시 계산돼야 한다(사용자:
|
||||
*"자식 slot 의 offset 을 다시 설정해주기 위함이구나. offset의 깊은 전파를
|
||||
위한거군"*).
|
||||
|
||||
```lua
|
||||
-- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환.
|
||||
-- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — mountInst의 삽입 위치,
|
||||
-- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다.
|
||||
function Dispatch.getOffsetAt(ownerKey, i)
|
||||
local bk = getBookkeeping(ownerKey)
|
||||
-- [2026-08-21 사용자 제안] **접두합 캐시 + "이 인덱스 초과부터 무효" 마커.**
|
||||
-- 매번 1..i-1을 다시 더하면 호출당 O(i)라 reconcile 전체가 O(N²)가 된다.
|
||||
-- 캐시된 접두합(`bk.offsetCache[j]` = j 자리의 절대 offset)은 **그 앞쪽이
|
||||
-- 안 바뀐 한 계속 유효**하므로, 무효화는 "바뀐 자리부터 뒤"만 하면 된다.
|
||||
local from = bk.offsetDirtyFrom or 1 -- 이 인덱스부터가 무효
|
||||
if i < from then
|
||||
return bk.offsetCache[i] -- 앞쪽은 그대로 유효 — O(1)
|
||||
end
|
||||
-- 무효 구간만 이어붙여 다시 계산한다(유효한 마지막 자리의 값에서 출발).
|
||||
local sum = if from > 1 then bk.offsetCache[from - 1] + contribution(bk, from - 1)
|
||||
else (if isSlot(ownerKey) then ownerKey.Offset:Get() else 0)
|
||||
for j = from, i - 1 do
|
||||
bk.offsetCache[j] = sum
|
||||
sum += contribution(bk, j) -- lengthList[j](State면 :Get())
|
||||
end
|
||||
bk.offsetCache[i] = sum
|
||||
bk.offsetDirtyFrom = i + 1 -- 여기까지는 이제 유효
|
||||
return sum
|
||||
end
|
||||
|
||||
**⭐ [2026-08-21 사용자 제안] `getOffsetAt`의 접두합 캐시 — 무효화 규칙**
|
||||
|
||||
`offsetCache`/`offsetDirtyFrom`이 성립하려면 **"앞이 안 바뀌면 뒤도 안 바뀐다"**는
|
||||
단조성이 지켜져야 한다. 그래서 아래 셋 중 하나라도 일어나면 `offsetDirtyFrom`을
|
||||
그 인덱스로 내린다(더 작은 값으로만 갱신):
|
||||
|
||||
1. **`setLength(ownerKey, i, ...)`** — `i` 자리의 기여도가 바뀌므로 `i + 1`부터
|
||||
무효(그 자리 자신의 offset은 안 변한다). State 길이가 나중에 emit할 때도 같다.
|
||||
2. **`spliceArraysUp`/`spliceArraysDown`(자리 삽입·삭제)** — 그 인덱스부터 전부
|
||||
밀리므로 그 자리부터 무효.
|
||||
3. **owner의 베이스가 바뀜**(`ownerKey.Offset` 변경 = `_baseObserver`가 도는
|
||||
그 순간) — **전체 무효**(`offsetDirtyFrom = 1`).
|
||||
|
||||
**⚠️ 아직 안 정한 것**: `recompute`가 이미 전체를 순회하며 같은 접두합을
|
||||
계산하므로, **그 순회가 캐시를 그대로 채우게 할지**(그러면 `recompute` 직후엔
|
||||
캐시가 항상 완전) 아니면 두 경로를 분리해 둘지. 전자가 낭비가 없어 보이지만
|
||||
`recompute`는 `Set` 캐스케이드를 유발할 수 있어 호출 빈도가 다르다 —
|
||||
구현 시 확인.
|
||||
|
||||
local function recompute(ownerKey, bk)
|
||||
-- [2026-08-21 G절] `0`이 아니라 이 owner의 베이스에서 시작한다.
|
||||
-- 베이스는 별도로 저장하지 않는다 — Slot이면 자기 `.Offset`이 곧 그 값이고
|
||||
-- (부모가 먼저 설정해두므로 이미 정확하다), 최상위 물리 inst엔 베이스가
|
||||
-- 아예 없어 항상 0이다. 위 `getOffsetAt`과 같은 식.
|
||||
local base = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
|
||||
local sum = 0
|
||||
for i = 1, bk.N do
|
||||
-- [2026-08-21 5라운드 감사] `bk.N or 0` — **빈 Slot 크래시 방어**.
|
||||
-- `bk.N`은 `setLength`가 처음 불릴 때 생기므로(`bk.N = math.max(bk.N or 0, i)`),
|
||||
-- 요소가 하나도 없는 Slot(`Slot()` 직후, 데이터가 빈 `:List` 등)은 `N`이 `nil`인
|
||||
-- 채로 `materializeSlotTree` 끝의 recompute에 도달한다 — `for i = 1, nil`은
|
||||
-- 그 자리에서 터진다. 빈 Slot은 완전히 정상적인 상태라 이건 방어가 아니라 계약.
|
||||
for i = 1, bk.N or 0 do
|
||||
local offset = bk.sourceList[i]
|
||||
-- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라
|
||||
-- offset은 실제 Source이거나 None(발행 채널 없음) — None은 truthy라
|
||||
-- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함.
|
||||
-- [전면 정정, 2026-08-20 QA 4라운드 `C-6`] `nil`은 skip이 아니라 error.
|
||||
-- 도달 경로가 없다는 게 재추적 결론이므로(bk.N=실제 개수, 배치 중엔
|
||||
|
|
@ -1492,14 +1598,14 @@ local function recompute(ownerKey, bk)
|
|||
if offset == nil then
|
||||
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
|
||||
end
|
||||
if offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set
|
||||
offset:Set(sum)
|
||||
if offset ~= None and offset:Get() ~= base + sum then -- 실제로 다를 때만 Set
|
||||
offset:Set(base + sum)
|
||||
end
|
||||
local v = bk.lengthList[i]
|
||||
sum += (if isState(v) then v:Get() else v)
|
||||
end
|
||||
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
|
||||
ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스
|
||||
ownerKey.Length:Set(sum) -- **Length엔 base를 안 더한다** — 길이는 위치와 무관
|
||||
end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절)
|
||||
end
|
||||
```
|
||||
|
|
@ -1536,7 +1642,13 @@ Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지
|
|||
그건 호출하는 배치 쪽 책임):
|
||||
|
||||
```lua
|
||||
function Dispatch.setLength(ownerKey, i, len)
|
||||
-- [시그니처 변경, 2026-08-21 구현 전 QA 5라운드 `C-4`] 4번째 인자 `anchor` 신설 —
|
||||
-- **부기 키(`ownerKey`)와 생명주기 앵커(`anchor`)를 분리**한다. 아래 절 참고.
|
||||
-- **생략하면 `ownerKey`** — 최상위(물리 inst가 곧 owner)에선 둘이 같은 값이라
|
||||
-- 기존 3-인자 호출부가 전부 그대로 맞고, **`ownerKey`가 Slot일 때만** 물리
|
||||
-- target을 명시적으로 넘기면 된다(그 경우에만 둘이 갈린다).
|
||||
function Dispatch.setLength(ownerKey, i, len, anchor)
|
||||
anchor = anchor or ownerKey
|
||||
local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성
|
||||
local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고)
|
||||
|
||||
|
|
@ -1557,7 +1669,8 @@ function Dispatch.setLength(ownerKey, i, len)
|
|||
|
||||
if isState(len) then
|
||||
local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회 실행도 게이팅됨
|
||||
bindLifetime(ownerKey, observer) -- ownerKey 생명주기에 귀속, Subscribe 아님
|
||||
bindLifetime(anchor, observer) -- **물리 target**의 생명주기에 귀속, Subscribe 아님
|
||||
-- (ownerKey는 부기 키일 뿐 — 아래 절)
|
||||
bk.observers[i] = observer
|
||||
else
|
||||
gatedRecompute() -- 상수 길이도 같은 게이트를 통과 — setLength 자신은 recompute를 직접 안 부름
|
||||
|
|
@ -1565,6 +1678,39 @@ function Dispatch.setLength(ownerKey, i, len)
|
|||
end
|
||||
```
|
||||
|
||||
**⭐ [2026-08-21 구현 전 QA 5라운드 `C-4`] 부기 키와 생명주기 앵커는 별개다 —
|
||||
4라운드 `D-56`의 결론을 되돌린다.**
|
||||
|
||||
4라운드는 "`ownerKey`가 Slot일 수 있으니 **백엔드의 `bindLifetime`이 Slot을
|
||||
첫 인자로 받는 경우를 핸들링**하고, `isBoundAlive`에 세 번째 분기를 둬라"로
|
||||
결론냈었다. 5라운드에서 사용자가 그 전제 자체에 의문을 제기했고(*"애초에
|
||||
Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 observer/effect 는
|
||||
실제 inst 에 불림 … 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지
|
||||
다시 생각해봐야할 부분"*), 검토 결과 **되돌리는 쪽이 맞다**:
|
||||
|
||||
- 이 Observer가 살아야 하는 기간은 "이 Slot이 **그 물리 트리에 마운트돼
|
||||
있는 동안**"이고, 그건 `physicalTarget`이 정확히 표현한다. Slot 자신의
|
||||
생존은 부모의 `_elements` 강참조가 이미 보장한다.
|
||||
- **`setLength`가 불리는 모든 자리에서 물리 target을 이미 알고 있다** —
|
||||
`Dispatch.drive`(=`inst`), `materializeSlotTree`(=`physicalTarget`),
|
||||
런타임 단건 `rawAdd`/`rawReplace`(=`self._mountedInst`).
|
||||
- 그래서 **`bindLifetime`의 첫 인자는 항상 물리 Instance**로 되돌아가고,
|
||||
`base/lifecycle-pattern.md`가 지고 있던 백엔드 요구사항(비-Instance 첫 인자
|
||||
핸들링)과 `isBoundAlive`의 **세 번째 분기가 통째로 불필요**해진다(그건
|
||||
아직 형태가 미정인 채 열려 있던 항목이었다). 옛 결론 원문은
|
||||
`archive/bindlifetime-slot-owner-reversed.md`.
|
||||
- **포탈(언마운트→재마운트)에서도 자연히 맞는다** — `unmountSlotTree`가
|
||||
`bk.observers`를 `unbindLifetime`하고, 재마운트 시 `materializeSlotTree`가
|
||||
새 `physicalTarget`을 앵커로 다시 등록한다.
|
||||
- **`getBookkeeping(ownerKey)`/`getBlocker(ownerKey)`는 그대로 Slot을 키로
|
||||
쓴다** — 그건 `Relate`의 weak 키일 뿐 생명주기 앵커가 아니다.
|
||||
- **`anchor`는 `len`이 State일 때만 실제로 쓰인다**(상수 길이는 Observer를
|
||||
안 만들므로). **생략 시 `ownerKey`로 폴백**하므로 최상위 호출부
|
||||
(`Dispatch.drive`, `ProcessedPreRefHandler`/`NilHandler` 등 `inst`를 owner로
|
||||
쓰는 자리 전부)는 **기존 3-인자 그대로 두면 된다** — 거기선 `ownerKey`가 곧
|
||||
물리 target이다. 4번째 인자를 실제로 넘겨야 하는 건 **`ownerKey`가 Slot인
|
||||
자리**(`materializeSlotTree`의 등록 루프, 런타임 `rawAdd`/`rawReplace`)뿐이다.
|
||||
|
||||
`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는
|
||||
본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst
|
||||
또는 Slot 자신)가 죽을 때 같이 죽어야 함 — `:Subscribe()`는 명시적
|
||||
|
|
@ -1616,24 +1762,36 @@ end
|
|||
호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고,
|
||||
`ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위
|
||||
재귀 케이스)도 같이 확정시킨다.
|
||||
- **⭐ [2026-08-21 5라운드 `DC-11`] 이 마지막 호출이 실제로 하는 일은
|
||||
"offset 채우기"가 아니다.** offset은 3번의 즉시 계산이 등록 시점마다
|
||||
이미 정확히 넣어뒀고(position `i`의 offset은 `1..i-1`의 길이 합인데
|
||||
그것들은 `i`보다 먼저 등록되므로), 이 호출에서 `offset:Get() ~= sum`
|
||||
가드에 걸려 대부분 아무것도 안 쓴다. 실제 역할은 둘 —
|
||||
**(a) `ownerKey`가 Slot이면 `ownerKey.Length`(= 기여도 합) 확정**
|
||||
(사용자 추측대로 이게 주 목적), **(b) 등록된 뒤에 값이 바뀐 길이가
|
||||
있으면 그 뒤 형제들의 offset 교정**. 그래서 `ownerKey`가 물리 `inst`인
|
||||
`Dispatch.drive` 경로에선 (a)가 없어 사실상 검증 패스에 가깝지만,
|
||||
O(N) 순회에 `Set`이 거의 없으므로 분기해서 빼지 않고 그냥 항상 부른다.
|
||||
|
||||
**`setOffsetSource`의 즉시 계산(2026-08-18 신설)** — 등록되는 그 자리에서
|
||||
`bk.lengthList[1..i-1]`을 합산해 곧바로 `:Set`한다(단 `source == None`이면
|
||||
스킵 — 참여 안 하는 자리는 계산할 게 없음):
|
||||
**`setOffsetSource`의 즉시 계산(2026-08-18 신설, 2026-08-21 G절에 정리)** —
|
||||
등록되는 그 자리에서 **`Dispatch.getOffsetAt(ownerKey, i)`**(= 베이스 +
|
||||
`bk.lengthList[1..i-1]` 합)를 구해 곧바로 `:Set`한다. `source == None`이면
|
||||
**얼리 리턴** — 발행할 채널이 없으니 계산할 이유가 없고, 그 자리 숫자가
|
||||
필요한 쪽(예: `mountInst`의 삽입 위치)은 `getOffsetAt`을 직접 부른다:
|
||||
|
||||
```lua
|
||||
-- [정리, 2026-08-21 G절] 합산 루프가 `Dispatch.getOffsetAt`으로 빠지면서
|
||||
-- 이 함수는 "등록 + (채널이 있으면) 즉시 1회 발행"만 남는다.
|
||||
function Dispatch.setOffsetSource(ownerKey, i, source)
|
||||
local bk = getBookkeeping(ownerKey)
|
||||
bk.sourceList[i] = source
|
||||
if source ~= None then
|
||||
local sum = 0
|
||||
for j = 1, i - 1 do
|
||||
local v = bk.lengthList[j] -- 배치가 순서대로 처리되므로 1..i-1은 항상 이미 등록돼 있음
|
||||
sum += (if isState(v) then v:Get() else v)
|
||||
end
|
||||
if source:Get() ~= sum then
|
||||
source:Set(sum)
|
||||
end
|
||||
if source == None then
|
||||
return -- 발행 채널이 없는 자리 — 계산할 이유가 없다. 숫자가 필요하면
|
||||
-- 그때 `Dispatch.getOffsetAt(ownerKey, i)`을 직접 부른다.
|
||||
end
|
||||
local offset = Dispatch.getOffsetAt(ownerKey, i) -- 배치가 순서대로 처리되므로
|
||||
if source:Get() ~= offset then -- 1..i-1은 항상 이미 등록돼 있음
|
||||
source:Set(offset)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
|
@ -1698,8 +1856,9 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
|
|||
전제할 수 있어야 자기 일을 할 수 있다. 연산마다 선후가 다르면 백엔드
|
||||
작성자가 매번 다시 확인해야 한다.
|
||||
- **각 연산에 적용하면**:
|
||||
- `rawAdd` — `Length:Set(newCount)`(→ 뒤 형제 offset 갱신 동기 완료) →
|
||||
`element.Parent = target`. 이미 이렇게 확정돼 있음(아래 문단).
|
||||
- `rawAdd` — 부기(`setOffsetSource`/`setLength` 등록 → `recompute`) 완료 →
|
||||
물리 마운트(`mountInst`). **[정정, 2026-08-21 5라운드 `C-2`]** 예전엔 여기
|
||||
`Length:Set(newCount)`라고 적혀 있었으나 **틀렸다** — 아래 문단 참고.
|
||||
- `rawRemove`/`rawUnmount`/**`rawDetach`**(**[2026-08-21]** `Detach`
|
||||
경로용으로 신설된 세 번째 형제 — 소유권을 **유지**한 채 언마운트만
|
||||
한다는 점만 다르고 순서는 같음, `base/slot-plan.md`) — 파괴/언마운트 →
|
||||
|
|
@ -1721,11 +1880,30 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
|
|||
|
||||
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
|
||||
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:
|
||||
Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder
|
||||
갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제
|
||||
트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set`
|
||||
자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위
|
||||
`Get` 가드와 같은 원칙을 호출부에서도 적용).
|
||||
Slot의 `rawAdd`는 **부기를 먼저 완결**하고(그 자리의 `setOffsetSource`/
|
||||
`setLength` 등록 → `recompute` → 뒤 형제 offset 갱신이 동기적으로 여기서 끝남)
|
||||
그 다음에 물리 마운트(`mountInst`)를 호출한다 — 트리에 보이는 시점엔
|
||||
다운스트림이 이미 정합적.
|
||||
|
||||
**⭐ [전면 정정, 2026-08-21 구현 전 QA 5라운드 `C-2`] `rawAdd`가
|
||||
`self.Length:Set(newCount)`를 직접 부른다는 옛 서술은 틀렸다.** 사용자
|
||||
지적(*"rawAdd 에서도 필요한가는 모르겠음. 목적이 다르지 않나?"*)을 파고들다
|
||||
확인된 것 셋:
|
||||
|
||||
1. **`newCount`(개수)는 더 이상 `Length`의 정의가 아니다** — `Length`는
|
||||
"요소별 기여도의 합"(plain=1, nested Slot=그 `.Length`)이라, 중첩이 있는
|
||||
순간 개수로 `Set`하면 틀린 값이 된다.
|
||||
2. **쓰는 주체가 둘이 되면 안 된다** — `recompute`가 이미
|
||||
`ownerKey.Length:Set(sum)`으로 확정 기록을 한다.
|
||||
3. **그 자리의 `Get() ~= newCount` 가드는 아무것도 안 거른다** — `rawAdd`에선
|
||||
카운트가 **항상** 달라지기 때문. 가드가 값을 하는 건 `recompute`의 전체
|
||||
순회 쪽뿐이다(위 `Get` 가드 문단).
|
||||
|
||||
**확정**: `Length`는 **`recompute`만 쓴다.** `rawAdd`/`rawReplace`는 자기 자리
|
||||
부기를 등록하고 `recompute`를 한 번 부를 뿐이고, 그게 `Parent` 대입 앞에
|
||||
오므로 "부기가 물리보다 먼저"는 그대로 지켜진다(사용자 확인: *"recompute 가
|
||||
length 를 잘 처리해놓고 나서 빈 공간에 들어가므로 해당 동작은 완결하다"*).
|
||||
의사코드는 `base/slot-plan.md`의 `rawAdd`/`rawReplace`가 소스.
|
||||
|
||||
**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이
|
||||
한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클
|
||||
|
|
@ -1734,7 +1912,11 @@ Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/Layou
|
|||
**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/
|
||||
`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**:
|
||||
DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로,
|
||||
`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 —
|
||||
`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음
|
||||
(**[2026-08-21 5라운드 재확인]** `mountInst`가 삽입 위치를 받게 된 뒤에도 이
|
||||
결론은 그대로다 — 사용자 확정: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고
|
||||
빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"*. 단 그게 성립하려면
|
||||
백엔드 op이 **아토믹한 최소 단위**여야 한다) —
|
||||
quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고,
|
||||
`offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느
|
||||
물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히
|
||||
|
|
@ -1760,7 +1942,13 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n
|
|||
읽을 수 있는 `Source<number>`이고 마운트 전/언마운트 후엔 잠정값(`0` 또는 마지막
|
||||
값)을 들고 있을 뿐이다. `nil`로 갈아치우면 그 Source를 이미 구독 중인
|
||||
다운스트림이 끊겨 포탈이 깨진다 — 근거는 `base/slot-plan.md`의 "추가 방어 조치"
|
||||
항목. 위 정정대로 이 값을
|
||||
항목. **⭐ [2026-08-21 5라운드 `DC-6`, 사용자 정밀화] 더 정확히는, 언마운트
|
||||
시점에 그 Slot이 이미 렌더해둔 요소들이 `LayoutOrder` 등을 위해 이 Source를
|
||||
**계속 구독한 채로 함께 딸려 나간다**는 게 핵심이다. 그 상태에서 재마운트
|
||||
때 `slot.Offset`에 **다른 Source 객체**를 넣으면, 딸려 나갔던 요소들은 여전히
|
||||
옛 객체를 보고 있어 새 위치가 반영되지 않는다 — 포탈이 그 지점에서 깨진다.
|
||||
그래서 언마운트는 값이 stale하게 남는 걸 감수하고 **객체 identity를 유지**하고,
|
||||
재마운트 시 `setOffsetSource`의 즉시 계산이 같은 객체에 새 값을 `Set`한다. 위 정정대로 이 값을
|
||||
`LayoutOrder` 등에 실제로 반영하는 건 Slot 자신이 하지 않으므로,
|
||||
`:List`의 `updateFn`이 이 값을 받아 쓰거나(아래 `base/slot-plan.md`
|
||||
참고) 수동 CRUD 사용자가 직접 `slot.Offset`을 읽어 자기 원소 프로퍼티를
|
||||
|
|
@ -1774,9 +1962,10 @@ store-bind — 그 외 방식은 UB로 확정(2026-08-10 세션).** `Length`/`Of
|
|||
카운팅은 그 위치를 담당하는 Handler(`Dispatch/Slot.luau`, store-bind
|
||||
프로퍼티 핸들러)가 `Dispatch.setLength`/`Dispatch.setOffsetSource`를
|
||||
호출해줘야만 정합적으로 유지됨 — 이 두 API를 부르지 않고 quad가 관리하는
|
||||
부모 Instance에 자식을 끼워 넣는 경로(예: 사용자 코드가 `newInst.Parent =
|
||||
부모 Instance에 자식을 끼워 넣는 경로(예: **사용자 코드**가 `newInst.Parent =
|
||||
parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을 몰래
|
||||
추가/제거하는 것)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게
|
||||
추가/제거하는 것 — base 의사코드 쪽의 `Parent` 직접 조작은 2026-08-21에
|
||||
전부 주입 op로 정정됐다, `base/slot-plan.md`의 "물리 조작은 주입 op다" 절)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게
|
||||
만들어 카운트·형제 순서 계산이 조용히 어긋남 — 별도 방어 로직 없는 UB.
|
||||
`Slot`이든 `state<Frame>`이든 둘 다 이미 이 두 API를 정확히 호출하는
|
||||
유일한 정당 경로로 확정돼 있음(위 `setLength`/`setOffsetSource` 절
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ mount/unmount 전용 유스케이스가 있고, 실제 leaf 생명주기 바인
|
|||
합의됨.
|
||||
|
||||
```
|
||||
Effect(fn, state?) -> EffectHandle
|
||||
Effect(fn, ...deps) -> EffectHandle
|
||||
```
|
||||
**[2026-08-21 5라운드 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`(의존성 하나)였다 —
|
||||
아래 "`Effect(fn, ...deps)`" 절이 소스.
|
||||
|
||||
**`state` 생략 시**: `fn()`을 즉시 1회 실행, 리턴값(`nil | () -> ()`)은
|
||||
이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출. 재실행 없음
|
||||
|
|
@ -45,17 +47,17 @@ leaf가 죽을 때 **마지막 cleanup을 한 번 더 호출**. 결과적으로
|
|||
`useEffect(fn, [dep])`와 동형(설치+재실행 사이/최종 cleanup 전부 같은
|
||||
반환 계약 하나로 처리).
|
||||
|
||||
- **다수 의존성은 `:With(...)`로 먼저 하나의 State로 묶어서 넘길 것** —
|
||||
React식 별도 deps 배열을 새로 만들지 않음, quad가 이미 가진 다중 의존성
|
||||
결합 관용구(`base/source-state-plan.md` "`:With` + `:Compute`" 절)를
|
||||
그대로 재사용해 같은 일 하는 두 번째 경로를 안 만듦. **`Effect(fn, a, b,
|
||||
c)`처럼 trailing args로 바로 받는 sugar는 의도적으로 안 만듦**(2026-08-11
|
||||
세션, `source-state-plan.md` "`:Compute(fn, ...)` — 추가 의존성을 trailing
|
||||
args로 직접 받는 sugar" 절 참고) — `Compute`와 달리 Effect/Observer는
|
||||
자기 자신이 결과를 담는 State 노드가 아니라서, 의존성이 둘 이상이면 그걸
|
||||
합칠 **새 노드**(`:With`가 만드는 것)가 실제로 필요함. 그 비용을 sugar로
|
||||
감추지 않고 `Effect(fn, state:With(a,b,c))`처럼 코드에 그대로 드러내는
|
||||
게 의도된 선택.
|
||||
- **🔄 [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-6`] "다수 의존성은 `:With`로
|
||||
묶어서 넘길 것 / trailing args sugar는 안 만듦"(2026-08-11 세션) — 뒤집혔다.**
|
||||
지금은 `Effect(fn, ...deps)`가 의존성을 **여러 개 직접 받고 각각에 구독을
|
||||
건다**(아래 "`Effect(fn, ...deps)`" 절이 소스). 옛 근거("의존성이 둘 이상이면
|
||||
합칠 새 노드가 실제로 필요하니 그 비용을 sugar로 감추지 말자")가 무너진
|
||||
이유는 둘 — (1) **`Ref`는 State가 아니라 `:With`로 합칠 수가 없어서**, 그
|
||||
모델에선 `Ref`가 Effect의 의존성이 될 방법이 **아예 없었다**(실제 갭),
|
||||
(2) 각 의존성에 구독을 따로 걸면 **합치는 노드 자체가 안 생긴다** — 감출
|
||||
비용이 애초에 없다. 옛 서술이 인용하던
|
||||
`source-state-plan.md`의 "`:Compute(fn, ...)`" 선례는 이제 **따르는 쪽**의
|
||||
근거가 됐다(인자 모양을 그 관용구 그대로 맞춤).
|
||||
- **`fn`은 커링 스타일도 권장(2026-08-07 여섯 번째 세션, 사용자 제안)** —
|
||||
`Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn(state)`를
|
||||
만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링 관용구(`modifier-plan.md`
|
||||
|
|
@ -176,15 +178,28 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
dedup" 절의 `old ~= v`). 그런데 `:Unsubscribe()`가 cleanup을 미리
|
||||
실행해버리면 뒤이은 재-dispatch에서 **dedup 때문에 재바인딩이 안
|
||||
일어나** 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님.
|
||||
- **⚠️ 같이 확인해야 할 별건(미해결)**: 그 dedup 경로에서 **retract가
|
||||
아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안 하는지** 대칭이
|
||||
실제로 성립하는지 확인 필요(사용자가 괄호로 남긴 것).
|
||||
`ObserverEffectLeafHandler` 의사코드 기준으론 `process`의
|
||||
`if old ~= v then bindLifetime(...) end`와 클로저의
|
||||
`if nextValue ~= v then unbindLifetime(...) end`가 짝을 이루지만,
|
||||
**`EffectHandle`은 내부 Observer로 cascade까지 해야 하므로** 그
|
||||
cascade가 dedup 분기 안에 제대로 들어가 있는지는 별도 확인 대상이다.
|
||||
M3 착수 전 확인할 것.
|
||||
- **✅ [해소, 2026-08-21 — 구현 전 QA 4라운드 `E-10` 결론을 5라운드 `EF-3`에서
|
||||
실제로 반영] dedup 경로의 process/retract 대칭은 성립한다.** 4라운드에
|
||||
결론이 났는데 **이 문서에 반영이 누락돼 "미해결"로 남아 있던 것**을
|
||||
5라운드가 잡아냈다(그 자체가 followup의 "반영 완료" 표를 신뢰 소스로
|
||||
쓰면 안 된다는 사례 — 소스는 항상 `base/` 본문).
|
||||
- **성립하는 이유**: 핸들러가 **이전 값(`old`)을 `Relate`로 직접 들고
|
||||
있고**, `process`의 `if old ~= v then ... end`와 클로저의
|
||||
`if nextValue ~= v then ... end` **두 분기 안에서만** bind/unbind가
|
||||
일어난다. 값이 같으면 retract도 아무것도 안 하고(= `old`를 지우지
|
||||
않는다) `process`도 조회해서 같으면 그대로 넘어간다 — 양쪽이 같은
|
||||
비교식을 쓰므로 한쪽만 도는 상태가 안 생긴다. **사용자 서술**(2026-08-20):
|
||||
*"relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup
|
||||
이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면
|
||||
dedup 되어야하는듯."*
|
||||
- **⭐ 단, 내부 Observer cascade도 그 분기 *안*에 있어야 한다**
|
||||
(5라운드 `EF-5`, 확인됨) — `EffectHandle`은 자기 자신뿐 아니라
|
||||
`handle._observer`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup
|
||||
분기 **밖**에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다
|
||||
(handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시
|
||||
같은 `if` 안에 둘 것.
|
||||
- **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린
|
||||
항목이 아니다.
|
||||
- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥
|
||||
위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의
|
||||
`:Unsubscribe()`는 "미래 재실행만
|
||||
|
|
@ -221,6 +236,41 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
여전히 `:Subscribe()`(전역 경로)와 `bindLifetime`(leaf 부착 포함,
|
||||
inst-scoped 경로)을 **같이** 쓰는 것뿐.
|
||||
|
||||
## ⭐ `Effect(fn, ...deps)` — 여러 의존성을 직접 받는다, `Ref`도 포함 (2026-08-21 구현 전 QA 5라운드 `C-6` 확정)
|
||||
|
||||
**갭이 실재했다**: 지금 `Effect`는 `state` 하나만 받고, 여럿을 엮으려면
|
||||
`:With`로 합쳐 하나의 State로 만들어야 한다. 그런데 **`Ref`는 State가 아니라**
|
||||
(`:Callback`만 있고 emit이 없다) `:With`로 합칠 수가 없어서, **오늘은 `Ref`가
|
||||
Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect 가 지금은 Ref에
|
||||
대해서 수행될 수가 없다. 단순히 Effect(, ...) 를 만들고 ... 요소를 With 으로
|
||||
합치는게 아니라 여러 요소에 대해서 Observe/Callback 하는게 어떻겠냐."*
|
||||
|
||||
**확정된 계약**(전부 사용자 확인, 2026-08-21):
|
||||
|
||||
- **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** —
|
||||
State/Source면 `Observer`, `Ref`면 `:Callback`. `:With`로 합치지 않는다.
|
||||
- **인자 모양은 `:Compute(fn, ...deps)`의 선례 그대로** — trailing deps를
|
||||
**lazy 위치 인자**로 콜백에 넘긴다(`base/source-state-plan.md`의 "trailing
|
||||
deps를 `fn`에 lazy positional 인자로도 노출" 절). 새 규칙이 아니다.
|
||||
- **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`가
|
||||
섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게
|
||||
될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다.
|
||||
- **`Ref` 의존성의 발화 시점은 `Set`될 때뿐**이다(Ref는 반복 재설정이
|
||||
가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다.
|
||||
- **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록
|
||||
즉시 1회 실행"이 N번 발화하므로, 설치 구간 동안 발화를 눌러뒀다가 마지막에
|
||||
한 번만 실행한다 — **`Blocker`의 "`state:Block()` 없이 직접 쓰는" 용례**를
|
||||
그대로 재사용(`base/blocker-plan.md`). **⚠️ 이 억제 장치의 정확한 모양은
|
||||
`Gate`(공용 게이트 노드) 설계에 딸려 있다** — `research/gate-primitive.md`가
|
||||
닫힌 뒤에 확정할 것.
|
||||
- **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도
|
||||
N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를
|
||||
같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피
|
||||
모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."*
|
||||
|
||||
**우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M3의
|
||||
`Effect` 구현과 같이 간다 — 다만 위 ⚠️(억제 장치) 때문에 `Gate`보다 뒤다.
|
||||
|
||||
## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체)
|
||||
|
||||
**과거 미해결이었던 두 질문 모두 확정**:
|
||||
|
|
|
|||
|
|
@ -356,39 +356,20 @@ function canExecute(value)
|
|||
end
|
||||
```
|
||||
|
||||
#### (1-1) ⚠️ 첫 인자가 물리 Instance가 아닐 수도 있다 — 백엔드가 반드시 핸들링할 것 (2026-08-20 구현 전 QA 4라운드 `D-56`)
|
||||
#### (1-1) ✅ [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`] 첫 인자는 **항상 물리 Instance**다
|
||||
|
||||
**위 구현 스케치는 `inst`가 항상 Roblox Instance라고 가정하고 `InstData`에서
|
||||
gcconn/gchold를 찾는데, 실제 호출부 중엔 `inst` 자리에 `Slot`이 오는 경로가
|
||||
이미 있다.** `Dispatch.setLength(ownerKey, i, len)`이 그것 —
|
||||
`base/dispatch-core-plan.md`의 "`setLength` 구현" 절이 `bindLifetime(ownerKey,
|
||||
observer)`를 부르는데, 그 `ownerKey`는 Slot-in-Slot 중첩에서 **Slot 자신**이다
|
||||
(`base/slot-plan.md`의 "재귀 메커니즘" 절 — `attachSlot`이 `ownerKey`로 자기
|
||||
자신을 넘겨 최상위/중첩을 같은 함수로 통합한 그 설계).
|
||||
여기 있던 절(4라운드 `D-56`)은 *"`Dispatch.setLength`의 `ownerKey`가 Slot일 수
|
||||
있으니 백엔드의 `bindLifetime`이 비-Instance 첫 인자를 핸들링하고,
|
||||
`isBoundAlive`에 세 번째 분기를 둬야 한다"*였다. **5라운드에서 뒤집혔다** —
|
||||
`setLength`가 **부기 키(`ownerKey`)와 생명주기 앵커(`anchor`)를 따로 받도록**
|
||||
바뀌면서, 앵커는 언제나 물리 target이 된다(모든 호출부가 이미 그 값을 알고
|
||||
있다). 그래서:
|
||||
|
||||
**사용자 판정(2026-08-20)**: *"ownerKey 가 Slot일 수도 있음. 각 엔진의
|
||||
bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong
|
||||
으로 gchold 비슷한걸 수행하면 됨."*
|
||||
|
||||
- **계약 두 개(위 절)는 그대로 유지된다** — 바뀌는 건 "그 계약을 무엇으로
|
||||
구현하는가"뿐. 물리 Instance면 gcconn 트릭이 두 계약을 다 만족시키고,
|
||||
Slot이면 **Slot 자신이 살아있는 동안 `value`를 붙잡는 강참조**(Slot 안의
|
||||
필드든, `Relate(slot)`에 `SetStrong`이든)와 **`value`가 그 Slot의 생존을
|
||||
되물을 수 있는 근거**를 백엔드가 제공하면 된다.
|
||||
- **왜 gcconn을 못 쓰는가**: gcconn 트릭은
|
||||
`inst:GetPropertyChangedSignal("ClassName")`에 의존하므로 엔진 객체가 아닌
|
||||
값(Slot은 평범한 Lua 테이블)엔 걸 수가 없다. Slot은 대신 **자기 자신이
|
||||
reachable한가**가 곧 생존이라, `Relate(slot)`가 weak-keyed인 것만으로
|
||||
"Slot이 죽으면 기록도 같이 사라진다"가 성립한다.
|
||||
- **`isBoundAlive`의 판정 분기도 이 경로를 알아야 함** — 지금 코드는
|
||||
gcconn이 없으면 곧바로 `.Subscribed` 폴백으로 떨어지는데, Slot-owned
|
||||
바인딩은 gcconn도 `.Subscribed`도 없어서 **살아있는데 `canBound`가 참으로
|
||||
잘못 나온다**(= 이중 바인딩 가드가 이 경로에선 안 걸림). 백엔드 구현이
|
||||
세 번째 분기를 추가하거나, Slot 쪽 홀더 존재 자체를 판정 근거로 삼아야 함.
|
||||
- **⚠️ 정확한 형태는 아직 미확정 — M2/M3 구현 시 확정할 것.** "Slot 안"(필드)
|
||||
이냐 "바깥"(`Relate`)이냐, `isBoundAlive`의 세 번째 분기를 어떤 모양으로
|
||||
둘지가 열려 있다. 지금 확정된 건 **"첫 인자가 Instance라고 가정하면 안
|
||||
된다"는 요구사항 자체**뿐.
|
||||
- **`bindLifetime`/`unbindLifetime`/`isBoundAlive`는 예전처럼 물리 Instance만
|
||||
상대한다** — 백엔드에 추가 요구사항이 없고, `isBoundAlive`의 **세 번째
|
||||
분기도 필요 없다**(형태 미정인 채 열려 있던 항목이 이걸로 닫혔다).
|
||||
- 근거와 트레이싱은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절
|
||||
바로 뒤 문단, 역전 전 원문은 `archive/bindlifetime-slot-owner-reversed.md`.
|
||||
|
||||
**`bindLifetime`이 `value`와 맺는 계약은 정확히 둘**(이 둘이 위 구현의 전부):
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,40 @@ inst, inst, k)` 한 줄로 축약됨** — `Dispatch.setLength`/`activateList`
|
|||
호출은 `attachSlot` 내부로 옮겨감(로직은 그대로, 재귀 가능하도록만
|
||||
일반화됨), 상세는 아래 "Slot-in-Slot 중첩" 절 참고.
|
||||
|
||||
### ⭐ 물리 조작은 주입 op다 — base는 `Parent`를 모른다 (2026-08-21 구현 전 QA 5라운드 `C-1`)
|
||||
|
||||
**사용자 지적**: *"element.Parent = self._mountedInst 부분은 실제 엔진이
|
||||
구현하게 되는 crud 셋을 사용하게 되어야할것이다. slot 의 해당 동작은 base
|
||||
이므로 parent 를 모른다."* 맞다 — 위 경계 문단이 "실제 트리 조작은 백엔드"라고
|
||||
이미 말해뒀는데, **의사코드는 계속 `element.Parent = ...` / `element:Destroy()`로
|
||||
Roblox 어휘를 직접 쓰고 있었다.** 이 문서의 의사코드를 전부 주입 op 호출로
|
||||
바꿨다(로직은 그대로, 표기만 정정):
|
||||
|
||||
| op | 언제 | 대응하는 경계 훅 |
|
||||
|---|---|---|
|
||||
| `mountInst(target, element, index)` | 요소를 물리 트리에 붙일 때(`index` = 0-based 절대 offset, 아래 항목) | mount(`Add`) |
|
||||
| `unmountInst(element)` | 물리 트리에서만 떼어낼 때(파괴 아님) | unmount(`Remove`/`Extract`) |
|
||||
| `disposeInst(element)` | 요소를 실제로 파괴할 때 | 이미 확정된 주입 op(아래 `dispose` 절) |
|
||||
|
||||
- **reposition(`Move`/`Swap`)은 base가 "Parent를 안 건드린다"는 계약만
|
||||
강제**하므로 별도 op이 없다(위 문단 그대로) — 백엔드가 `LayoutOrder` 정렬에
|
||||
기대 no-op으로 둘 수도 있다.
|
||||
- **⭐ [확정, 2026-08-21 5라운드 G절] `mountInst(target, element, index)` —
|
||||
index는 절대 offset(0-based)이다.** 원래는 "형제 순서는 `Offset`/`LayoutOrder`
|
||||
부기가 담당하니 안 넘긴다"였는데, 사용자 지적 — *"웹에서는 어떻게 되냐가
|
||||
모호함. 어디 둘지 어떻게 아느냐는것"* — 대로 **DOM류 백엔드는 삽입 위치를
|
||||
알아야 하고, 정작 plain 요소는 `setOffsetSource`에 `None`을 등록해 그 숫자가
|
||||
계산조차 안 되고 있었다.** 지금은 `Dispatch.getOffsetAt(ownerKey, i)`가 발행
|
||||
채널 유무와 무관하게 그 숫자를 준다(`base/dispatch-core-plan.md`의
|
||||
"Length/Offset" 절). Roblox 백엔드는 이 인자를 그냥 무시한다 — `LayoutOrder`가
|
||||
물리 순서와 분리돼 있으므로.
|
||||
- **`unmountInst(element)`는 그대로 인자 없음** — 뺄 때는 자기 자신만 알면 된다.
|
||||
- **0-based인 이유**: `offset`/`sum`이 이미 0-based 개수라 그쪽과 일관되고,
|
||||
`updateFn`의 `index`(1-based 지역 위치)와 헷갈리지 않게 하기 위함.
|
||||
- **⚠️ 이름은 `disposeInst` 선례를 따른 가칭** — 주입 op 목록에 정식으로
|
||||
추가할 때 확정할 것(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와
|
||||
주입되는 엔진 op" 절).
|
||||
|
||||
**추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미
|
||||
만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예:
|
||||
`Frame { Frame {} }`)를 위한 핸들러도 필요 — `quad-roblox/src/Handlers/
|
||||
|
|
@ -595,6 +629,7 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
|
|||
|---|---|---|---|
|
||||
| `Add` | `Slot:Add(element, index?): number` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 — **실제로 삽입된 인덱스를 반환** |
|
||||
| `Remove` | `Slot:Remove(index)` | O(n) | 제거 **+ 파괴**(retract/Destroy) — `Extract(index):Destroy()`와 동치, 흔한 경로라 별도 이름으로 유지 |
|
||||
| `Replace` | `Slot:Replace(index, newElement)` | **O(1)** | 그 자리 요소를 교체하고 **이전 것을 파괴** — `Extract(index, newElement)`의 파괴 짝(`Remove` ↔ `Extract` 관계와 동형). **[2026-08-21 5라운드 `B-5` 신설]** |
|
||||
| `Extract` | `Slot:Extract(index, newElement?)` | O(n) 또는 O(1) | `newElement` 생략 — 제거만(파괴 안 함), 뒤 요소가 당겨져 빈 자리를 메움(O(n)). `newElement` 지정 — 그 자리를 즉시 교체(뒤 요소 안 건드림, O(1)), 이전 element를 반환 |
|
||||
| `ExtractAll` | `Slot:ExtractAll(): {T}` | O(n) | 전체 추출(파괴 안 함) — `Clear`의 비파괴 버전, 추출된 element 배열(순서 보존)을 반환 |
|
||||
| `Splice` | `Slot:Splice(index, removeCount, ...newElements): {T}` | O(n) | 한 위치에서 `removeCount`개를 비파괴 추출(반환)하고 그 자리에 `newElements`를 삽입 — shift+recompute 1회로 통합 |
|
||||
|
|
@ -623,6 +658,28 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
|
|||
정확히 같아서(교체도 "그 자리 걸 빼내고 새 걸 넣는" 것의 원자적 버전일
|
||||
뿐). `newElement`에도 `Add`와 같은 검증(이미 마운트/타입 제약)이
|
||||
똑같이 적용됨.
|
||||
- **⭐ `Replace` 신설(2026-08-21 구현 전 QA 5라운드 `B-5`)** — `Extract(index,
|
||||
newElement)`가 이미 "그 자리 교체 + 이전 것 반환"을 O(1)로 해주는데, **흔한
|
||||
경우는 이전 것을 그냥 버리는 것**이다. 그때 `Extract`로 받아서 직접
|
||||
`dispose`하게 하면 `Remove`가 있는데도 `Extract(index):Destroy()`를 쓰게
|
||||
하는 것과 같은 불편이라, 파괴 짝을 이름 하나로 준다.
|
||||
**사용자 판정**: *"차라리, replace 를 제공하는게 나아보임. 해당 요소 자리에
|
||||
교체분을 넣고, 이전건 파기해주는 것. extract 가 뽑아내는것과 다르게 이건
|
||||
제거해준다."*
|
||||
- **진짜 동기는 `:List`의 reconcile 쪽이다** — 교체를 "제거 후 삽입"으로
|
||||
하면 `spliceArraysDown`(뒤 요소 당김) + `spliceArraysUp`(다시 밀어냄)이
|
||||
쌍으로 돌아 **O(n) 시프트가 두 번, `recompute`도 두 번** 난다. 자리 수가
|
||||
안 변하는 교체엔 둘 다 불필요하다(사용자: *"list 에서도 교체 작업이 제거
|
||||
다음 붙여넣기일텐데, 밀고 당기는게 많아짐"*). 그래서 `settle`의 교체
|
||||
분기가 `releaseElement` + `rawAdd`가 아니라 **`rawReplace` 하나**를
|
||||
쓴다(아래 의사코드).
|
||||
- **`Extract`와의 관계**: `Remove` ↔ `Extract`가 "파괴 / 비파괴"로 갈리는
|
||||
것과 정확히 같은 축이다. 새 능력이 아니라 이름 하나.
|
||||
- **[2026-08-21 감사] `rawReplace`의 `destroyOld`가 갈리는 지점**: 공개
|
||||
`Replace`는 **항상 `true`**(사용자가 명시적으로 "이건 지워라"라고 부른
|
||||
것이므로), `:List`의 `settle`만 `self._owned ~= false`를 넘긴다(남의
|
||||
요소면 언마운트만). `_listed` Slot은 공개 CRUD가 막혀 있어 두 경로가
|
||||
한 Slot에서 섞이지 않는다.
|
||||
- **`Splice` 신설(2026-08-12 열다섯 번째 세션)** — 한 구간을 제거하고
|
||||
동시에 새 요소들을 그 자리에 넣는 흔한 배치 갱신(`:List` 없이 수동
|
||||
CRUD로 큰 구간을 통째로 교체하는 경우)을 `Extract`/`Add`를 요소 수만큼
|
||||
|
|
@ -1132,12 +1189,17 @@ GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에
|
|||
패턴(마운트 시점까지 미뤘다가 그 자리에서 `bindLifetime`).
|
||||
|
||||
```lua
|
||||
function Slot:List(data, updateFn, keyFn)
|
||||
function Slot:List(data, updateFn, keyFn, opts)
|
||||
assert(not self._listed, "Slot already has :List installed")
|
||||
self._listed = true
|
||||
self._listData = data
|
||||
self._updateFn = updateFn
|
||||
self._keyFn = keyFn or function(_, index) return index end
|
||||
-- [2026-08-21 감사] `Owned`를 실제로 심는 유일한 자리 — 이 줄이 없으면
|
||||
-- `settle`/`destroySlotTree`/`releaseElement`가 읽는 `self._owned`가
|
||||
-- 영원히 nil(=owned)이라 `Owned = false` 확정이 코드에 도달하지 못한다.
|
||||
-- 기본이 true이므로 **false일 때만** 필드를 세운다(nil = owned).
|
||||
if opts and opts.Owned == false then self._owned = false end
|
||||
|
||||
if self._mounted then
|
||||
activateList(self, self._mountedInst) -- 이미 마운트돼 있으면 즉시 활성화
|
||||
|
|
@ -1186,35 +1248,66 @@ function activateList(self, physicalTarget)
|
|||
-- 소멸 루프가 같은 걸 쓴다(분기가 두 군데로 갈리면 반드시 어긋남).
|
||||
local function settle(key, result, detach, pos)
|
||||
local wasMounted = mounted[key]
|
||||
local wasDetached = self._detached[key] -- Slot 필드(아래 "Detach된 요소는 `slot._detached`가 보유한다" 절)
|
||||
-- [2026-08-21 5라운드 `DE-7`] `_detached`는 **lazy** — 없을 수 있다.
|
||||
-- 읽기는 항상 nil 체크, 쓰기는 `getDetached(self)`(getOrCreate)로.
|
||||
local detached = self._detached
|
||||
local wasDetached = detached and detached[key]
|
||||
local prev = wasMounted or wasDetached
|
||||
|
||||
if detach then
|
||||
-- "이 자리를 비우되 죽이지 마라". 이미 detach 상태면 **nop**.
|
||||
-- "이 자리를 비우되 죽이지 마라."
|
||||
-- **prev가 아예 없어도(마운트도 detach도 아님) nop** — 사용자가 "지금
|
||||
-- prev가 있는지"를 추적할 의무가 없다. 이미 detach 상태여도 nop.
|
||||
if wasMounted ~= nil then
|
||||
rawDetach(self, wasMounted) -- 언마운트하되 **소유권은 유지**
|
||||
self._detached[key], mounted[key] = wasMounted, nil
|
||||
local idx = keyIndex[key] -- 이 키가 지금 차지한 _elements 인덱스
|
||||
if self._owned == false then
|
||||
-- [2026-08-21 5라운드 `DE-13`] **내 것이 아니면 붙잡지 않는다** —
|
||||
-- 언마운트 + 소유권 반납으로 끝내고 `_detached`에 넣지 않는다.
|
||||
-- 다음 사이클의 `prev`는 `nil`이 된다(아래 "`Owned = false`에서
|
||||
-- `Detach`" 절).
|
||||
rawUnmount(self, idx)
|
||||
mounted[key] = nil
|
||||
else
|
||||
rawDetach(self, idx) -- 언마운트하되 **소유권은 유지**
|
||||
getDetached(self)[key], mounted[key] = wasMounted, nil
|
||||
end
|
||||
end
|
||||
elseif result == nil then
|
||||
-- "지워라". Owned=false면 파괴 대신 언마운트만(아래 "Owned" 절).
|
||||
if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end
|
||||
mounted[key], self._detached[key] = nil, nil
|
||||
elseif result == prev then
|
||||
if prev ~= nil then releaseElement(self, keyIndex[key], prev, wasDetached ~= nil) end
|
||||
mounted[key] = nil
|
||||
if detached then detached[key] = nil end
|
||||
elseif result == unwrapElement(prev) then
|
||||
-- [2026-08-21 5라운드 `C-3`] 비교 대상은 **사용자가 받은 값**이다 —
|
||||
-- `prev`는 물리 요소(State를 반환했으면 래퍼 Slot)라 그대로 비교하면
|
||||
-- 같은 State를 다시 반환해도 "다른 값"으로 오인한다.
|
||||
if wasDetached ~= nil then
|
||||
self._detached[key] = nil -- **재마운트** — detach된 걸 되살림
|
||||
detached[key] = nil -- **재마운트** — detach된 걸 되살림
|
||||
-- 4번째 인자 = fromDetached. 소유권을 놓은 적이 없으므로
|
||||
-- `claimOwner`가 재클레임을 허용해야 한다(위 그 함수 주석).
|
||||
rawAdd(self, result, pos, true)
|
||||
mounted[key] = result
|
||||
rawAdd(self, prev, pos, true) -- 물리 요소(=래퍼)를 그대로 되돌림
|
||||
mounted[key] = prev
|
||||
elseif keyIndex[key] ~= pos then
|
||||
rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동
|
||||
rawMove(self, keyIndex[key], pos) -- 그대로 쓰되 위치만 이동
|
||||
end
|
||||
else
|
||||
-- 교체 — 밀려난 prev 처분은 Owned가 정한다
|
||||
if prev ~= nil then releaseElement(self, prev, wasDetached ~= nil) end
|
||||
self._detached[key] = nil
|
||||
rawAdd(self, result, pos)
|
||||
mounted[key] = result
|
||||
-- 교체.
|
||||
-- [2026-08-21 5라운드 `B-5`] 마운트 중이던 자리는 **`rawReplace`** —
|
||||
-- "제거 후 삽입"으로 하면 `spliceArraysDown` + `spliceArraysUp`이 쌍으로
|
||||
-- 돌아 O(n) 시프트와 `recompute`가 두 번씩 난다. 자리 수가 안 바뀌는
|
||||
-- 교체엔 둘 다 불필요.
|
||||
local element = wrapElement(result) -- State면 래퍼 Slot, 아니면 그대로
|
||||
if wasDetached ~= nil then
|
||||
-- detach 중이던 건 이미 `_elements` 밖이라 "자리 교체"가 성립 안 함
|
||||
releaseElement(self, nil, prev, true) -- index 없음(트리 밖)
|
||||
detached[key] = nil
|
||||
rawAdd(self, element, pos)
|
||||
elseif wasMounted ~= nil then
|
||||
rawReplace(self, keyIndex[key], element, self._owned ~= false)
|
||||
else
|
||||
rawAdd(self, element, pos)
|
||||
end
|
||||
mounted[key] = element -- **물리 요소**를 기억한다(래퍼일 수 있음)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1231,7 +1324,9 @@ function activateList(self, physicalTarget)
|
|||
|
||||
-- [2026-08-21] prev는 "이 키의 요소" — 마운트돼 있든 detach돼 있든.
|
||||
-- 그래서 detach된 걸 그대로 반환하면 재마운트가 된다(settle 참고).
|
||||
local prev = mounted[key] or self._detached[key]
|
||||
-- [2026-08-21 5라운드 `C-3`] `updateFn`에는 **래핑 전 값**을 준다
|
||||
-- (물리 요소가 래퍼 Slot이어도 사용자는 자기가 준 State를 다시 본다).
|
||||
local prev = unwrapElement(mounted[key] or (self._detached and self._detached[key]))
|
||||
local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능)
|
||||
local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key])
|
||||
if result == None then result = nil end -- 편의: None도 nil과 동일 취급
|
||||
|
|
@ -1258,14 +1353,17 @@ function activateList(self, physicalTarget)
|
|||
-- 아래 "`KeyGone`" 절이 소스.
|
||||
for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key
|
||||
if not seen[key] then
|
||||
local prev = mounted[key] or self._detached[key]
|
||||
local prev = unwrapElement(mounted[key] or (self._detached and self._detached[key]))
|
||||
local result, ud = updateFn(KeyGone, 0, offset, prev, userdata[key])
|
||||
if result == None then result = nil end
|
||||
local detach = (result == Detach)
|
||||
if detach then result = nil end
|
||||
if result ~= nil and result == prev then
|
||||
error("Slot:List — KeyGone에 prev를 그대로 반환할 수 없음(키가 없는데 마운트 유지는 모순). "
|
||||
.. "계속 들고 있으려면 Detach를 반환할 것")
|
||||
-- [2026-08-21 5라운드 `DE-9`] prev든 **새 값이든** 전부 error.
|
||||
-- KeyGone을 받은 자리는 "데이터가 다시 나타날 때를 위한 캐싱"
|
||||
-- (= Detach) 아니면 파괴뿐이고, **새 마운트/생성은 거부**한다.
|
||||
if result ~= nil then
|
||||
error("Slot:List — KeyGone에는 nil/None(파괴) 또는 Detach(홀드)만 반환할 수 있음 "
|
||||
.. "(자리가 없어진 키에 새 요소를 마운트할 수 없고, prev 유지도 모순)")
|
||||
end
|
||||
settle(key, result, detach, 0) -- pos는 의미 없음(자리를 안 차지함)
|
||||
userdata[key] = ud -- 유저가 nil을 반환해야 지워짐
|
||||
|
|
@ -1287,6 +1385,7 @@ function activateList(self, physicalTarget)
|
|||
-- 게이팅할 뿐 죽는 순간의 콜백을 안 준다(`base/effect-plan.md`).
|
||||
self._detachCleanup = Effect(function()
|
||||
return function()
|
||||
if not self._detached then return end -- [2026-08-21] lazy — 한 번도 detach 안 했으면 끝
|
||||
for key, element in pairs(self._detached) do
|
||||
-- releaseOwner를 **먼저, 두 분기 공통으로**. 빠뜨리면
|
||||
-- `_owned == false` 요소가 죽은 Slot을 owner로 달고 남아,
|
||||
|
|
@ -1294,9 +1393,10 @@ function activateList(self, physicalTarget)
|
|||
-- "이미 마운트돼 있음" error가 난다 — 위 "소유권 반납은
|
||||
-- GC에 맡기면 안 됨" 절이 경계하는 실패 모드.
|
||||
releaseOwner(element, self)
|
||||
if self._owned ~= false then
|
||||
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
|
||||
end
|
||||
-- [2026-08-21 5라운드 `DE-13`] `_owned == false` 분기가 여기 있었으나
|
||||
-- 제거했다 — unowned Slot은 `_detached`를 **애초에 채우지 않으므로**
|
||||
-- (위 `settle`의 detach 분기) 여기 오는 건 전부 내 것이다.
|
||||
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
|
||||
self._detached[key] = nil
|
||||
end
|
||||
end
|
||||
|
|
@ -1454,10 +1554,33 @@ GC 폴백이 아예 없으므로 명시적 정리 경로가 **필수**다.
|
|||
|
||||
**확정된 형태**:
|
||||
|
||||
- **`slot._detached: {[key]: T}` — Slot 필드.** 클로저 업밸류가 아니라
|
||||
필드여야 `destroySlotTree`/`dispose`의 walk가 닿는다. (`mounted`/`userdata`/
|
||||
`keyIndex`가 `activateList`의 업밸류로 남는 것과 다른 이유 —
|
||||
**마운트된 요소는 `_elements`를 통해 walk가 이미 닿기 때문**이다.)
|
||||
- **`slot._detached: {[key]: T}?` — Slot 필드, 단 lazy(`nil` 허용).** 클로저
|
||||
업밸류가 아니라 필드여야 `destroySlotTree`/`dispose`의 walk가 닿는다.
|
||||
(`mounted`/`userdata`/`keyIndex`가 `activateList`의 업밸류로 남는 것과 다른
|
||||
이유 — **마운트된 요소는 `_elements`를 통해 walk가 이미 닿기 때문**이다.)
|
||||
- **⭐ [2026-08-21 5라운드 `DE-7`] 모든 Slot이 이 테이블을 미리 갖지
|
||||
않는다** — `_detached`를 채우는 건 `:List`의 `settle`뿐인데 Slot 대부분은
|
||||
`:List`도, detach도 없다. **사용자 판정**: *"테이블 생성 비용을 모든
|
||||
slot 이 가져야하나는 의문 … if 확인으로 nil 이면 스킵이 훨씬 싸게
|
||||
먹히지 않는가? … 최적화에 드는 비용이 거의 없는데, 안 할 이유가 보이진
|
||||
않음."* 그래서 **읽기는 항상 `if slot._detached then`, 쓰기는
|
||||
`getDetached(slot)`(getOrCreate 유틸)**로 통일한다 — `Relate`의
|
||||
`StrongMap`/`WeakMap`이 첫 `Set`에서야 만들어지는 것과 같은 관례
|
||||
(`base/relate-plan.md`의 "실제 구조" 절).
|
||||
- **⭐ [2026-08-21 5라운드] `Detach`의 nop 조건은 둘이다** — (a) 이미
|
||||
detach 중, (b) **`prev`가 아예 없음**(그 키에 마운트된 것도 detach된 것도
|
||||
없음). 후자도 조용히 무시하므로 **`updateFn`이 "지금 prev가 있는지"를
|
||||
추적할 의무가 없다**(사용자 질문에 대한 확정) — `if not shouldShow(item)
|
||||
then return Detach end`처럼 조건만 보고 반환해도 안전하다.
|
||||
- **⭐ [2026-08-21 5라운드 `DE-13`] `Owned = false`에서 `Detach`는
|
||||
`_detached`에 안 들어간다.** unowned 요소는 애초에 남의 것이라 "잠깐
|
||||
빼두고 내가 계속 들고 있는다"가 성립하지 않는다 — 그래서 `rawDetach`가
|
||||
아니라 **`rawUnmount`(언마운트 + 소유권 반납)**로 처리하고, 다음 사이클의
|
||||
`prev`는 `nil`이 된다. **사용자 판정**: *"애초에 unowned 의 state 로 받은것은
|
||||
더이상 가지고 있지 않는다. detach 에 들어가있지도 않는다. 외부로 반출된
|
||||
것이라 다시 들고와서 자기 자신에 붙이지 않음."* 부수 결과 — unowned
|
||||
`:List`에서는 `Detach`와 `nil`이 **같은 동작**이 되고(둘 다 언마운트+반납),
|
||||
`_detached`가 영원히 `nil`이라 `_detachCleanup`도 할 일이 없다.
|
||||
- **소유권은 유지된다** — `Detach`는 `rawUnmount`(소유권 반납)가 아니라
|
||||
`rawDetach`(**소유권 유지**)를 쓴다. 아래 "raw 3형제" 참고.
|
||||
- **`prev`로 그대로 돌려준다** — reconcile이 `mounted[key] or self._detached[key]`를
|
||||
|
|
@ -1469,6 +1592,12 @@ GC 폴백이 아예 없으므로 명시적 정리 경로가 **필수**다.
|
|||
- **`ud`에 담는 것 자체는 여전히 자유** — 다만 **보존을 위해 담을 필요가
|
||||
없어졌고**, 담더라도 그건 사용자 몫이라 `:List`가 정리해주지 않는다
|
||||
(아래 "`userdata`의 생명주기 제약" 절).
|
||||
- **⚠️ [2026-08-21 5라운드 `DE-11`] 문서화 시 반드시 같이 적을 것 — 홀드된
|
||||
요소는 owner가 죽을 때까지 쌓인다.** `Detach`는 **삽입/삭제가 빈번한
|
||||
경우를 위한 재사용 최적화일 뿐 그 이상을 돕지 않는다**(사용자 확정).
|
||||
키가 다시 안 나타나면 `_detachCleanup`이 도는 시점(owner 사망)까지
|
||||
`_detached`에 남으므로, churn이 큰 리스트에서 이걸 무한정 쓰면 메모리가
|
||||
는다 — 잘라내는 정책(LRU/최대 개수)은 **만들지 않는다**.
|
||||
|
||||
```lua
|
||||
-- filter에서 걸러진 아이템 — 그냥 Detach만 반환하면 된다
|
||||
|
|
@ -1497,9 +1626,15 @@ updateFn(item: T | KeyGone, index, offset, prev, ud)
|
|||
- **`updateFn`은 `if item == KeyGone then ... end`로 분기**해 처분을 고른다 —
|
||||
반환값 의미는 정상 사이클과 **완전히 같다**(`nil`=파괴, `Detach`=계속 홀드,
|
||||
새 값=교체). 그래서 reconcile의 처분 로직(`settle`)이 한 벌로 공유된다.
|
||||
- **`prev`를 그대로 반환하는 것만 `error`** — 키가 없는데 마운트를 유지하라는
|
||||
건 모순이다. 계속 들고 있으려면 `Detach`를 반환해야 한다. (다른 CRUD 에러
|
||||
조건들과 같은 fail-fast 톤.)
|
||||
- **⭐ [정정, 2026-08-21 5라운드 `DE-9`] `nil`/`None`/`Detach` 외의 반환은
|
||||
전부 `error`** — `prev`를 그대로 반환하는 것뿐 아니라 **새 요소를 반환하는
|
||||
것도** 거부한다. **사용자 판정**: *"KeyGone 을 받은 요소는 오직 데이터의
|
||||
파괴 또는 detach 를 통해 다시 나오는 경우를 위한 캐싱 이외의 새로운
|
||||
마운트나 생성을 거부한다."* 자리가 없어진 키에 새 요소를 마운트하면
|
||||
넣을 위치 자체가 없다(옛 의사코드는 `pos = 0`으로 `rawAdd`를 불러 범위 밖
|
||||
인덱스로 터졌을 것). 그래서 소멸 루프의 `settle` 호출은 **항상 `result ==
|
||||
nil`**이고, 교체 분기에 도달하는 경로가 없다. (다른 CRUD 에러 조건들과 같은
|
||||
fail-fast 톤.)
|
||||
- **`index`는 `0`** — 사라진 키는 자리를 안 차지한다. `offset`/`sum`이 이미
|
||||
0-based 개수라 "아무 자리도 없음"이 `0`으로 자연스럽고, `index: number`라는
|
||||
시그니처도 안 바뀐다(`nil`을 넣으면 타입이 바뀜). `offset`은 Slot의 것을
|
||||
|
|
@ -1563,6 +1698,7 @@ Slot:Single(state, updateFn?, opts?)
|
|||
| `updateFn`이 새 값 반환 | 밀려난 `prev` **파괴** | **언마운트만** |
|
||||
| `updateFn`이 `nil`/`None` 반환 | **파괴** | **언마운트만** |
|
||||
| 키가 데이터에서 사라짐 | **파괴** | **언마운트만** |
|
||||
| `updateFn`이 `Detach` 반환 | `_detached`가 보유(소유권 유지) | **언마운트 + 소유권 반납**(`_detached`에 안 들어감, 다음 `prev`는 `nil`) — **[2026-08-21 5라운드 `DE-13`]** |
|
||||
| owner가 죽음(`destroySlotTree`/`dispose`) | **파괴**(재귀) | **언마운트만** |
|
||||
| `Slot:Add(state)` sugar | — | **이걸로 설치됨** |
|
||||
|
||||
|
|
@ -1735,7 +1871,7 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제
|
|||
```lua
|
||||
local function identityUpdateFn(item) return item end
|
||||
|
||||
function Slot:Single(state, updateFn)
|
||||
function Slot:Single(state, updateFn, opts)
|
||||
updateFn = updateFn or identityUpdateFn -- [2026-08-11 일곱 번째 세션] 기본값 추가
|
||||
|
||||
local data = if isState(state)
|
||||
|
|
@ -1744,7 +1880,7 @@ function Slot:Single(state, updateFn)
|
|||
|
||||
return self:List(data, function(item, index, offset, prev, ud)
|
||||
return updateFn(item, offset, prev, ud) -- index는 항상 상수라 안 넘김
|
||||
end, function() return true end) -- 고정 key
|
||||
end, function() return true end, opts) -- 고정 key, opts는 그대로 전달
|
||||
end
|
||||
```
|
||||
|
||||
|
|
@ -1854,10 +1990,18 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
|
|||
-- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다.
|
||||
local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
||||
-- offset 먼저 — activateList가 updateFn에 이 값을 넘겨야 하므로(C1)
|
||||
local offsetSource = Source(0)
|
||||
-- [2026-08-21 5라운드 G절 검토 중 발견] **기존 Source가 있으면 재사용한다.**
|
||||
-- 매번 `Source(0)`을 새로 만들면, 언마운트가 `slot.Offset`을 일부러 보존해둔
|
||||
-- 이유(이미 렌더된 요소들이 그 Source를 **구독한 채 함께 딸려 나간다**,
|
||||
-- `SL-75`/`DC-6`)가 재마운트에서 그대로 무너진다 — 그 구독자들은 옛 객체를
|
||||
-- 계속 보고 있어 새 위치가 영원히 반영 안 됨. identity 유지가 포탈의 전제.
|
||||
local offsetSource = slot.Offset or Source(0)
|
||||
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
|
||||
slot.Offset = offsetSource
|
||||
|
||||
-- [2026-08-21 5라운드 G절] `recompute(slot, ...)`은 `0`이 아니라 **이
|
||||
-- `slot.Offset`**에서 시작한다 — 그래야 자식 offset이 절대값이 된다(안 그러면
|
||||
-- depth ≥ 2에서 부모 베이스만큼 어긋남). 베이스를 부기에 따로 복사해두지
|
||||
-- 않는다(같은 값이 두 곳에 생김) — `base/dispatch-core-plan.md`의 `recompute`.
|
||||
-- `_mounted`는 여전히 false — reconcile의 rawAdd가 "아직 마운트 전"
|
||||
-- 경로(= `_elements`에만 넣고 끝)를 타야 함(RC-3/RC-4). 이제 이 조건이
|
||||
-- **함수 경계로 강제**된다: `_mounted`를 켜는 코드가 이 함수엔 아예 없음.
|
||||
|
|
@ -1868,6 +2012,23 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
|||
-- 정의와 범위가 정확히 일치함(옛 코드는 물리 마운트까지 같이 감쌌음).
|
||||
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
|
||||
blocker:On()
|
||||
|
||||
-- [2026-08-21 5라운드 G절] **깊은 전파** — 앞 형제의 길이가 변해 내 베이스가
|
||||
-- 밀리면 내 자식들의 offset도 다시 계산돼야 한다.
|
||||
-- `_listObserver`/`_detachCleanup`과 **같은 취급**: 생성 1회, 재마운트 땐
|
||||
-- 앵커만 새 target으로(앵커가 물리 target인 근거는 위 `C-4`).
|
||||
-- **생성이 `blocker:On()` *뒤*인 게 중요하다** — Observer의 "등록 즉시 1회
|
||||
-- 실행"이 여기서 곧바로 `recompute`를 태우면 아직 자식 등록이 하나도 안 된
|
||||
-- 상태를 훑게 된다. 게이트 안에서 만들면 그 1회가 그냥 삼켜지고, 아래
|
||||
-- 마지막 `recompute`가 어차피 정확한 값을 계산한다.
|
||||
if slot._baseObserver then
|
||||
bindLifetime(physicalTarget, slot._baseObserver)
|
||||
else
|
||||
slot._baseObserver = offsetSource:Observer(function()
|
||||
if not getBlocker(slot):IsOn() then recompute(slot, getBookkeeping(slot)) end
|
||||
end)
|
||||
bindLifetime(physicalTarget, slot._baseObserver)
|
||||
end
|
||||
for i, element in ipairs(slot._elements) do
|
||||
if isSlot(element) then
|
||||
-- 재귀 — 자식이 자기 끝에서 setLength(slot, i, 자기Length)까지 하고 옴
|
||||
|
|
@ -1876,7 +2037,7 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
|||
-- 평범한 요소: 자기 자리의 offset은 아무도 안 읽으므로 None,
|
||||
-- length는 상수 1. 순서는 늘 offsetSource → setLength(C4).
|
||||
Dispatch.setOffsetSource(slot, i, None)
|
||||
Dispatch.setLength(slot, i, 1)
|
||||
Dispatch.setLength(slot, i, 1, physicalTarget) -- 4번째 = 생명주기 앵커(5라운드 `C-4`)
|
||||
end
|
||||
end
|
||||
-- [2026-08-21 감사] 위 재귀가 **예외를 던지면 이 줄에 도달하지 못해
|
||||
|
|
@ -1896,20 +2057,34 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
|||
-- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에
|
||||
-- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에
|
||||
-- 만족시킬 수 없었던 지점.
|
||||
Dispatch.setLength(ownerKey, position, slot.Length)
|
||||
Dispatch.setLength(ownerKey, position, slot.Length, physicalTarget)
|
||||
end
|
||||
|
||||
-- (2) 물리만 붙인다. 부기를 단 한 줄도 안 건드린다 → Blocker 불필요.
|
||||
-- **⚠️ [2026-08-21 감사] 전제: 반드시 `materializeSlotTree` 이후에만 부를 것.**
|
||||
-- 아래 `acc`가 `slot.Offset:Get()`에서 시작하는데, 그 값이 최종값이 되는 건
|
||||
-- materialize의 마지막 `recompute`가 끝난 뒤다. 순서를 뒤집거나 materialize를
|
||||
-- 건너뛰고 부르면 물리 삽입 위치가 조용히 어긋난다(공개 `attachSlot`이 둘을
|
||||
-- 붙여 부르는 것이 이 계약의 전부 — `research/slot-attach-decomposition.md`의
|
||||
-- "prepare만 하고 mount 안 한 중간 상태" 항목이 아직 열려 있는 이유이기도 하다).
|
||||
local function mountSlotTree(slot, physicalTarget)
|
||||
slot._mounted = true
|
||||
slot._mountedInst = physicalTarget
|
||||
-- [2026-08-21 5라운드 G절] 물리 삽입 위치(절대 offset, 0-based)를 같이 넘긴다.
|
||||
-- 러닝 누적이라 O(n) — 자리마다 getOffsetAt을 부르면 O(n²)가 된다.
|
||||
local acc = slot.Offset:Get()
|
||||
-- [이관, 2026-08-21] `_detachCleanup` Effect 설치가 여기 있었으나
|
||||
-- `activateList`로 옮겼다 — `_detached`를 채우는 건 `:List`의 `settle`뿐이라
|
||||
-- List 없는 Slot마다 no-op Effect를 심고 있었다(위 그 함수의 주석이 소스).
|
||||
-- 그래서 이 함수는 이제 **정말로 물리 대입만** 한다.
|
||||
for i, element in ipairs(slot._elements) do
|
||||
if isSlot(element) then mountSlotTree(element, physicalTarget)
|
||||
else element.Parent = physicalTarget end -- quad-roblox 글루가 실제 수행
|
||||
if isSlot(element) then
|
||||
mountSlotTree(element, physicalTarget) -- 자식은 자기 Offset에서 다시 시작
|
||||
acc += element.Length:Get()
|
||||
else
|
||||
mountInst(physicalTarget, element, acc) -- 주입 op(위 "물리 조작은 주입 op" 절)
|
||||
acc += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -2005,7 +2180,7 @@ local function unmountSlotTree(slot)
|
|||
if isSlot(element) then
|
||||
unmountSlotTree(element) -- 재귀 — 중첩 Slot도 똑같이 비파괴
|
||||
else
|
||||
element.Parent = nil -- Destroy 아님(quad-roblox 글루가 수행)
|
||||
unmountInst(element) -- 파괴 아님(주입 op — 위 "물리 조작은 주입 op" 절)
|
||||
end
|
||||
-- releaseOwner를 **안 부름** — 자식들은 여전히 이 slot의 소유. 이게
|
||||
-- destroySlotTree와의 핵심 차이(파괴는 소유권까지 반납, 언마운트는 유지).
|
||||
|
|
@ -2026,6 +2201,7 @@ local function unmountSlotTree(slot)
|
|||
-- 멱등 가드다. 파괴 쪽(`destroySlotTree`)만 핸들까지 `nil`로 지운다.
|
||||
if slot._listObserver then unbindLifetime(slot._listObserver) end
|
||||
if slot._detachCleanup then unbindLifetime(slot._detachCleanup) end
|
||||
if slot._baseObserver then unbindLifetime(slot._baseObserver) end -- [2026-08-21 G절]
|
||||
-- `slot._detached`는 **안 건드린다** — 언마운트는 파괴가 아니고,
|
||||
-- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유).
|
||||
slot._mounted, slot._mountedInst = false, nil
|
||||
|
|
@ -2050,15 +2226,17 @@ local function destroySlotTree(slot)
|
|||
if isSlot(element) then
|
||||
destroySlotTree(element) -- 재귀는 "파괴"에만, choreography 없음
|
||||
else
|
||||
element:Destroy()
|
||||
disposeInst(element)
|
||||
end
|
||||
end
|
||||
-- [2026-08-21] Detach로 홀드 중인 요소도 같이 파괴 — 이것들은 `_elements`에
|
||||
-- 없으므로(rawDetach가 뺐음) 위 루프가 못 닿는다. **`dispose`가 재귀적으로
|
||||
-- 잘 죽이는가**의 답이 정확히 이 줄이다(아래 "`dispose`" 절).
|
||||
for key, element in pairs(slot._detached) do
|
||||
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
|
||||
slot._detached[key] = nil
|
||||
if slot._detached then -- [2026-08-21 5라운드 `DE-7`] lazy — 없으면 통째로 스킵
|
||||
for key, element in pairs(slot._detached) do
|
||||
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
|
||||
slot._detached[key] = nil
|
||||
end
|
||||
end
|
||||
if slot._detachCleanup then
|
||||
unbindLifetime(slot._detachCleanup) -- 이미 손으로 비웠으니 Effect는 할 일 없음
|
||||
|
|
@ -2077,6 +2255,10 @@ local function destroySlotTree(slot)
|
|||
unbindLifetime(slot._listObserver)
|
||||
slot._listObserver, slot._listActivated = nil, nil
|
||||
end
|
||||
if slot._baseObserver then -- [2026-08-21 G절] 파괴는 핸들까지
|
||||
unbindLifetime(slot._baseObserver)
|
||||
slot._baseObserver = nil
|
||||
end
|
||||
local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들
|
||||
if bk then
|
||||
for i, observer in pairs(bk.observers) do
|
||||
|
|
@ -2094,11 +2276,19 @@ local function destroySlotTree(slot)
|
|||
end
|
||||
|
||||
-- [명확화, 2026-08-13 감사에서 index/element 불일치 발견해 보강] 아래
|
||||
-- 시그니처는 index 기준 예시 — 위 "raw* 내부 호출 규약" 절이 이미
|
||||
-- 못박았듯 reconcile(위 "여러 Slot이 섞일 때" 절 근처)은 element 기준으로
|
||||
-- rawRemove(self, prev)를 부름. **같은 불일치가 rawUnmount에도 그대로
|
||||
-- 있음** — 아래 reconcile 예시의 rawUnmount(self, prev) 호출도 prev가
|
||||
-- element(mounted[key])이지 index가 아님. 둘 중 하나로 통일할지 얇은
|
||||
-- **✅ [해소, 2026-08-21 5라운드 — index 기준으로 통일 확정]** 오래 열려 있던
|
||||
-- "raw*가 index 기준과 element 기준으로 섞여 있다"는 캐비엇은 **전부 index로
|
||||
-- 통일**하는 것으로 닫혔다(**사용자 확정**: *"index 로 전부 처리되면 될듯.
|
||||
-- 애초에 안에서 다시 element -> index 를 찾아야하던걸로 앎"*).
|
||||
-- * `rawRemove`/`rawUnmount`/`rawDetach`/`rawMove`/`rawSwap`/`rawExtract`/
|
||||
-- `rawSplice`/`rawReplace` — **전부 index를 받는다.**
|
||||
-- * 예외는 `rawAdd(self, element, index, fromDetached?)` 하나 — 새로 넣는
|
||||
-- 대상이라 element가 인자인 게 당연하다(그 element는 **이미 래핑된 물리
|
||||
-- 요소**여야 한다, 아래 "래핑은 raw 바깥에서" 항목).
|
||||
-- * 그래서 element만 손에 쥔 호출부(`:List`의 `settle`)가 index를 구해야
|
||||
-- 하는데, **그 값은 이미 `keyIndex`가 들고 있다**(그 키가 지금 차지한
|
||||
-- 압축 위치 = `_elements` 인덱스). `indexOfRaw`(O(n) 선형 탐색)는 그게
|
||||
-- 없는 예외 경로용 폴백이지 기본 경로가 아니다. 둘 중 하나로 통일할지 얇은
|
||||
-- 변환 계층을 둘지는 아직 M6 구현 세부로 열려 있음 — 이 블록/아래
|
||||
-- reconcile 블록 둘 다 그 결정 전 illustrative 예시.
|
||||
-- [신설, 2026-08-13 여섯 번째 세션] rawRemove의 비파괴 짝 — `:List`의
|
||||
|
|
@ -2110,7 +2300,7 @@ function rawUnmount(self, index)
|
|||
unbindLifetime(bk.observers[index])
|
||||
end
|
||||
releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음)
|
||||
if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end
|
||||
if isSlot(element) then unmountSlotTree(element) else unmountInst(element) end
|
||||
|
||||
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
|
||||
recompute(self, bk)
|
||||
|
|
@ -2127,7 +2317,7 @@ function rawDetach(self, index)
|
|||
unbindLifetime(bk.observers[index])
|
||||
end
|
||||
-- releaseOwner를 **안 부름** — 이게 rawUnmount와의 유일한 차이
|
||||
if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end
|
||||
if isSlot(element) then unmountSlotTree(element) else unmountInst(element) end
|
||||
|
||||
spliceArraysDown(self, index)
|
||||
recompute(self, bk)
|
||||
|
|
@ -2136,21 +2326,90 @@ end
|
|||
-- [신설, 2026-08-21] 밀려나거나 지워지는 요소의 처분 — `Owned`가 정한다
|
||||
-- (위 "`Owned` 옵션" 절). detach 중이던 요소는 이미 `_elements`에 없으므로
|
||||
-- spliceArraysDown/recompute가 필요 없어 경로가 갈린다.
|
||||
function releaseElement(self, element, wasDetached)
|
||||
-- [시그니처 정리, 2026-08-21] index 우선 — detach 중이던 요소만 index가 없다
|
||||
-- (트리 밖이라 자리 자체가 없음), 그 경우 `index = nil`로 부른다.
|
||||
function releaseElement(self, index, element, wasDetached)
|
||||
if wasDetached then
|
||||
if self._owned ~= false then
|
||||
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
|
||||
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
|
||||
end
|
||||
return -- 이미 물리 트리 밖 + 부기 밖이라 더 할 일 없음
|
||||
end
|
||||
if self._owned ~= false then rawRemove(self, element) -- 파괴
|
||||
else rawUnmount(self, element) end -- 언마운트만(사용자 것)
|
||||
if self._owned ~= false then rawRemove(self, index) -- 파괴
|
||||
else rawUnmount(self, index) end -- 언마운트만(사용자 것)
|
||||
end
|
||||
|
||||
-- **raw 3형제 — 갈리는 축이 둘(파괴하는가 / 소유권을 놓는가)**:
|
||||
-- rawRemove : 소유권 반납 + **파괴**
|
||||
-- rawUnmount: 소유권 반납 + 파괴 안 함 ← 요소를 살려서 내보내는 경로
|
||||
-- rawDetach : **소유권 유지** + 파괴 안 함 ← 내가 계속 들고 있는 경로
|
||||
-- 셋 다 **자리를 없애는** 연산이라 spliceArraysDown이 따라붙는다. 자리를
|
||||
-- 유지한 채 내용만 바꾸는 rawReplace(아래)는 그래서 별개 축이다.
|
||||
|
||||
-- [신설, 2026-08-21 5라운드 `C-1`] rawAdd — 이 문서에서 가장 많이 참조되는데
|
||||
-- 정의가 없어서 `_mounted` 분기가 다른 함수 주석에만 흩어져 있었다. 새 결정은
|
||||
-- 없고 기존 서술을 모은 것(사용자 확인, 5라운드).
|
||||
function rawAdd(self, element, index, fromDetached)
|
||||
claimOwner(element, self, fromDetached) -- 이미 누가 갖고 있으면 error(detach 재마운트만 예외)
|
||||
index = index or (#self._elements + 1)
|
||||
table.insert(self._elements, index, element)
|
||||
|
||||
if not self._mounted then
|
||||
-- **아직 마운트 전: 부기도 물리도 없다.** 이 분기가 `materializeSlotTree`가
|
||||
-- `activateList`를 Blocker **밖**에서 불러도 안전한 이유다(5라운드 `AS-5`)
|
||||
-- — 게이팅할 recompute 자체가 안 일어난다. 나중에 attachSlot이 통째로 처리.
|
||||
return index
|
||||
end
|
||||
|
||||
local bk = getBookkeeping(self)
|
||||
spliceArraysUp(self, index) -- _elements 외 배열들을 한 칸 밀고 bk.N 증가
|
||||
|
||||
if isSlot(element) then
|
||||
attachSlot(element, self._mountedInst, self, index) -- 자식이 자기 부기+물리를 다 함
|
||||
else
|
||||
Dispatch.setOffsetSource(self, index, None) -- 순서는 늘 offsetSource → setLength(C4)
|
||||
Dispatch.setLength(self, index, 1, self._mountedInst)
|
||||
recompute(self, bk) -- 부기 완결(C7: 물리보다 먼저)
|
||||
mountInst(self._mountedInst, element, Dispatch.getOffsetAt(self, index))
|
||||
-- 그 다음에야 물리 마운트(주입 op, 위 그 절)
|
||||
end
|
||||
return index
|
||||
end
|
||||
|
||||
-- [신설, 2026-08-21 5라운드 `B-5`] rawReplace — 자리를 유지한 채 내용만 교체.
|
||||
-- `destroyOld`는 호출부가 정한다(공개 `Replace`는 항상 true, `:List`는 `_owned`).
|
||||
-- [2026-08-21] `indexOfRaw(self, element)` — `_elements`를 **언래핑 없이 그대로**
|
||||
-- 선형 탐색해 인덱스를 찾는 비공개 폴백. 공개 `Slot:IndexOf`와 다르다: 그쪽은
|
||||
-- 사용자가 넘긴 **언래핑된 값**으로 찾아주는 API고(위 "래핑/언래핑" 절), 이쪽은
|
||||
-- 물리 요소를 그대로 찾는다. **기본 경로가 아니다** — reconcile은 `keyIndex`가
|
||||
-- 이미 인덱스를 들고 있어 이걸 부를 필요가 없다(위 raw* 인자 규약).
|
||||
function rawReplace(self, index, newElement, destroyOld)
|
||||
local oldElement = self._elements[index]
|
||||
claimOwner(newElement, self) -- 새 요소 먼저 클레임(실패하면 아무것도 안 바뀜)
|
||||
releaseOwner(oldElement, self)
|
||||
|
||||
if self._mounted then
|
||||
-- 빼기는 물리 먼저(C7의 "좁은 쪽이 먼저")
|
||||
if isSlot(oldElement) then unmountSlotTree(oldElement) else unmountInst(oldElement) end
|
||||
end
|
||||
if destroyOld then
|
||||
if isSlot(oldElement) then destroySlotTree(oldElement) else disposeInst(oldElement) end
|
||||
end
|
||||
|
||||
self._elements[index] = newElement -- **시프트 없음** — 자리 수가 안 변한다
|
||||
if not self._mounted then return end
|
||||
|
||||
local bk = getBookkeeping(self)
|
||||
if isSlot(newElement) then
|
||||
attachSlot(newElement, self._mountedInst, self, index)
|
||||
else
|
||||
Dispatch.setOffsetSource(self, index, None)
|
||||
Dispatch.setLength(self, index, 1, self._mountedInst)
|
||||
recompute(self, bk)
|
||||
mountInst(self._mountedInst, newElement, Dispatch.getOffsetAt(self, index))
|
||||
end
|
||||
end
|
||||
|
||||
function rawRemove(self, index)
|
||||
local element = self._elements[index]
|
||||
local bk = getBookkeeping(self)
|
||||
|
|
@ -2162,7 +2421,7 @@ function rawRemove(self, index)
|
|||
-- rawExtract가 releaseOwner를 부른다고 이미 명시하고
|
||||
-- 있었는데 코드만 불일치) — 엄격 releaseOwner가
|
||||
-- 들어온 뒤로는 이 누락이 실동작 차이를 만듦
|
||||
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
|
||||
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
|
||||
|
||||
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
|
||||
recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만
|
||||
|
|
@ -2288,16 +2547,70 @@ Single(element)`을 대신 삽입** — raw 반응형 요소는 전부 Slot-in-S
|
|||
중첩(위 절) 위에 얹힌 `:Single`의 얇은 sugar일 뿐이다:
|
||||
|
||||
```lua
|
||||
-- [정정, 2026-08-21 5라운드 `C-3`] 래핑을 공개 `Add` 안에 인라인으로 두지 않고
|
||||
-- **공용 헬퍼 하나**로 뺀다 — `Replace`/`Extract(index, new)`/`Splice`/`:List`의
|
||||
-- reconcile까지 전부 같은 래핑이 필요하기 때문(아래 절).
|
||||
function Slot:Add(element, index)
|
||||
if isState(element) then
|
||||
local sub = Slot()
|
||||
sub:Single(element) -- updateFn 생략 시 identity 기본값(아래 참고)
|
||||
element = sub
|
||||
end
|
||||
return rawAdd(self, element, index)
|
||||
return rawAdd(self, wrapElement(element), index)
|
||||
end
|
||||
```
|
||||
|
||||
### ⭐ 래핑/언래핑은 Slot 전체에 걸린 연산이다 (2026-08-21 구현 전 QA 5라운드 `C-3`)
|
||||
|
||||
**사용자 지적**: *"replace, extract 등에서도 래핑해야하므로 래핑이 하나의
|
||||
함수로 나와야한다. 그리고, 또, IndexOf 와 Get 등은 래핑 전 객체를 주어야할텐데
|
||||
… 입력을 isState 인지 확인하고 래핑하거나 안 하거나 하는 래퍼와 반대로 get 등을
|
||||
위해 언랩하는 도구가 필요하다."* 그대로 확정한다.
|
||||
|
||||
```lua
|
||||
-- Slot 내부 비공개 헬퍼 둘. 이 둘만이 래핑을 아는 자리다.
|
||||
local function wrapElement(v)
|
||||
if not isState(v) then return v end
|
||||
local sub = Slot()
|
||||
sub:Single(v, nil, { Owned = false }) -- updateFn은 identity 기본값.
|
||||
-- **`Owned = false`를 여기서 실제로 넘긴다** — 안쪽
|
||||
-- 요소는 사용자 것이라 래퍼가 죽어도 파괴하면 안 됨.
|
||||
sub._wrapped = v -- ← 역참조. 언래핑이 O(1)이 되는 근거
|
||||
return sub
|
||||
end
|
||||
|
||||
local function unwrapElement(el)
|
||||
if el == nil then return nil end
|
||||
return el._wrapped or el -- 래퍼면 원래 값, 아니면 자기 자신
|
||||
end
|
||||
```
|
||||
|
||||
- **⭐ [확정, 2026-08-21] 래핑은 `raw*` **바깥**에서 한다 — `raw*`는 언제나
|
||||
이미 래핑된 물리 요소만 다룬다.** 래핑 지점은 정확히 둘: 공개 표면
|
||||
(`Slot:Add`/`Replace`/`Extract(index, new)`/`Splice`)과 `:List`의 `settle`.
|
||||
(**사용자 확인**: *"raw들은 모두 래핑된거 그대로 넣고 빼도록 할까?"* — 그렇다.)
|
||||
- **왜 `rawAdd` 안이 아닌가**: `raw*`가 래핑까지 하면 "이 함수가 받은 게
|
||||
사용자 값인가 물리 요소인가"가 호출부마다 달라져, 지금 막 index로 통일한
|
||||
인자 규약이 다시 흐려진다. 래핑을 바깥에 두면 `raw*`의 세계는 **물리
|
||||
요소 하나로 균일**하다.
|
||||
- 그래서 `mounted[key]`도, `_elements`도, `_detached`도 전부 **물리 요소**를
|
||||
담는다. 언래핑은 **사용자에게 나갈 때만** 일어난다.
|
||||
- **`_elements`에는 항상 물리 요소(래퍼일 수 있음)가 들어간다** — 부기/물리
|
||||
조작(`rawAdd`/`rawReplace`/`rawMove`/`rawRemove`/`attachSlot`)은 전부 이쪽을
|
||||
본다.
|
||||
- **사용자에게 나가는 값은 전부 `unwrapElement`를 거친다** — `Get`/`IndexOf`/
|
||||
`Extract`/`ExtractAll`/`Splice`의 반환값, 그리고 `:List`의 `updateFn`이 받는
|
||||
`prev`. 사용자는 자기가 넣은 State를 그대로 돌려받지, quad가 만든 래퍼 Slot을
|
||||
보지 않는다.
|
||||
- **`IndexOf(element)`는 언래핑 기준으로 비교**한다 — 사용자가 넣은 State를
|
||||
그대로 넘겨도 그 자리를 찾아준다(비교가 O(n)인 건 원래 계약 그대로).
|
||||
- **⭐ 이 한 쌍이 있으면 "반환값 맵"과 "물리 요소 맵"을 따로 둘 필요가 없다.**
|
||||
`:List`의 `mounted[key]`는 **물리 요소 하나만** 들고, 사용자 관점의 값이
|
||||
필요할 때(=`prev` 전달, 멱등 비교) `unwrapElement`를 부르면 된다 — 사용자가
|
||||
예상한 그대로다(*"이 래핑 언래핑이 구현되면 자연스럽게 wrappers[key] |
|
||||
mounted[key] 분리가 필요하지 않아질 수도 있긴하다"*).
|
||||
- **래퍼 Slot의 소유 관계**: 래퍼는 quad가 만든 것이라 **부모가 파괴할 수 있고**,
|
||||
래퍼 안의 요소는 사용자 것이라 `Owned = false`로 설치된다 — `destroySlotTree(래퍼)`가
|
||||
`_owned == false`를 보고 안쪽은 언마운트만 하고 빠진다. 두 층이 정확히 이
|
||||
구분으로 갈린다.
|
||||
- **`Extract`가 돌려주는 것도 언래핑된 값**이고, 그 시점에 래퍼는 할 일이
|
||||
없어져 그냥 버려진다(언마운트로 자기 구독이 풀리므로 GC-native).
|
||||
|
||||
**`:Single`의 `updateFn`을 선택 인자로 완화 — 기본값은 identity.** 이
|
||||
sugar가 성립하려면 `:Single(state)`(updateFn 생략)이 유효해야 함 —
|
||||
`Slot:Single(state, updateFn?)`, 생략 시 `function(item) return item end`.
|
||||
|
|
@ -2429,6 +2742,34 @@ return Slot {
|
|||
(GC-native, 이 프로젝트의 기본 원칙 그대로): 아무도 참조를 안 들고 있으면
|
||||
그냥 GC됨. 명시적으로 지금 죽이고 싶으면 `dispose`(아래).
|
||||
|
||||
**⭐ [2026-08-21 5라운드 신설] 단, 아직 트리에 붙어 있는 동안 조상이 죽으면
|
||||
그 요소도 같이 죽는다 — `Owned = false`여도 마찬가지다.** 사용자 질문에서
|
||||
나온 확인 사항:
|
||||
|
||||
```lua
|
||||
Frame {
|
||||
Slot {
|
||||
State(Frame), -- unowned: quad는 이 Frame을 절대 파괴하지 않는다
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
여기서 바깥 `Frame`이 `Destroy`되면 **엔진이 서브트리를 재귀적으로 파괴**하므로
|
||||
안쪽 `State(Frame)`의 값도 같이 죽는다. **이건 의도된 동작이고 quad가 개입할
|
||||
자리가 아니다**(사용자: *"엔진 자체가 recursive 호출로 전부 죽이는게
|
||||
일반적이기에 우리가 빼줄 수 있는 요소도 아니고, 같이 죽는게 의도 동작"*).
|
||||
|
||||
- **`Owned = false`가 약속하는 건 "quad가 안 죽인다"뿐**이지 "무슨 일이 있어도
|
||||
살아남는다"가 아니다. 언마운트(`Parent = nil`)가 조상 파괴보다 **먼저**
|
||||
일어난 요소만 살아남는다.
|
||||
- **그래서 `state<Frame>`에서 값을 빼내 재사용하려면 조상이 살아있는 동안
|
||||
꺼내야 한다** — 조상이 이미 죽은 뒤에 `state:Get()`으로 얻은 값은 **이미
|
||||
죽은 Instance**다. 그 값에 다시 마운트를 시도하면 `bindLifetime`/`canExecute`
|
||||
게이트에 걸린다(바로 아래 "부수 효과" 문단이 서술하는 그 경로).
|
||||
- 같은 이유로 `_detached`가 들고 있던 요소도 조상이 죽으면 같이 죽는다 —
|
||||
detach는 `Parent = nil`이므로 **조상 트리에서 이미 빠져 있어** 이 경우엔
|
||||
해당 없음(그쪽 정리는 `_detachCleanup`이 담당).
|
||||
|
||||
**부수 효과 — 이미 파괴된 대상에 재마운트하려는 시도가 자연히 막힘.**
|
||||
Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 물리 target에
|
||||
묶고, 실제 동작 전에 `canExecute`를 확인**하도록 하면(`base/lifecycle-pattern.md`),
|
||||
|
|
|
|||
|
|
@ -449,36 +449,36 @@ Tween(opts: {
|
|||
}) -> Tween<T>
|
||||
```
|
||||
|
||||
## `Tween<T>:Map(fn)` — 값만 갈아끼운 새 `Tween`을 반환 (2026-08-20 구현 전 QA 4라운드 `UI-8` 신설)
|
||||
## `Tween<T>:Mapped(fn)` — 값만 갈아끼운 새 `Tween`을 반환 (2026-08-20 구현 전 QA 4라운드 `UI-8` 신설, 이름은 2026-08-21 5라운드 확정)
|
||||
|
||||
**동기**: `base/ui-shorthand-plan.md`의 숏핸드가 스칼라를 자식 프로퍼티 타입으로
|
||||
감싸야 하는데(`UICorner = 8` → `CornerRadius = UDim.new(0, 8)`), 값이
|
||||
`Tween<number>`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만** 적용해야 한다.
|
||||
그 문서가 `mapTweenValue(v, wrap)`라는 로컬 헬퍼로 적어뒀던 것을, 사용자 판정으로
|
||||
**`Tween` 자신의 공개 메소드로 승격**한다: *"그냥 펑터 구조를 그대로 줘도
|
||||
무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성."*
|
||||
무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성."*(이름은 이후
|
||||
`Mapped`로 확정 — 아래 마지막 항목)
|
||||
|
||||
```lua
|
||||
tween:Map(fn: (T) -> U): Tween<U> -- opts를 clone하고 Value만 fn(Value)로 교체해 새 Tween 반환
|
||||
tween:Mapped(fn: (T) -> U): Tween<U> -- opts를 clone하고 Value만 fn(Value)로 교체해 새 Tween 반환
|
||||
```
|
||||
|
||||
- **타입이 안전하게 성립한다** — `Tween<T>`는 immutable raw 값이고 `Value` 외의
|
||||
필드는 값 타입과 무관한 옵션(`Time`/`Style`/…)이라, `Value`만 `U`로 바꾼
|
||||
`Tween<U>`를 만드는 건 타입 레벨에서 깨끗하다.
|
||||
- **`Tween<T>`가 immutable이라는 기존 확정과 일관** — `:Map`은 원본을 안 건드리고
|
||||
- **`Tween<T>`가 immutable이라는 기존 확정과 일관** — `:Mapped`는 원본을 안 건드리고
|
||||
`table.clone` 후 `Value`만 교체해 `Tween(opts)`로 다시 만든다(`Tag`/`Modifier`의
|
||||
clone 체이닝과 같은 계열).
|
||||
- **부수 효과 — 기본 `Tween` 정의를 만들어두고 재사용하는 패턴이 열린다.**
|
||||
`local FAST = Tween{Value = 0, Time = 0.15}` 같은 상수를 두고
|
||||
`FAST:Map(function() return targetPos end)`처럼 옵션만 재사용할 수 있다. 다만
|
||||
`FAST:Mapped(function() return targetPos end)`처럼 옵션만 재사용할 수 있다. 다만
|
||||
**사용 케이스가 넓지는 않을 것**으로 봄 — 어차피 내부 구현에 필요해서 만드는
|
||||
것이고, 외부에 보이는 게 무해하니 같이 공개하는 것뿐(사용자 판단).
|
||||
- **이름 — `Map` 또는 `Mapped`.** 코퍼스의 `-ed` 관례("clone 후 즉시 확정된 값"은
|
||||
`Added`/`Removed`/`Overridden`처럼 과거분사, `base/source-state-plan.md`의
|
||||
"네이밍 — `Compute`가 `-ed`가 아닌 이유" 절)를 그대로 적용하면 **`Mapped`가
|
||||
더 일관적**이다 — `Tween`은 lazy가 아니라 즉시 확정되는 raw 값이므로.
|
||||
사용자도 둘 다 열어둠(*"혹은 Mapped 의 immutable 의 ed 형태를 써도 좋아보임"*)
|
||||
— **⚠️ 최종 이름은 미확정**, 코퍼스 일관성만 보면 `Mapped` 쪽이 맞다.
|
||||
- **✅ 이름 — `Mapped`로 확정(2026-08-21 구현 전 QA 5라운드 `TW-2`).**
|
||||
코퍼스의 `-ed` 관례("clone 후 즉시 확정된 값"은 `Added`/`Removed`/`Overridden`처럼
|
||||
과거분사, `base/source-state-plan.md`의 "네이밍 — `Compute`가 `-ed`가 아닌 이유"
|
||||
절)를 그대로 적용한 결과 — `Tween`은 lazy가 아니라 즉시 확정되는 raw 값이므로
|
||||
`Mapped`가 맞다(**사용자 확정**: *"Mapped 로 확정"*).
|
||||
|
||||
## 네임스페이스드 객체 (더 이상 유효한 관심사 아님)
|
||||
|
||||
|
|
|
|||
|
|
@ -191,11 +191,11 @@ end
|
|||
|
||||
**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.**
|
||||
**[승격, 2026-08-20 구현 전 QA 4라운드 `UI-8`] 아래 로컬 헬퍼 `mapTweenValue`는
|
||||
`Tween` 자신의 공개 메소드 `:Map(fn)`(이름 후보 `:Mapped`)으로 올라갔다** —
|
||||
`base/tween-plan.md`의 "`Tween<T>:Map(fn)`" 절이 소스. 이 문서에 로컬 헬퍼로
|
||||
`Tween` 자신의 공개 메소드 `:Mapped(fn)`으로 올라갔다**(이름은 2026-08-21 5라운드 `TW-2`에서 확정) —
|
||||
`base/tween-plan.md`의 "`Tween<T>:Mapped(fn)`" 절이 소스. 이 문서에 로컬 헬퍼로
|
||||
두면 같은 변환이 다른 숏핸드/백엔드에서 또 복제되므로, 값 타입 자신이 제공하는
|
||||
게 맞다는 사용자 판단. 아래 스케치는 그 메소드가 하는 일을 풀어 쓴 것으로만
|
||||
읽을 것(`isTween(v)` 분기는 호출부에 남고, `Tween`이면 `v:Map(wrap)`, 아니면
|
||||
읽을 것(`isTween(v)` 분기는 호출부에 남고, `Tween`이면 `v:Mapped(wrap)`, 아니면
|
||||
`wrap(v)`): 숏핸드는
|
||||
"스칼라를 받아 자식 프로퍼티 타입으로 감싸는" 변환을 갖고 있음(`UICorner = 8`
|
||||
→ `CornerRadius = UDim.new(0, 8)`, 열린 질문 절의 룩업 테이블 `wrap=fn`).
|
||||
|
|
|
|||
|
|
@ -1181,8 +1181,11 @@ M6의 `Detach` 항목 둘이 옛 설계(userdata 보존, 키 소멸 처분 ⚠
|
|||
## H-7. 남은 것
|
||||
|
||||
- **`question.md` 3번의 관련 항목**은 이 처리로 전부 닫혔다.
|
||||
- 사용자 지시대로 **5라운드 문항지는 만들지 않는다** — "이후 stale 만 잡는
|
||||
것으로 끝낼 수 있어보임"(D절 회신).
|
||||
- ~~사용자 지시대로 **5라운드 문항지는 만들지 않는다** — "이후 stale 만 잡는
|
||||
것으로 끝낼 수 있어보임"(D절 회신).~~ **[2026-08-21 뒤집힘]** 같은 날 사용자가
|
||||
5라운드를 요청해 `pre-implementation-qa-round5.md`를 만들었다 — 4라운드에서
|
||||
"예"로 넘어간 자리는 건너뛰고 **문항이 아예 없던 영역 + 이 followup이 새로
|
||||
확정한 것 + 큰 문서의 심화**만 묻는 범위다.
|
||||
- 실측으로 남은 것: 스파이크 `01` 재작성(단일 generalized `for`),
|
||||
`table.insert` 구멍 재사용(`R-11`) 스파이크. 상태의 소스는
|
||||
`luau-test/STATUS.md`.
|
||||
|
|
|
|||
802
.claude/qa-request/pre-implementation-qa-round5-followup.md
Normal file
802
.claude/qa-request/pre-implementation-qa-round5-followup.md
Normal file
|
|
@ -0,0 +1,802 @@
|
|||
# 구현 전 QA **5라운드 followup** — 회신 처리 결과 + 재질문
|
||||
|
||||
**상태**: **[2026-08-21] 4차까지 처리. 열린 항목은 `Gate` 하나뿐.**
|
||||
A~E절은 1차 처리(문항지 회신), F절은 2차, G절은 3차(`mountInst` 삽입 위치 질문),
|
||||
**최신은 H절**(그 결론 — `getOffsetAt` 신설로 확정, `base/` 반영 완료). 회신 원문은
|
||||
`pre-implementation-qa-round5-response.md`, 문항지 원본은
|
||||
`pre-implementation-qa-round5.md`. **처리 결과의 소스는 이 파일**이고,
|
||||
지금 유효한 설계는 언제나 `base/`가 소스다(4라운드에서 이 구분이 실제로
|
||||
어긋난 사례가 있었다 — 아래 `A-8` 참고).
|
||||
|
||||
**이번 라운드에서 언급 안 된 문항은 전부 "예"로 간주**했다(4라운드와 같은
|
||||
규약 — 회신은 아니오/보류만).
|
||||
|
||||
---
|
||||
|
||||
## A. 반영 완료 — 바로 고친 것
|
||||
|
||||
전부 이번에 `base/`(+`ROADMAP.md`/`question.md`/`README.md`)에 반영했고
|
||||
`doc-check.py` ERROR 0을 유지했다.
|
||||
|
||||
| # | 항목 | 무엇을 고쳤나 | 대상 |
|
||||
|---|---|---|---|
|
||||
| A-1 | `DE-7` | **`slot._detached`를 lazy(nilable)로** — 모든 Slot이 빈 테이블을 미리 갖지 않는다. 읽기는 `if slot._detached then`, 쓰기는 `getDetached(slot)`(getOrCreate). `settle`/`destroySlotTree`/`_detachCleanup` 세 자리 전부 nil 가드 | `slot-plan.md` |
|
||||
| A-2 | `DE-7`(추가 질문) | **`prev`가 없는데 `Detach`를 반환해도 nop** — 사용자가 "지금 prev가 있는지"를 추적할 의무가 없다는 걸 계약으로 명시 | `slot-plan.md` |
|
||||
| A-3 | `DE-9` | **`KeyGone`에 `nil`/`None`/`Detach` 외 반환은 전부 `error`** — `prev`뿐 아니라 **새 값도** 거부. 그래서 소멸 루프의 `settle` 호출은 항상 `result == nil`이고 교체 분기에 도달할 경로가 없어졌다(옛 코드는 `pos = 0`으로 `rawAdd`를 불러 범위 밖 인덱스로 터졌을 것) | `slot-plan.md` |
|
||||
| A-4 | `DE-13` | **`Owned = false`에서 `Detach`는 `_detached`에 안 들어간다** — `rawDetach`가 아니라 `rawUnmount`(언마운트+소유권 반납)로 처리, 다음 사이클 `prev`는 `nil`. `Owned` 대조표에 `Detach` 행 추가, `_detachCleanup`의 `_owned` 분기는 **삭제**(도달 불가가 됨) | `slot-plan.md` |
|
||||
| A-5 | `DE-11` | "홀드된 요소는 owner가 죽을 때까지 쌓인다 / 삽입·삭제 최적화일 뿐 그 이상을 돕지 않는다"를 **문서화 유의사항으로 명시**, 잘라내기 정책은 안 만든다 | `slot-plan.md` |
|
||||
| A-6 | 회신 마지막 "+" | **조상이 죽으면 `Owned = false` 요소도 엔진 재귀 파괴로 같이 죽는다**를 신설 — "`Owned = false`가 약속하는 건 quad가 안 죽인다는 것뿐"이라는 경계까지 | `slot-plan.md` |
|
||||
| A-7 | `AT-1`/`AT-2` | `groupClaimKeys`의 키를 **`(inst, groupValue) → k`로 확정**, `nameClaims`보다 **위치 claim을 먼저** 본다는 순서까지. `Frame { a, a }` 갭도 같이 닫힘 | `attribute-plan.md`, `question.md` |
|
||||
| A-8 | `EF-3` | 4라운드가 반영했다고 적고 실제로는 누락됐던 **`E-10` dedup 대칭 결론을 실제로 반영** + `EF-5`(내부 Observer cascade도 dedup 분기 **안**) 명시. "미해결" 표시 제거 | `effect-plan.md` |
|
||||
| A-9 | `TW-2`/`CR-2` | **`Tween<T>:Mapped(fn)`으로 확정** — 문서 전체 표기 통일(`tween-plan.md`/`ui-shorthand-plan.md`) | `tween-plan.md`, `ui-shorthand-plan.md` |
|
||||
| A-10 | `DC-6` | `Offset` Source의 identity를 유지하는 진짜 이유를 사용자 서술로 정밀화 — "언마운트 때 이미 렌더된 요소들이 그 Source를 **구독한 채 함께 딸려 나간다**" | `dispatch-core-plan.md` |
|
||||
| A-11 | `DC-11` | 배치 끝 `recompute`가 실제로 하는 일을 명시 — offset은 즉시 계산이 이미 채웠고, **(a) Slot owner의 `.Length` 확정**(사용자 추측대로 이게 주 목적)과 (b) 등록 후 바뀐 길이 교정이 역할 | `dispatch-core-plan.md` |
|
||||
| A-12 | `DC-14` | 재진입 경로가 **정상 API로는 아예 만들 수 없다**를 명시(`_crudUsed` ↔ `_listed` 가드 때문) | `dispatch-core-plan.md` |
|
||||
| A-13 | `CR-3`/`DT-4` | **"게이팅 먼저" 결정 반영** — M2 각주를 해소로 갱신하되, 앞당기는 대상이 `Blocker`가 아니라 공용 `Gate` 노드라는 것과 표면이 미정이라는 것까지 | `ROADMAP.md`, `question.md` |
|
||||
| A-14 | 새 문서 2개 | `research/gate-primitive.md`, `research/state-epoch-validation.md` 신설(아래 D절) + `README.md` 색인 | `research/`, `README.md` |
|
||||
|
||||
---
|
||||
|
||||
## B. 답변 — 물어보신 것
|
||||
|
||||
### B-1. `DE-13` — `_detachCleanup`이 뭔가, 그리고 unowned 판단이 맞나
|
||||
|
||||
**먼저 `_detachCleanup`이 뭔지**: **"detach해둔 요소들을 나중에 청소하는 쪽"**이
|
||||
맞다. 정확히는 — `activateList`가 Slot마다 하나 설치하는 **`Effect`이고, 그
|
||||
cleanup이 `slot._detached`를 전부 비운다.** 언제 도느냐가 핵심인데,
|
||||
`bindLifetime(physicalTarget, self._detachCleanup)`으로 **물리 target에
|
||||
앵커**돼 있어서 **그 물리 Instance가 죽을 때** cleanup이 돈다.
|
||||
|
||||
왜 `Effect`가 유일한 도구냐면, `bindLifetime`은 "지금 실행해도 되는가"만
|
||||
게이팅할 뿐 **죽는 순간의 콜백을 안 준다** — 죽을 때 뭔가를 하려면 `Effect`의
|
||||
cleanup 계약이 필요하다(`base/effect-plan.md`).
|
||||
|
||||
**그리고 unowned에 대한 판단 — 맞다. 잘못 흐른 게 아니다.** 검토 결과:
|
||||
|
||||
- `Owned = false`는 "이 요소는 애초에 내 게 아니다"이므로, **"잠깐 빼두고
|
||||
내가 계속 들고 있는다"(= `Detach`)가 성립할 수 없다.** 들고 있으려면
|
||||
소유권을 유지해야 하는데(`rawDetach`가 `releaseOwner`를 안 부르는 게
|
||||
그 핵심), 남의 것에 소유권을 유지하는 건 모순이다.
|
||||
- 그래서 `Owned = false` + `Detach`는 **`rawUnmount`(언마운트 + 소유권
|
||||
반납)**로 처리하고 `_detached`에 안 넣는다 → 다음 사이클 `prev`는 `nil`.
|
||||
**말씀하신 그대로 반영했다.**
|
||||
- **부수 결과 둘**: (1) unowned `:List`에서는 `Detach`와 `nil` 반환이
|
||||
**완전히 같은 동작**이 된다(둘 다 언마운트+반납). (2) unowned Slot은
|
||||
`_detached`가 영원히 비어 있으므로 `_detachCleanup`의 `_owned ~= false`
|
||||
분기가 **도달 불가**가 된다 — 그래서 그 분기를 지웠다(이제 cleanup에
|
||||
오는 건 전부 내 것).
|
||||
- **`state<Frame>` 의미론과의 정합도 그대로 지켜진다** — 값 교체 시
|
||||
`releaseElement`가 `_owned == false`를 보고 `rawUnmount`로 빠지므로 이전
|
||||
요소는 파괴되지 않는다. 말씀하신 논거와 같다.
|
||||
|
||||
### B-2. `DE-7` 추가 질문 두 개
|
||||
|
||||
**(a) 아무것도 없는데 `Detach`를 보내면?** → **무시(nop)한다.** 지금
|
||||
의사코드가 이미 `if wasMounted ~= nil then ... end`로 감싸고 있어서 `prev`가
|
||||
없으면 아무 일도 안 일어난다. **사용자가 "지금 prev가 있는지"를 추적할 의무가
|
||||
없다**는 걸 계약으로 명시해뒀다(A-2) — `if not shouldShow(item) then return
|
||||
Detach end`처럼 조건만 보고 반환해도 안전하다. 이렇게 둔 이유는 "이미 detach
|
||||
중이면 nop"과 같은 결이기 때문이다(둘 다 "이 자리를 비워라"인데 이미 비어
|
||||
있는 상태).
|
||||
|
||||
**(b) 마운트 상태의 `prev`를 다시 반환하는 건 여전히 잘 도는가?** → **그렇다.**
|
||||
`settle`의 `result == prev` 분기가 `wasDetached == nil`이므로 재마운트 쪽으로
|
||||
안 가고, `keyIndex[key] ~= pos`일 때만 `rawMove`를 부른다. 값도 위치도 그대로인
|
||||
가장 흔한 경로는 **테이블 조회 몇 번이 전부**이고 물리 트리에 손을 안 댄다.
|
||||
이번 변경(lazy `_detached`, unowned 분기)은 전부 `detach == true` 경로나
|
||||
`wasDetached ~= nil` 경로만 건드려서 이 경로에는 닿지 않는다.
|
||||
|
||||
### B-3. `AS-5` — `activateList`가 상위에 `setLength`를 하는가
|
||||
|
||||
**안 한다. 상위 등록(`Dispatch.setLength(ownerKey, position, slot.Length)`)은
|
||||
`materializeSlotTree`의 마지막 줄이 유일한 자리**이고, 그건 Blocker를 이미
|
||||
`OffWithoutEmit()`한 뒤다. `activateList`는 자기 `:List`를 실체화하면서
|
||||
`rawAdd`만 부른다.
|
||||
|
||||
**그럼 그 `rawAdd`가 부기를 건드리지 않는다는 보장은 어디서 오는가** — 그
|
||||
Slot이 아직 `_mounted == false`이기 때문이다. 이 상태의 `rawAdd`는
|
||||
"`_elements`에만 넣고 끝"이라 `setLength`/`setOffsetSource`/`recompute`를
|
||||
아예 안 부르므로, **게이팅할 대상 자체가 없다.** 초기 population이 만든
|
||||
요소들의 부기는 그 직후 `materializeSlotTree`의 `_elements` 등록 루프가
|
||||
(이번엔 Blocker를 켜고) 한꺼번에 처리한다.
|
||||
|
||||
**⚠️ 다만 확인 중에 실제 갭을 하나 찾았다 — 그 보장을 담은 `rawAdd` 의사코드가
|
||||
문서에 없다.** `rawRemove`/`rawUnmount`/`rawDetach`/`releaseElement`는 전부
|
||||
코드 블록이 있는데 **가장 많이 참조되는 `rawAdd`만 정의가 없고**, `_mounted`
|
||||
분기는 다른 함수의 주석에만 흩어져 있다. 아래 `C-1`에서 초안을 제안한다.
|
||||
|
||||
### B-4. `DC-11` — 배치 끝 `recompute`가 왜 필요한가
|
||||
|
||||
**추측하신 대로 `Length` 때문이 맞다.** 정리하면:
|
||||
|
||||
- **offset은 이미 채워져 있다** — `setOffsetSource`의 즉시 계산이 등록 시점마다
|
||||
`1..i-1` 길이 합을 넣어두고, 그것들은 `i`보다 먼저 등록되므로 항상 정확하다.
|
||||
그래서 이 마지막 `recompute`는 offset에 대해선 `Get() ~= sum` 가드에 걸려
|
||||
대부분 아무것도 안 쓴다.
|
||||
- **실제 역할은 (a) `ownerKey`가 Slot이면 `ownerKey.Length`(= 기여도 합) 확정,
|
||||
(b) 등록된 뒤에 값이 바뀐 길이가 있으면 그 뒤 형제 offset 교정.**
|
||||
- `ownerKey`가 물리 `inst`인 `Dispatch.drive` 경로에선 (a)가 없어 사실상
|
||||
검증 패스지만, `Set`이 거의 없는 O(N) 순회라 분기해서 빼지 않고 그냥 항상
|
||||
부른다. **문서에 이대로 반영했다**(A-11).
|
||||
|
||||
### B-5. `+` — `state<Slot>` → `Slot:Single { Slot }`에서 부모가 Length를 따라가는가
|
||||
|
||||
**따라간다.** 한 단계씩 트레이싱하면:
|
||||
|
||||
1. `Slot:Add(state)`가 래퍼 `sub = Slot(); sub:Single(state)`를 만들어
|
||||
부모 `_elements[i]`에 넣는다.
|
||||
2. 부모의 `materializeSlotTree` 루프가 `isSlot(sub)`을 보고 재귀 →
|
||||
`sub`가 자기 실체화를 끝내고 **마지막에
|
||||
`Dispatch.setLength(parent, i, sub.Length)`** 를 부른다. 여기서 넘기는 건
|
||||
**값이 아니라 `Source` 객체 자신**이라, 부모는 그 객체를 구독해둔다.
|
||||
3. 나중에 state가 다른 Slot을 emit하면 `sub`의 reconcile이 교체를 수행하고,
|
||||
그 안에서 `recompute(sub, bk)`가 `sub.Length`를 새 합으로 `Set`한다.
|
||||
4. 부모가 2번에서 걸어둔 Observer가 그 `Set`을 받아 `gatedRecompute(parent)` →
|
||||
부모 `recompute`가 자기 `Length`와 뒤 형제 offset을 갱신한다.
|
||||
|
||||
**한 가지 캐비엇**: 3번에서 교체는 `rawUnmount`(→ `spliceArraysDown` +
|
||||
`recompute`) **다음** `rawAdd`(→ 등록 + `recompute`) 순서라, `sub.Length`가
|
||||
**잠깐 줄었다가 다시 늘어난다.** 부모 쪽 `recompute`도 그만큼 두 번 돈다.
|
||||
크래시나 오작동은 아니고(그 사이에 프레임 경계가 없다 — yield 금지 계약),
|
||||
뒤 형제 offset이 두 번 계산되는 낭비다. `:Single`처럼 항상 0/1개인 경우엔
|
||||
`Detach`/재마운트 경로가 아니라면 피하기 어렵다 — **지금은 그대로 두는 게
|
||||
맞다고 보는데, 아니면 알려주시라**(교체를 "먼저 넣고 나중에 빼는" 순서로
|
||||
바꾸면 없앨 수 있으나 `C-7`("빼기는 물리 먼저")과 부딪힌다).
|
||||
|
||||
### B-6. `+` — 조상이 죽을 때 안에 있던 요소도 같이 죽는 것
|
||||
|
||||
**언급이 없었다 — 이번에 신설했다**(A-6). 요지는 문서에 이렇게 적었다:
|
||||
`Owned = false`가 약속하는 건 **"quad가 안 죽인다"뿐**이지 "무슨 일이 있어도
|
||||
살아남는다"가 아니고, 언마운트가 조상 파괴보다 **먼저** 일어난 요소만
|
||||
살아남는다. 그래서 조상이 이미 죽은 뒤에 `state:Get()`으로 꺼낸 값은 **이미
|
||||
죽은 Instance**이고, 재마운트를 시도하면 `bindLifetime`/`canExecute` 게이트에
|
||||
걸린다. `_detached`는 이미 `Parent = nil`이라 이 경우에 해당하지 않는다(그쪽
|
||||
정리는 `_detachCleanup`).
|
||||
|
||||
### B-7. `SS-2`/`SS-3` — 에포크 제안에 대한 답
|
||||
|
||||
**"선제 최적화가 아니라 확정 동작으로의 승격"이라는 판단에 동의한다.**
|
||||
길어서 `research/state-epoch-validation.md`로 뺐고(D절), 요지만:
|
||||
|
||||
- **지목하신 glitch는 실재한다.** 지금 `base/source-state-plan.md`의 다이아몬드
|
||||
절은 "중복 재계산이 없다"만 말하는데, 그 논증은 *`Get()`이 전파 파동이 끝난
|
||||
뒤에 온다*고 암묵 가정한다 — Observer가 전파 도중 발화한다는 사실과 겹치면
|
||||
그 가정이 깨진다. **문서 어디에도 이 현상이 서술돼 있지 않다.**
|
||||
- **제안한 방식이 고치는 것**: 섞인 값(정확성)과 중복 재계산. 아직 신호를 못
|
||||
받은 가지도 `Get()` 때 자기 `sourceList`의 카운트 불일치를 보고 스스로
|
||||
재계산하므로, `Get()`이 "지금 이 순간의 일관된 값"을 준다는 보장이 **처음으로**
|
||||
성립한다.
|
||||
- **안 고쳐지는 것**: 중복 **통지**. Observer는 여전히 두 번 운다(값은 두 번 다
|
||||
옳다). "에포크를 넣으면 다이아몬드가 완전히 해결된다"고 적으면 틀린 서술이 된다.
|
||||
- **선례가 있다** — 값이 아니라 **버전/에포크를 비교해 lazy하게 검증**하는 건
|
||||
MobX·Adapton류가 쓰는 표준 기법이고, quad가 이미 택한 pull 모델과 결이 같다
|
||||
(Fusion식 eager 위상정렬의 대안).
|
||||
- **비용 추산에 동의**한다(`rawInvalid`가 false면 훑지도 않으므로 흔한 경로는
|
||||
지금과 같은 비용).
|
||||
- **⭐ 채택한다면 같이 못 박아야 하는 것 하나** — `sourceList`는 `:With`/trailing
|
||||
deps로 **선언된** 상류에서만 합성되므로, `:Compute` 콜백이 클로저로 잡은
|
||||
**선언 안 한 Source**를 `Get()`하면 에포크 비교가 못 잡는다. 지금도 stale이지만
|
||||
새 모델은 "`Get`은 항상 일관"이라는 **더 강한 약속**을 하므로, 그 예외를
|
||||
UB로 명문화해야 한다.
|
||||
|
||||
**`Get`이 최신을 준다는 말이 무력화되는 것 아니냐**는 우려에 대해선 방향이
|
||||
반대라고 본다 — **지금 모델이 그 약속을 못 지키고 있었고**, 이 제안이 지키게
|
||||
만든다.
|
||||
|
||||
---
|
||||
|
||||
## C. 사용자 판단 필요 — 임의로 처리하지 않은 것
|
||||
|
||||
### C-1. ⭐ `rawAdd` 의사코드가 문서에 없다 — 초안 승인 요청 (`AS-5`에서 발견)
|
||||
|
||||
`rawAdd`는 이 문서에서 가장 많이 참조되는 함수인데 **정의 블록이 없고**,
|
||||
`_mounted` 분기·부기 순서·nested 재귀가 전부 다른 함수의 주석에 흩어져 있다.
|
||||
`AS-5`의 보장("게이트 없이 `recompute`가 돌 일이 없다")이 정확히 그 분기에
|
||||
달려 있으므로 명시적으로 적어두는 게 맞다고 본다. 초안:
|
||||
|
||||
```lua
|
||||
-- [초안, 5라운드 C-1] 기존 서술을 모은 것 — 새 결정은 없음.
|
||||
function rawAdd(self, element, index, fromDetached)
|
||||
claimOwner(element, self, fromDetached) -- 이미 누가 갖고 있으면 error(detach 재마운트만 예외)
|
||||
index = index or (#self._elements + 1)
|
||||
table.insert(self._elements, index, element)
|
||||
|
||||
if not self._mounted then
|
||||
return index -- 아직 마운트 전: 부기도 물리도 없음. attachSlot이 나중에 통째로 처리
|
||||
end
|
||||
|
||||
local bk = getBookkeeping(self)
|
||||
spliceArraysUp(self, index) -- _elements 외 배열들을 한 칸씩 밀고 bk.N 증가
|
||||
|
||||
if isSlot(element) then
|
||||
-- 자식 Slot은 자기 부기를 자기가 등록한다(setOffsetSource → setLength 순서 포함)
|
||||
attachSlot(element, self._mountedInst, self, index)
|
||||
else
|
||||
Dispatch.setOffsetSource(self, index, None) -- 순서: 항상 offsetSource 먼저(C4)
|
||||
Dispatch.setLength(self, index, 1)
|
||||
recompute(self, bk) -- 부기 완결(C7: 물리보다 먼저)
|
||||
element.Parent = self._mountedInst -- 그 다음에야 물리 마운트
|
||||
end
|
||||
return index
|
||||
end
|
||||
```
|
||||
|
||||
**확인이 필요한 지점 둘**:
|
||||
1. **`element.Parent` 대입이 `attachSlot` 쪽 분기엔 없다** — 자식이 Slot이면
|
||||
`mountSlotTree`가 대신 해주기 때문. 맞나?
|
||||
2. **`recompute` 호출 위치** — plain 요소는 위처럼 `setLength` 직후 1회면
|
||||
충분하지만, Slot 자식은 `attachSlot`(정확히는 그 안의 `materializeSlotTree`)이
|
||||
자기 끝에서 `setLength(self, index, ...)`를 부르고 그게 `gatedRecompute`를
|
||||
태우므로 여기서 또 부를 필요가 없다고 봤다. 맞나?
|
||||
|
||||
### C-2. ⭐⭐ `DC-19` — `rawAdd`가 `Length`를 직접 `Set`하는 서술이 지금 계약과 충돌한다
|
||||
|
||||
말씀하신 "목적이 다르지 않나"를 파고들다 더 큰 걸 찾았다.
|
||||
`dispatch-core-plan.md`는 두 자리에서 **`rawAdd`가 `self.Length:Set(newCount)`를
|
||||
부른다**고 적어두는데:
|
||||
|
||||
1. **`newCount`(개수)는 이제 `Length`의 정의가 아니다** — `Length`는
|
||||
"요소별 기여도의 합"(plain=1, nested Slot=그 `.Length`)으로 바뀌었다.
|
||||
개수로 `Set`하면 중첩이 있는 순간 틀린 값이 된다.
|
||||
2. **쓰는 주체가 둘이 된다** — `recompute`가 이미
|
||||
`ownerKey.Length:Set(sum)`으로 확정 기록을 한다. 같은 Source에 두 곳이
|
||||
쓰면 어느 쪽이 진실인지가 갈린다.
|
||||
3. 지적하신 "가드가 필요한가"도 여기서 답이 나온다 — `rawAdd` 자리에서는
|
||||
카운트가 **항상** 달라지므로 `Get() ~= sum` 가드가 아무것도 안 걸러서
|
||||
실제로 무의미하다(가드가 값을 하는 건 `recompute`의 전체 순회 쪽뿐).
|
||||
|
||||
**제안**: `rawAdd`에서 `Length:Set`를 **빼고**, `Length`는 `recompute`만 쓴다
|
||||
(위 `C-1` 초안이 그렇게 돼 있다). "부기가 물리보다 먼저"(C-7)는 `recompute`가
|
||||
`Parent` 대입 앞에 오는 것으로 그대로 지켜진다. **이대로 정정할까?**
|
||||
|
||||
### C-3. ⭐⭐ `DE-17` — `updateFn`이 State를 반환할 때의 래핑/`prev` 규칙이 지금 문서엔 없다
|
||||
|
||||
말씀하신 *"updateFn 이 state 를 던져도 싱글 slot화 된다"*를 확인하러 갔더니
|
||||
**지금 문서는 그렇게 안 돼 있다**:
|
||||
|
||||
- State → nested Slot 래핑은 **공개 `Slot:Add`에만** 있다
|
||||
(`if isState(element) then sub = Slot(); sub:Single(element); element = sub end`).
|
||||
- 그런데 `:List`의 reconcile은 공개 `Add`가 아니라 **`rawAdd`를 직접** 부른다
|
||||
(가드/에러 체크가 중복이라 의도적으로 그렇게 확정돼 있다).
|
||||
- 그래서 지금 문서대로면 **`updateFn`이 State를 반환하면 래핑 없이 `_elements`에
|
||||
raw State가 들어간다** — 요소 타입 제약 위반.
|
||||
|
||||
그리고 래핑을 하기로 하면 **`prev` identity 문제가 따라온다.** 말씀하신
|
||||
*"prev 는 이전에 던진 그대로"* + *"이전과 같은 state 를 던지면 멱등으로써
|
||||
새로운 slot 을 만들지 않고 그대로 두는것"*을 둘 다 만족시키려면, `settle`의
|
||||
`result == prev` 비교가 **래퍼가 아니라 사용자가 반환한 값**을 봐야 한다.
|
||||
|
||||
**선택지**:
|
||||
|
||||
1. **(권고) 래핑을 `rawAdd`로 내리고, `:List`는 "반환값"과 "물리 요소"를 따로
|
||||
기억한다.** `mounted[key]`엔 사용자가 반환한 값(raw State)을 넣고, 래퍼 Slot은
|
||||
`wrappers[key]`(또는 `mounted[key] = {returned, element}`)에 둔다.
|
||||
`rawMove`/`rawDetach`/`releaseElement`는 래퍼에, `prev`/멱등 비교는 반환값에
|
||||
적용. → 사용자가 원하는 의미론 그대로, 대신 `:List` 내부 상태가 하나 는다.
|
||||
2. **래핑을 `settle`에서 한다** — 효과는 (1)과 같고 `rawAdd`는 안 건드린다.
|
||||
대신 `Slot:Add`와 `:List` 두 곳에 같은 래핑 코드가 생긴다.
|
||||
3. **`:List`의 `updateFn`이 State를 반환하는 걸 금지(error)** — 사용자가 직접
|
||||
`Slot():Single(state)`을 만들어 반환하게 한다. 가장 단순하지만 말씀하신
|
||||
방향과 반대다.
|
||||
|
||||
**부수 확인**: 그 래퍼의 `:Single` 설치가 `Owned = false`가 되는 것
|
||||
(*"owned = false 가 되는건 이 싱글 슬롯 안 1번째 객체에 대해서 적용"*)은
|
||||
맞다고 보고, 래퍼 Slot **자신**은 quad가 만든 것이라 부모가 파괴할 수 있다
|
||||
(`destroySlotTree(wrapper)` → `_owned == false`라 안쪽은 언마운트만) —
|
||||
이 조합도 확인 부탁드린다.
|
||||
|
||||
### C-4. ⭐⭐ `LC-3`/`LC-4` — `setLength`의 Observer를 Slot에 앵커하는 걸 되돌리자는 제안
|
||||
|
||||
지적이 맞다고 본다. 지금 확정(4라운드 `D-56`)은 *"`bindLifetime`의 첫 인자가
|
||||
Slot일 수 있으니 백엔드가 그 경우를 핸들링하라 + `isBoundAlive`에 세 번째
|
||||
분기를 둬라"*인데, 물으신 대로 **왜 Slot이 소유자여야 하는지**가 실제로는
|
||||
근거가 약하다:
|
||||
|
||||
- `Dispatch.setLength(ownerKey, i, len)`이 만드는 Observer는 `ownerKey`가
|
||||
**부기 키**라는 이유로 `bindLifetime(ownerKey, observer)`를 부르고 있는데,
|
||||
**부기 키와 생명주기 앵커는 별개 개념**이다.
|
||||
- 실제로 그 Observer가 살아야 하는 기간은 "이 Slot이 그 물리 트리에 마운트돼
|
||||
있는 동안"이고, 그건 **`physicalTarget`이 정확히 표현한다.** Slot 자신의
|
||||
생존은 부모의 `_elements` 강참조가 이미 보장한다(말씀하신 그대로).
|
||||
- 그리고 `setLength`가 불리는 모든 자리에서 **물리 target을 이미 알고 있다** —
|
||||
`Dispatch.drive`(=`inst`), `materializeSlotTree`(=`physicalTarget`), 런타임
|
||||
단건 `rawAdd`(=`self._mountedInst`).
|
||||
|
||||
**제안**: `setLength`가 **부기 키(`ownerKey`)와 앵커(`physicalTarget`)를 따로
|
||||
받게** 하고, `bindLifetime`은 **항상 물리 Instance만** 받는다.
|
||||
|
||||
- `base/lifecycle-pattern.md`의 `(1-1)` 절(첫 인자가 Instance가 아닐 수 있다는
|
||||
백엔드 요구사항)이 **통째로 불필요**해진다.
|
||||
- `isBoundAlive`의 **세 번째 분기도 필요 없어진다**(지금 ⚠️ 미정으로 열려 있는 것).
|
||||
- 포탈(언마운트→재마운트)에서도 앵커가 자연히 새 target으로 옮겨간다 —
|
||||
`unmountSlotTree`가 `bk.observers`를 `unbindLifetime`하고 재마운트 시
|
||||
`materializeSlotTree`가 다시 등록하는 지금 흐름 그대로.
|
||||
- `getBlocker(slot)`/`getBookkeeping(slot)`은 그대로 Slot을 `Relate` 키로
|
||||
쓴다(그건 weak 키일 뿐 생명주기 앵커가 아니라 문제없다).
|
||||
|
||||
**이 방향으로 `D-56`을 되돌릴까?** 되돌린다면 `base/lifecycle-pattern.md`,
|
||||
`base/dispatch-core-plan.md`(`setLength` 시그니처), `base/slot-plan.md`를 같이
|
||||
고쳐야 한다.
|
||||
|
||||
### C-5. `Gate` — 이름과 표면 (`CR-3`/`DT-4`)
|
||||
|
||||
`research/gate-primitive.md`로 정리했고(D절), 결정이 필요한 건 넷:
|
||||
**(a) 이름**(권고: `Gate` 그대로 — `blocker`/`modifier`와 달리 `gate`는 이미
|
||||
행위자가 아니라 **장치**를 가리키는 명사라 `-er`가 필요 없다. 대안:
|
||||
`Valve`/`Relay`), **(b) `:Apply` 팩토리인지 독립 생성자인지**, **(c) `Blocker`가
|
||||
그 위에 어떻게 얹히는지**(공유 외부 객체라 모양이 다름), **(d) M2에 `Gate`만
|
||||
넣을지 `Blocker`까지 넣을지**.
|
||||
|
||||
### C-6. `+` — `Effect`가 여러 의존성(특히 `Ref`)을 직접 받는 안
|
||||
|
||||
제안하신 방향(`:With`로 합치지 말고 `Effect(fn, ...)`가 여러 요소를 각각
|
||||
Observe/Callback하고, 최초 발화는 `Blocker`의 다른 사용법으로 억제)은
|
||||
**타당해 보이고, 지금 실제로 갭이 맞다** — `Ref`는 State가 아니라
|
||||
(`:Callback`만 있고 emit이 없다) `:With`로 합칠 수가 없어서 **오늘은 Effect의
|
||||
의존성이 될 방법이 아예 없다.**
|
||||
|
||||
정하고 갈 것들:
|
||||
1. **`fn`이 받는 인자 모양** — `:Compute(fn, ...deps)`가 이미
|
||||
`fn(self, previous?, ...deps)`로 **trailing deps를 lazy 핸들로 위치 인자화**
|
||||
하는 선례가 있으니 그대로 따르는 게 맞아 보인다(새 규칙 없음).
|
||||
2. **`Ref` 의존성의 발화 시점** — Ref는 "한 번 채워지는" 값이라 State처럼
|
||||
반복 emit이 없다. 채워질 때 1회 발화 + 이후 재설정 시 발화(Ref는 반복
|
||||
재설정 가능)로 보면 되나?
|
||||
3. **최초 1회 실행** — 지금 `Effect`는 설치 시 1회 실행이 계약인데, 의존성이
|
||||
여럿이면 "아직 안 채워진 Ref"가 있는 채로 도는 게 정상인가(=`nil`을 보고
|
||||
각자 판단), 아니면 전부 채워질 때까지 미루나? 제안하신 Blocker 억제는
|
||||
전자에 가깝게 들린다.
|
||||
4. **leaf dedup/cascade와의 관계** — 의존성이 N개면 내부 Observer도 N개라,
|
||||
`EffectHandle`의 bind/unbind cascade가 전부를 덮어야 한다(`EF-5`와 같은 함정).
|
||||
|
||||
**방향에 동의하시면 `research/`에 문서를 하나 만들어 위 넷을 정리하겠다** —
|
||||
지금은 이 항목만 남기고 아무것도 안 만들었다.
|
||||
|
||||
### C-7. `B-5`의 캐비엇 — `:Single` 교체 시 Length가 잠깐 줄었다 느는 것
|
||||
|
||||
위 B-5 마지막 문단. 낭비만 있고 오작동은 아니라 **그대로 두는 쪽**을 권하는데,
|
||||
확인 부탁드린다.
|
||||
|
||||
---
|
||||
|
||||
## D. 새로 만든 research 문서 둘
|
||||
|
||||
| 문서 | 무엇 | 왜 base가 아니라 research인가 |
|
||||
|---|---|---|
|
||||
| `research/gate-primitive.md` | `Blocker`/`Debounce`/`Throttle` 아래의 공용 게이트 노드. 사용자 스케치(2단 `setup(emit) -> onUpstreamEmit`), `DT-4`의 "Blocker+Observer로는 순서를 못 지킨다" 논거, 열린 질문 6개 | **방향은 확정, 표면·이름이 미정** — M2 착수 전에 닫아야 함 |
|
||||
| `research/state-epoch-validation.md` | 소스 에포크 비교로 재계산을 판정하는 안. 문제(glitch) 재현 시나리오, 제안 정리, 에이전트 분석(고쳐지는 것/안 고쳐지는 것/선례), 비용, 열린 질문 6개, 권고 | **아무것도 확정 안 함** — `base/source-state-plan.md`가 여전히 정본, M3 전에 결론 필요 |
|
||||
|
||||
---
|
||||
|
||||
## E. 회신 방법
|
||||
|
||||
- **B절** — "이 이해가 맞나"만 봐주시면 된다. 어긋나는 지점만 알려주시면
|
||||
base까지 같이 고친다.
|
||||
- **C절** — `C-2`(`rawAdd`의 `Length:Set` 제거), `C-3`(State 반환 래핑/`prev`),
|
||||
`C-4`(`setLength` 앵커 되돌리기)가 **파급이 가장 크고 나머지를 막는다.**
|
||||
`C-1`은 초안 승인, `C-5`/`C-6`은 방향 확인.
|
||||
- 언급 안 하신 문항은 전부 "예"로 간주하고 넘어간다.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# F. 2차 회신 처리 (2026-08-21) — C절 전량 확정, `Gate`만 다음 세션으로
|
||||
|
||||
**입력**: `B-5`/`B-7`/`C-1`~`C-7`에 대한 사용자 회신(원문은
|
||||
`-response.md`에 이어붙이지 않고 이 절에 인용). **`C-5`(`Gate`)를 뺀 전부가
|
||||
확정됐고 `base/`에 반영 완료.**
|
||||
|
||||
## F-1. 반영 완료
|
||||
|
||||
| 항목 | 회신 | 반영 |
|
||||
|---|---|---|
|
||||
| `B-5` | *"차라리, replace 를 제공하는게 나아보임. 해당 요소 자리에 교체분을 넣고, 이전건 파기해주는 것."* | **`Slot:Replace(index, newElement)` 공개 CRUD 신설**(O(1), `Extract`의 파괴 짝) + **`rawReplace` 의사코드 신설**. `:List`의 `settle` 교체 분기가 `releaseElement`+`rawAdd`(시프트 2회·`recompute` 2회)에서 **`rawReplace` 하나**(시프트 0)로 바뀜 — `C-7`의 "Length가 잠깐 줄었다 느는 것"도 이걸로 사라진다 |
|
||||
| `B-7` (1) | *"중복 통지는 … 이미 모든 소스가 최신이면 무시하는게 맞아보인다 … emit 은 자신 소스를 주게 되므로 자신 소스 카운트만 빠르게 비교가 쉽다"* | `state-epoch-validation.md`에 **중복 통지도 접는다**로 반영. 판정이 O(1)이라는 것, **2026-08-14에 폐기된 옛 dedup과 다른 장치**라는 것(영구 침묵 모드가 없는 이유), 그리고 **노드가 `seen`/`computedAt` 두 카운트를 따로 들어야 한다**는 구현 요구까지 |
|
||||
| `B-7` (2) | *"그건 아니다 … 상류의 상태를 물어보므로 … 이전과 다른게 없다"* | 에이전트가 붙였던 "선언 안 된 의존성을 UB로 명문화" 조건 **철회**, 같은 문서 §5·§7에 기각 사유와 함께 기록 |
|
||||
| `C-1` | 확인 + *"element.Parent … 는 실제 엔진이 구현하게 되는 crud 셋을 사용하게 되어야 … slot 의 해당 동작은 base 이므로 parent 를 모른다"* | **`rawAdd` 의사코드 확정 반영** + **"물리 조작은 주입 op다" 절 신설** — 이 문서 의사코드 전체의 `element.Parent = ...`/`element:Destroy()`를 `mountInst`/`unmountInst`/`disposeInst` 호출로 정정(9곳) |
|
||||
| `C-2` | *"확인. recompute 가 length 를 잘 처리해놓고 나서 빈 공간에 들어가므로 해당 동작은 완결하다"* | `dispatch-core-plan.md`의 **`rawAdd`가 `Length:Set(newCount)`를 부른다는 서술 2곳 전면 정정** — `Length`는 이제 `recompute`만 쓴다(개수 ≠ 기여도 합 / 이중 기록 방지 / 그 자리 가드는 무의미) |
|
||||
| `C-3` | *"권고를 따르고자 한다 … 래핑이 하나의 함수로 나와야한다 … IndexOf 와 Get 등은 래핑 전 객체를 주어야"* | **"래핑/언래핑은 Slot 전체에 걸린 연산" 절 신설** — `wrapElement`/`unwrapElement` 비공개 헬퍼 한 쌍, 래퍼에 `_wrapped` 역참조(언래핑 O(1)), `_elements`는 물리 요소·사용자에게 나가는 값은 전부 언래핑, `IndexOf`도 언래핑 기준. **예상하신 대로 `wrappers[key]`/`mounted[key]` 분리가 불필요해졌다** |
|
||||
| `C-4` | *"제안에 동의한다"* | **`Dispatch.setLength(ownerKey, i, len, anchor)`로 시그니처 변경** — 부기 키와 생명주기 앵커 분리. `bindLifetime`은 다시 물리 Instance 전용이 되고, `lifecycle-pattern.md`의 `(1-1)` 절은 **`archive/bindlifetime-slot-owner-reversed.md`로 이전**(포인터만 남김). **`isBoundAlive`의 세 번째 분기 항목도 같이 닫힘** |
|
||||
| `C-6` | *"최소 1회 실행이 맞음 useEffect 와 동일. 인자 모양도 동의하고, 의존성 발화도 set 될때만임 … 전부 동의"* | `effect-plan.md`에 **`Effect(fn, ...deps)` 절 신설**(확정) — `Ref`도 의존성이 될 수 있음, trailing lazy 위치 인자, 최소 1회 실행, `Ref`는 `Set`될 때만 발화, cascade/dedup이 N개 Observer 전부를 덮어야 함. 최초 1회 억제 장치만 `Gate`에 딸려 있어 ⚠️로 표시 |
|
||||
| `C-7` | *"위 응답에서 해소되었다 생각함"* | `B-5`의 `rawReplace`로 해소 — 별도 조치 없음 |
|
||||
|
||||
## F-2. 다음 세션으로 넘긴 것 — `Gate` 하나
|
||||
|
||||
**회신**: *"고칠것이 많으므로 Gate 는 다음 세션에 다루겠음. 해당 부분은 정정이
|
||||
아니고 추가이고, 새 인터페이스를 고민해야하므로 해결해야할 일로 남겨두길 바람.
|
||||
단지 지금 세션 상 지식만 이전될 수 있게 두세요."*
|
||||
|
||||
→ `research/gate-primitive.md` 상단에 그 지시를 명시하고 **"다음 세션이 바로
|
||||
이어받을 수 있게 재료만 모아둔 상태"**로 표시했다. `question.md` 3번과
|
||||
`todos.md`에도 **M2 착수를 막는 유일한 항목**으로 남겼다. 이 세션에서
|
||||
`Gate`에 대해 새로 정한 건 없다.
|
||||
|
||||
## F-3. 이번 처리로 파생된 것 (확인 부탁)
|
||||
|
||||
1. **`mountInst`/`unmountInst`는 가칭이다** — `disposeInst` 선례를 따라 붙였고,
|
||||
`base/dispatch-core-plan.md`의 주입 op 목록에 정식 등록할 때 이름을 확정해야
|
||||
한다(`slot-plan.md`의 그 절에 ⚠️로 표시해둠). `mountInst`에 **index를 안
|
||||
넘기는** 것도 같이 확정된 셈인데(형제 순서는 `Offset` 부기가 담당), 이건
|
||||
맞다고 보고 그렇게 적었다.
|
||||
2. **`rawReplace`가 `indexOfRaw(self, oldElement)`로 자리를 찾는다** — 지금
|
||||
`raw*`가 index 기준과 element 기준으로 섞여 있는 알려진 불일치
|
||||
(`slot-plan.md`의 "raw* 내부 호출 규약" 캐비엇)를 그대로 따랐다. M6 구현 때
|
||||
둘 중 하나로 통일할 것.
|
||||
3. **공개 `Replace`는 항상 파괴, `:List` 경로만 `_owned`를 본다** —
|
||||
`rawReplace(self, old, new, destroyOld)`의 4번째 인자로 갈린다. `_listed`
|
||||
Slot은 공개 CRUD가 막혀 있어 두 경로가 섞이지 않는다.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# G. 3차 — `mountInst`의 삽입 위치 (그리고 그 과정에 발견한 중첩 offset 결함)
|
||||
|
||||
**입력**: *"mountInst/unmountInst 가 인덱스를 안 받는다는 문제가 있을수도.
|
||||
웹에서는 어떻게 되냐가 모호함. 어디 둘지 어떻게 아느냐는것. 문제는
|
||||
`Dispatch.setOffsetSource(self, index, None)` 가 실제 오프셋을 안 주는데, 그냥
|
||||
None 이면 number 로 넘겨주는게 어떻겠냐는 생각."*
|
||||
|
||||
**결론부터: 지적이 맞고, 제안 방향도 맞다. 그리고 그 자리를 파다 별개 결함을
|
||||
하나 더 찾았다.** 아직 `base/`를 고치지 않았고(⚠️ 마커만 달아둠), 아래 셋에
|
||||
대한 판단을 받고 싶다.
|
||||
|
||||
## G-1. 지금 뭐가 문제인가 — `None`이 두 뜻을 겸하고 있다
|
||||
|
||||
`base/dispatch-core-plan.md`는 `setOffsetSource`의 `None`을 **"실제 마운트를
|
||||
하지 않는 위치"**로 정의하고, **"`setLength`도 같은 위치엔 짝을 맞춰 `0`으로
|
||||
등록해야 한다(둘 중 하나만 반영되면 어긋남)"**는 규칙까지 못 박아뒀다.
|
||||
|
||||
그런데 **plain 요소는 `None` + `setLength(1)`로 등록된다**(`materializeSlotTree`의
|
||||
등록 루프, 그리고 이번에 쓴 `rawAdd`/`rawReplace`). 즉 **마운트는 하는데
|
||||
`None`을 쓰는** 자리라 위 규칙과 정면으로 어긋나 있었고, 아무도 못 보고
|
||||
있었다. 실제로 `None`이 뜻하는 건 두 가지가 섞여 있다:
|
||||
|
||||
1. **아무것도 안 차지한다**(`Ref` 자리, `nil`/`None` 값) — 길이 `0`.
|
||||
2. **차지는 하는데 아무도 그 offset을 안 읽는다**(plain 요소) — 길이 `1`.
|
||||
|
||||
지적하신 문제는 정확히 2번에서 나온다 — **`None`이라 숫자가 계산조차 안 되니,
|
||||
DOM 백엔드가 "이걸 어디에 넣어야 하나"를 물을 곳이 없다.**
|
||||
|
||||
## G-2. ⭐⭐ 파다가 나온 별개 결함 — 중첩 Slot의 offset이 부모 베이스를 못 받는다
|
||||
|
||||
`recompute`는 `local sum = 0`으로 시작하고, **`ownerKey.Offset`을 읽는 자리가
|
||||
한 군데도 없다.** 그래서 각 offset은 **그 owner 안에서의 로컬 누적합**이다.
|
||||
depth 1(물리 inst 바로 아래)에선 로컬 == 절대라 아무 문제가 없어서 지금까지
|
||||
안 드러났는데, **depth 2부터 어긋난다**:
|
||||
|
||||
```
|
||||
Frame {
|
||||
header, -- 정적 1개
|
||||
Slot { -- A
|
||||
plainX,
|
||||
Slot { :List 3개 }, -- B
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| 계산 | 값 | 맞는 값 |
|
||||
|---|---|---|
|
||||
| `recompute(Frame)`: `A.Offset` | `1`(header 기여) | 1 ✓ |
|
||||
| `recompute(A)`: `B.Offset` | **`1`**(plainX 기여만) | **`2`**(= `A.Offset` 1 + plainX 1) |
|
||||
| B의 `updateFn`이 쓰는 `index + offset` | 2,3,4 | 3,4,5 |
|
||||
|
||||
즉 **`A.Offset`만큼 통째로 밀려 있다.** `updateFn`에 넘기는 `offset`을
|
||||
"형제로 섞인 다른 Slot/정적 자식이 기여한 개수의 누적합"이라고 서술해온 것과도
|
||||
안 맞는다(그 서술은 절대값을 뜻한다).
|
||||
|
||||
이게 지금 질문과 한 덩어리인 이유: **DOM에 넘길 삽입 위치는 절대값이어야
|
||||
하므로, 숫자를 주기로 하면 이 결함부터 고쳐야 한다.**
|
||||
|
||||
## G-3. 제안 (셋 다 같이 가야 함)
|
||||
|
||||
### P1 — `sourceList`의 의미를 "참여 여부"에서 "발행 채널"로 좁힌다
|
||||
|
||||
`recompute`가 **모든 position에 대해 숫자를 계산해 `bk.offsetList[i]`에
|
||||
기록**하고, `sourceList[i]`에 Source가 있을 때만 거기에 `:Set`한다.
|
||||
|
||||
- **참여 여부는 이미 `lengthList[i]`가 표현한다**(0이면 아무것도 안 차지).
|
||||
그래서 `None`은 이제 **"발행 채널 없음"** 하나만 뜻하게 되고, plain 요소가
|
||||
`None` + `setLength(1)`을 등록하던 모순이 그대로 해소된다.
|
||||
- **`None` ↔ `setLength(0)` 페어링 규칙은 "값이 없는 자리"에만 남는다** —
|
||||
그건 여전히 유효하지만, 이유가 "짝을 맞춰야 어긋나지 않아서"가 아니라
|
||||
그냥 그 자리가 정말 0개를 기여해서다.
|
||||
- 비용은 배열 하나에 숫자 쓰기뿐이고, Source가 없는 자리엔 다운스트림
|
||||
캐스케이드가 없으니 `Get()` 가드도 필요 없다.
|
||||
- **⚠️ `bk.offsetList`는 네 번째 병렬 배열이 되므로 `spliceArraysUp`/
|
||||
`spliceArraysDown`이 같이 밀고 당겨야 한다**(그 목록에 빠진 게 있어서
|
||||
3라운드에 이미 한 번 물린 적 있음 — `bk.observers`).
|
||||
|
||||
### P2 — `mountInst(target, element, index)`
|
||||
|
||||
`index`는 **그 자리의 절대 offset(0-based)**. Roblox 백엔드는 그냥 무시하고,
|
||||
DOM류는 그 숫자로 삽입 위치를 정한다. `unmountInst(element)`는 그대로 인자
|
||||
없음(뺄 때는 자기 자신만 알면 된다).
|
||||
|
||||
- 부수 효과로 `slot-plan.md`의 **"위치 이전 기억은 base 책임 아님 — backend가
|
||||
필요하면 `Relate`로"** 절이 완화된다: 백엔드가 "직전 형제가 누구였는지"를
|
||||
따로 기억할 필요 없이 **base가 숫자를 준다.** (그 절의 결론 자체는 유지 —
|
||||
base는 여전히 백엔드 종속 위치 정보를 안 들고 있다.)
|
||||
|
||||
### P3 — `recompute`가 절대값을 계산하도록 베이스를 넣는다 (G-2 수정)
|
||||
|
||||
```lua
|
||||
local function recompute(ownerKey, bk)
|
||||
local base = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
|
||||
local sum = 0
|
||||
for i = 1, bk.N do
|
||||
bk.offsetList[i] = base + sum -- P1: 항상 숫자로 기록
|
||||
local src = bk.sourceList[i]
|
||||
if src ~= None and src:Get() ~= base + sum then
|
||||
src:Set(base + sum) -- 발행 채널이 있을 때만
|
||||
end
|
||||
sum += contribution(i)
|
||||
end
|
||||
if isSlot(ownerKey) then ownerKey.Length:Set(sum) end -- Length는 base를 안 더한 로컬 합
|
||||
end
|
||||
```
|
||||
|
||||
- **`Length`에는 `base`를 안 더한다** — 길이는 "내가 몇 개를 차지하나"라
|
||||
위치와 무관하다. 이 둘이 갈리는 게 `base`를 별도 변수로 두는 이유.
|
||||
- **추가로 구독 하나가 필요하다** — 중첩 Slot은 **자기 `Offset`이 바뀌면 자기
|
||||
`recompute`를 다시 돌려야** 한다(앞 형제의 길이가 변해 베이스가 밀리는 경우).
|
||||
`materializeSlotTree`에서 `slot.Offset`에 Observer 하나를 걸고
|
||||
`gatedRecompute(slot)`을 연결, 앵커는 `physicalTarget`(`C-4` 규칙 그대로).
|
||||
- **순환은 안 생긴다** — offset은 top-down, length는 bottom-up으로 방향이
|
||||
갈린다. offset 변경이 자식 offset을 밀 뿐 길이를 바꾸지 않고, 길이 변경이
|
||||
부모 recompute를 태울 뿐 자기 offset을 안 바꾼다.
|
||||
|
||||
## G-4. 판단이 필요한 것
|
||||
|
||||
1. **P1~P3을 이대로 반영할까?**(셋이 한 덩어리다 — P2만 하면 숫자가 틀리고,
|
||||
P3만 하면 plain 요소 숫자가 여전히 없다.)
|
||||
2. **`mountInst`의 `index`를 0-based 절대 offset으로 두는 게 맞나** —
|
||||
`offset`/`sum`이 이미 "0-based 개수"라 그쪽과는 일관된다. 1-based로 주면
|
||||
`updateFn`의 `index`(1-based 지역 위치)와 헷갈릴 수 있어 0-based를 권한다.
|
||||
3. **`bk.offsetList`를 새로 두는 대신 `sourceList`가 `Source | number`를
|
||||
담게 하는 안**도 가능하다(사용자 표현에 더 가까움). 다만 그러면 "이 자리에
|
||||
발행 채널이 있나"와 "지금 값이 얼마인가"가 한 슬롯에 섞이고, 등록 시점에
|
||||
`None`이던 자리가 계산 후 숫자로 덮이면 **등록 정보가 사라진다**(해제 시
|
||||
`None` 재등록으로 판정하는 지금 계약과 충돌). 그래서 **배열을 하나 더 두는
|
||||
쪽을 권한다.**
|
||||
|
||||
|
||||
---
|
||||
|
||||
# H. 4차 — `getOffsetAt`으로 확정 (G절 결론, `base/` 반영 완료)
|
||||
|
||||
**입력**: *"isSlot 인지 아닌지와 상관 없이 부모에서의 Offset 을 알아야하는거
|
||||
아님? … 아. 구독 이유는 알겠음, 자식 slot 의 offset 을 다시 설정해주기
|
||||
위함이구나. offset의 깊은 전파를 위한거군. 단순 그러고선 setOffsetSource(None)
|
||||
은 그냥 리턴해버리고, getOffsetAt 같은걸 넣어서 length 추적을 그냥 뽑아놓자.
|
||||
setOffsetSource 에선 source 를 받으면 그건 set 해주지만, 아니면 그냥 얼리리턴에
|
||||
None 으로만 둬주고, getOffsetAt 은 직접 호출하는걸로?"*
|
||||
|
||||
**에이전트 제안(G-3의 P1)보다 이쪽이 낫다** — 채택하고 반영했다.
|
||||
|
||||
## H-1. 무엇이 바뀌었나
|
||||
|
||||
| | 에이전트 제안(P1) | **채택된 것(사용자안)** |
|
||||
|---|---|---|
|
||||
| 숫자를 어떻게 얻나 | `recompute`가 **모든 자리에 push**해 `bk.offsetList[i]`에 기록 | **`Dispatch.getOffsetAt(ownerKey, i)`로 pull** |
|
||||
| 병렬 배열 | 네 번째(`offsetList`) 신설 → `spliceArraysUp/Down`이 같이 밀어야 함 | **안 늘어남** |
|
||||
| `setOffsetSource(None)` | 등록만 하고 계산은 recompute가 | **얼리 리턴**(계산할 이유 자체가 없음) |
|
||||
|
||||
**pull이 나은 이유 세 가지**(반영하며 확인):
|
||||
|
||||
1. **부기 배열이 안 는다** — 3라운드에 `spliceArraysDown`이 밀어야 할 배열
|
||||
목록에서 `bk.observers`가 빠져 있던 게 실제 결함이었는데, 배열을 하나 더
|
||||
만들면 그 실수 표면이 또 넓어진다.
|
||||
2. **"관측해야 실체화된다"와 같은 결** — 아무도 안 물어보는 자리의 숫자를
|
||||
미리 계산해 들고 있을 이유가 없다.
|
||||
3. **중복이 사라진다** — `setOffsetSource`의 즉시 계산이 하던 합산 루프가
|
||||
그대로 `getOffsetAt`이 되어, 두 곳에 있던 같은 로직이 한 곳으로 합쳐졌다.
|
||||
|
||||
## H-2. 베이스는 어디서 오나 — `slot.Offset` 그대로 (`bk.base`는 폐기)
|
||||
|
||||
**1차 반영 때 `bk.base` 필드에 복사해뒀다가, 같은 세션에 사용자 지적으로
|
||||
걷어냈다**: *"bk.base 가 왜 필요한거임? … 이건 slot 안의 slot.offset 이랑 기능이
|
||||
겹칠텐데, 부모 slot 의 offset 읽는게 이미 정확해. 미리 offset을 받아 설정해두니까.
|
||||
최상위에선 애초에 base자체가 없지 않아? 항상 0 일텐데."* 맞다 —
|
||||
|
||||
- **Slot의 베이스 = 자기 `.Offset`**이고, 그 값은 **부모가 먼저 설정해준다**
|
||||
(`materializeSlotTree`의 첫 줄이 `setOffsetSource(ownerKey, position, ...)`).
|
||||
즉 이미 정확한 값이 있는데 `bk.base`는 그걸 한 번 더 들고 있는 **중복
|
||||
상태**였다 — 같은 값을 두 곳에 두면 갈라진다는, 이 코퍼스가 반복해서 물린
|
||||
패턴 그대로다.
|
||||
- **최상위(물리 inst)는 베이스라는 개념 자체가 없어 항상 0.**
|
||||
|
||||
```lua
|
||||
local base = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
|
||||
```
|
||||
|
||||
- **`isSlot` 분기가 남는 건 타입 분기여서가 아니라 다른 방법이 없어서다** —
|
||||
`ownerKey.Offset`을 그냥 인덱싱해 있나 보는 duck-typing은 **Roblox userdata에서
|
||||
정의 안 된 키 인덱싱이 에러를 던질 수 있어** 코퍼스가 이미 금지한 방식이다
|
||||
(`base/brand-plan.md`의 duck-typing 기각 근거 (b)).
|
||||
- `Length`에는 베이스를 안 더한다(길이는 위치와 무관) — 그래서 `base`와 `sum`이
|
||||
별도 변수로 남는다.
|
||||
|
||||
## H-3. 깊은 전파 — 자기 `Offset` 관측 (짚으신 그대로)
|
||||
|
||||
앞 형제의 길이가 변해 내 베이스가 밀리면 **내 자식들의 offset도 다시 계산돼야
|
||||
한다.** 그래서 중첩 Slot은 자기 `Offset`에 Observer 하나를 걸고
|
||||
`recompute(self)`를 태운다. `_listObserver`/`_detachCleanup`과 **완전히 같은
|
||||
취급**으로 뒀다 — 생성 1회, 언마운트 시 앵커만 해제, 재마운트 시 새
|
||||
`physicalTarget`에 재앵커, 파괴 시 핸들까지 `nil`.
|
||||
|
||||
## H-4. ⭐ 반영하다 발견한 결함 하나 더 — 재마운트가 `Offset` Source를 새로 만들고 있었다
|
||||
|
||||
`materializeSlotTree`가 `local offsetSource = Source(0)`으로 **매 마운트마다 새
|
||||
Source를 만들어** `slot.Offset`에 넣고 있었다. 그런데 언마운트 쪽은
|
||||
`slot.Offset`을 **일부러 보존**한다(`SL-75`) — 그 이유가 *"이미 렌더된 요소들이
|
||||
그 Source를 구독한 채 함께 딸려 나가기 때문"*(`DC-6`에서 사용자가 정밀화)인데,
|
||||
재마운트가 객체를 갈아치우면 **그 구독자들이 옛 객체에 남아 새 위치를 영원히
|
||||
못 받는다.** 포탈이 정확히 그 지점에서 깨진다.
|
||||
|
||||
→ `local offsetSource = slot.Offset or Source(0)`으로 **identity 재사용**하도록
|
||||
정정했다. `SL-75`/`DC-6`이 세운 불변식("`Offset`은 객체를 유지하고 값만
|
||||
갱신한다")이 이제 마운트/언마운트 양쪽에서 일관된다.
|
||||
|
||||
## H-5. 반영 목록
|
||||
|
||||
| 대상 | 내용 |
|
||||
|---|---|
|
||||
| `dispatch-core-plan.md` | `Dispatch.getOffsetAt(ownerKey, i)` 신설, `recompute`에 `bk.base` 시드(+`Length`는 base 제외), `setOffsetSource`의 `None` 얼리 리턴, `None` 의미를 "발행 채널 없음"으로 정정(plain 요소가 `None`+`setLength(1)`인 게 정상임을 명시) |
|
||||
| `slot-plan.md` | `materializeSlotTree`의 `Offset` identity 재사용 + `_baseObserver`(생성 1회/재앵커/파괴 시 정리), `mountSlotTree`가 러닝 누적으로 `mountInst(target, element, acc)` 호출(O(n)), `rawAdd`/`rawReplace`는 `getOffsetAt`으로 위치 전달, `mountInst` 절을 확정으로 갱신(0-based 절대 offset, `unmountInst`는 인자 없음) |
|
||||
| `question.md` | G절 항목을 **해소**로 갱신 |
|
||||
|
||||
## H-6. 남은 확인 (작음)
|
||||
|
||||
- **`getOffsetAt`은 O(i)**라 배치에서 자리마다 부르면 O(N²)다. 그래서
|
||||
`mountSlotTree`는 러닝 누적으로 O(n)을 유지하고, 단건 경로(`rawAdd`/
|
||||
`rawReplace`)만 직접 부른다. 이 구분이 맞다고 보고 그렇게 적었다.
|
||||
- **`getOffsetAt`이 공개 표면인지** — 지금은 `Dispatch.*`로 뒀다(백엔드가
|
||||
자기 핸들러에서 부를 수 있어야 하므로). 비공개로 두고 싶으면 알려주시라.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# I. 반영 후 자체 트레이싱 (2026-08-21, 커밋 전)
|
||||
|
||||
H절 반영을 커밋하기 전에 새 의사코드를 손으로 실행해보다 **실제로 터지는 경로
|
||||
둘**을 찾아 같이 고쳤다.
|
||||
|
||||
## I-1. ⭐ 빈 Slot이 `recompute`에서 크래시 (`bk.N`이 `nil`)
|
||||
|
||||
`bk.N`은 `setLength`가 처음 불릴 때 생긴다(`bk.N = math.max(bk.N or 0, i)`).
|
||||
그래서 **요소가 하나도 없는 Slot**(`Slot()` 직후, 데이터가 빈 `:List`,
|
||||
전부 필터로 걸러진 리스트)은 `N`이 `nil`인 채로 `materializeSlotTree` 끝의
|
||||
`recompute(slot, bk)`에 도달하고, `for i = 1, nil`이 그 자리에서 터진다.
|
||||
|
||||
**이건 이번 변경이 만든 게 아니라 원래 있던 갭**이다(`recompute`의 루프 상한이
|
||||
처음부터 `bk.N`이었음). 빈 Slot은 완전히 정상적인 상태라 방어가 아니라 계약으로
|
||||
보고 `for i = 1, bk.N or 0`으로 고쳤다.
|
||||
|
||||
## I-2. ⭐ `_baseObserver`의 "등록 즉시 1회 실행"이 미완성 부기를 훑음
|
||||
|
||||
G절에서 신설한 `_baseObserver`(자기 `Offset`을 관측해 자식 offset을 다시 미는
|
||||
구독)를 처음엔 `blocker:On()` **앞**에 만들었는데, Observer는 **등록 즉시 1회
|
||||
실행**되므로 그 자리에서 곧바로 `recompute(slot, ...)`이 돈다 — **자식 등록이
|
||||
하나도 안 된 상태**를 훑는다(위 `I-1`과 겹치면 그대로 크래시).
|
||||
|
||||
→ **생성을 `blocker:On()` 뒤로 옮겼다.** 게이트 안에서 만들면 그 1회가 그냥
|
||||
삼켜지고, 배치 끝의 명시적 `recompute`가 어차피 정확한 값을 계산한다. 재마운트
|
||||
분기(앵커만 다시 거는 쪽)는 Observer를 새로 안 만드니 이 문제가 없다.
|
||||
|
||||
**패턴 메모**: "등록 즉시 1회 실행"이 배치 게이팅과 부딪히는 건
|
||||
`setLength`의 Observer에서 이미 한 번 정리된 적이 있다(`gatedRecompute`가 그
|
||||
1회도 게이트에 통과시킨다는 규칙) — **새로 만드는 Observer는 그 규칙을 자동으로
|
||||
물려받지 않으므로, 배치 구간에서 Observer를 만들 때마다 "이 1회가 게이트를
|
||||
지나는가"를 확인해야 한다.**
|
||||
|
||||
|
||||
## I-3. `setLength`의 `anchor`를 선택 인자로 (핸드오버 정리 중 보강)
|
||||
|
||||
`C-4`로 신설한 4번째 인자를 "항상 넘긴다"로 적어뒀는데, 그러면 `inst`를 owner로
|
||||
쓰는 **기존 3-인자 호출부가 전부 stale**해진다(`ref-plan.md`의
|
||||
`ProcessedPreRefHandler`/`ProcessedPostRefHandler`, `modifier-plan.md`의
|
||||
`ProcessedModifierHandler`, `lifecycle-hooks-plan.md`, `component-composition-plan.md`
|
||||
등). 그런데 그 자리들은 **`ownerKey`가 곧 물리 target**이라 앵커를 따로 줄 이유가
|
||||
없다.
|
||||
|
||||
→ **`anchor`를 생략하면 `ownerKey`로 폴백**하도록 정리했다. 실제로 넘겨야 하는
|
||||
건 **`ownerKey`가 Slot인 자리**(`materializeSlotTree`의 등록 루프, 런타임
|
||||
`rawAdd`/`rawReplace`)뿐이고, 나머지 문서의 호출 예시는 손댈 필요가 없다.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# J. 커밋 전 감사 (2026-08-21) — `quad-doc-auditor` 1라운드
|
||||
|
||||
핸드오버 준비로 감사를 돌렸다(각도: **이번 diff와 그걸 인용하는 자리의 base
|
||||
정합성**). **실제 결함 6건 + 판단 필요 2건**이 나와 전부 처리했다.
|
||||
|
||||
## J-1. 실제 결함 (전부 반영 완료)
|
||||
|
||||
| # | 발견 | 왜 문제였나 | 반영 |
|
||||
|---|---|---|---|
|
||||
| 1 | **`Owned`가 코드에 도달하지 못하고 있었다** | `settle`/`destroySlotTree`/`releaseElement`가 `self._owned`를 9곳 넘게 읽는데, 정작 `Slot:List`/`Slot:Single` 의사코드가 **`opts` 인자를 안 받고** `_owned`를 세우는 줄이 없었다. `wrapElement`도 산문은 "`Owned = false`로 설치된다"면서 코드는 `sub:Single(v)`뿐 | `Slot:List(data, updateFn, keyFn, opts)` / `Slot:Single(state, updateFn, opts)`로 시그니처 보강 + `opts.Owned == false`일 때만 필드 세팅, `wrapElement`는 `sub:Single(v, nil, { Owned = false })` |
|
||||
| 2 | **`effect-plan.md`가 자기 자신과 모순** | 문서 최상단 시그니처가 `Effect(fn, state?)`이고 "**`Effect(fn, a, b, c)`처럼 trailing args로 받는 sugar는 의도적으로 안 만듦**"이라는 확정 문단이 그대로 남은 채, 같은 파일 아래에 `C-6`의 `Effect(fn, ...deps)` 절이 추가돼 있었다 — **역전 배너 없이 정반대 두 서술이 공존** | 시그니처를 `Effect(fn, ...deps)`로 갱신, 옛 문단에 🔄 역전 배너 + **옛 근거가 무너진 이유 둘**(`Ref`는 `:With`로 합칠 수 없어 애초에 의존성이 될 방법이 없었다 / 구독을 따로 걸면 합치는 노드 자체가 안 생겨 "감출 비용"이 없다) |
|
||||
| 3 | **`indexOfRaw`가 어디에도 정의돼 있지 않음** | 신설 `rawReplace`가 쓰는데 문서에 없었다. 구현자가 공개 `IndexOf`를 그대로 쓰면 **래핑된 자리에서 어긋난다**(그건 언래핑 기준 비교) | `indexOfRaw` 한 줄 정의 + `IndexOf`와의 차이 명시 |
|
||||
| 4 | **index/element 혼용 캐비엇 목록이 안 늘어남** | 기존 캐비엇이 `rawRemove`/`rawUnmount`만 나열하는데, 이번에 같은 불일치를 물려받은 `rawDetach`/`releaseElement`/`rawReplace`가 빠져 있었다 | 캐비엇에 셋 추가 |
|
||||
| 5 | **`mountSlotTree`의 전제가 코드에 없었다** | `acc = slot.Offset:Get()`이 정확하려면 **`materializeSlotTree`가 먼저 돌아야** 하는데, 분해된 함수의 계약이 주석에 없었다(`research/slot-attach-decomposition.md`에 "중간 상태 처리"가 열린 항목이라 더 위험) | ⚠️ 전제 주석 추가 |
|
||||
| 6 | **`Replace`의 `destroyOld`가 base 본문에 없었다** | `rawReplace`에 인자가 있는데 CRUD 표/산문이 그 존재를 설명 안 함. followup은 처리 기록이지 소스가 아니다(이번 라운드 `CR-1`의 교훈 그대로) | 공개 `Replace`는 항상 파괴 / `:List`만 `_owned`를 넘긴다를 산문에 명시 |
|
||||
|
||||
부수로 인덱스도 같이 정리했다 — **`Gate` 이름이 `question.md` 1번(용어
|
||||
대기열)에 없던 것**(5라운드 `CR-2`가 지적한 패턴이 같은 라운드에서 재발),
|
||||
`todos.md` 00번 헤더의 "열린 항목 없음"(5라운드/`Gate`와 모순),
|
||||
`architecture.md`/`dispatch-core-plan.md`의 주입 op 목록에 `mountInst`/`unmountInst`
|
||||
가칭 표시, `setLength` narrative 스텁의 `anchor?`/`getOffsetAt` 반영,
|
||||
`research/gate-primitive.md`에 **소비자 `Effect(fn, ...deps)`** 추가.
|
||||
|
||||
## J-2. 판단 필요 — `question.md` 3번에 올림
|
||||
|
||||
1. **offset이 바뀌면 이미 배치된 물리 노드를 옮겨야 하는가**(웹 백엔드 계약).
|
||||
`mountInst`가 삽입 시점 위치만 받는 일회성이라, 앞 형제 길이가 변해 뒤
|
||||
형제 offset이 밀리는 흔한 경우 DOM에선 순서가 실제로 어긋난다. quad-web이
|
||||
생길 때까지 미룰 수는 있지만 **지금 안 정하면 M6가 "안 옮긴다"를 전제로
|
||||
굳는다.**
|
||||
2. **`:List` 재조정의 `getOffsetAt` 비용** — `settle`이 키마다 부르므로 이미
|
||||
마운트된 리스트가 통째로 바뀌면 O(N²)(최초 마운트는 `_mounted == false`
|
||||
얼리 리턴이 막아준다). `DC-9`의 "배치 1회니 감수"와 달리 **매 reconcile**이라
|
||||
빈도가 다르다. reconcile이 `pos`처럼 절대 offset도 러닝 누적으로 들면 O(n)
|
||||
— `mountSlotTree`가 이미 그 방식이다.
|
||||
|
||||
## J-3. 감사가 확인만 하고 넘어간 것 (회귀 없음)
|
||||
|
||||
`Tween:Mapped` 표기 통일 / `bk.base` 제거(남은 건 "왜 안 두는지" 서술뿐) /
|
||||
`D-56` 역전 4곳 정합(포인터·archive 원문·README 색인·question 해소) /
|
||||
`KeyGone` 의사코드↔산문 / `Owned=false`+`Detach` 3곳 / 물리 op 치환 9곳
|
||||
(`element.Parent`/`:Destroy()` 잔존 0) / 5라운드 파일 3개 색인.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# K. 감사 2라운드 + 그 자리에서 받은 회신 (2026-08-21)
|
||||
|
||||
## K-1. 감사 2라운드(각도: 인덱스 레이어·히스토리 문서) — 전부 반영
|
||||
|
||||
| 발견 | 반영 |
|
||||
|---|---|
|
||||
| **`ROADMAP.md` 백로그 문단과 `debounce-throttle-plan.md`가 아직 "Gate 추출은 M3에서"라고 서술** — 이번 세션에 M2로 앞당겨졌는데, **손대지 않은 문서라 diff에 안 잡히는 사각지대** | 두 곳 다 "M2로 앞당겨졌다"로 정정 |
|
||||
| `question.md`/`README.md`의 State-에포크 서술이 **옛 결론**(중복 통지 안 고쳐짐 / UB 명문화 필요)을 담고 있었음 — 같은 날 후속으로 정반대로 확정됐는데 인덱스만 안 따라옴 | 둘 다 갱신(중복 통지도 접음 / UB 조항 기각 / `seen`·`computedAt` 두 카운트) |
|
||||
| `README.md`가 5라운드 문항지를 "회신 대기"로 태그한 채 바로 옆에 회신·처리 파일을 등재 | "신설·처리 완료"로 |
|
||||
| 5라운드 문항지 자신의 상태 줄이 "회신 대기"에 멈춰 있음(4라운드는 종결로 갱신한 선례 있음) | "완료·종결" + followup 포인터 |
|
||||
| `ROADMAP.md` M2 체크박스의 문장이 접합부에서 깨져 있었음 | 문장 정리 |
|
||||
| M3의 `Blocker.luau` 체크박스에 "게이트 노드는 M2에 이미 있다"는 표시가 없음 | M3에 순서 주의 항목 + **State 에포크 미결이 `Source.luau`/`State.luau`보다 먼저 닫혀야 한다**는 착수 전 확인 항목 신설 |
|
||||
| `slot-plan.md`의 주입 op 표가 `mountInst(target, element)` 2-인자로 남음(바로 아래 문단은 3-인자로 확정) | 표를 3-인자로 |
|
||||
| `todos.md`가 처리 상태를 `1차 처리 완료`로 적어 실제(4차까지 진행)와 갈림 | 라운드 수를 빼고 `처리 완료`로 |
|
||||
|
||||
## K-2. 같은 자리에서 받은 회신 — 셋 다 확정
|
||||
|
||||
### (1) `raw*`는 **index로 전부 통일** (오래 열려 있던 캐비엇 종결)
|
||||
|
||||
**사용자 확정**: *"index 로 전부 처리되면 될듯. 애초에 안에서 다시 element ->
|
||||
index 를 찾아야하던걸로 앎."*
|
||||
|
||||
- `rawRemove`/`rawUnmount`/`rawDetach`/`rawMove`/`rawSwap`/`rawExtract`/
|
||||
`rawSplice`/**`rawReplace`** 전부 index를 받는다. 예외는 `rawAdd` 하나
|
||||
(새로 넣는 대상이라 element가 인자인 게 당연).
|
||||
- `settle`이 element만 쥐고 있어 index를 구해야 하는 문제는 — **`keyIndex`가
|
||||
이미 그 값을 들고 있다**(그 키가 지금 차지한 압축 위치 = `_elements` 인덱스).
|
||||
그래서 `indexOfRaw`(O(n))는 **폴백이지 기본 경로가 아니다.**
|
||||
- `releaseElement(self, index, element, wasDetached)`로 시그니처 조정
|
||||
(detach 중이던 요소만 자리가 없어 `index = nil`).
|
||||
- **M6 구현 세부로 열어뒀던 항목이 이걸로 닫혔다.**
|
||||
|
||||
### (2) 래핑은 `raw*` **바깥**에서 (`raw*`는 물리 요소만)
|
||||
|
||||
**사용자 확인**: *"raw들은 모두 래핑된거 그대로 넣고 빼도록 할까?"* — 그렇다.
|
||||
래핑 지점은 **공개 표면 + `settle`** 둘뿐이고, `raw*`의 세계는 물리 요소 하나로
|
||||
균일하다. 그래야 방금 통일한 index 규약이 다시 흐려지지 않는다.
|
||||
|
||||
### (3) `B-1` 해소 / `B-2` 해소
|
||||
|
||||
- **`B-1`(offset이 바뀌면 이미 놓인 노드를 옮겨야 하나) → 아니오.**
|
||||
*"위에서 넣고 빼면 insert 같은거로 일어나서 뒤로 밀린다"* — DOM은 삽입/삭제
|
||||
자체가 뒤를 밀고 당긴다. 단 **백엔드 op이 아토믹한 최소 단위**여야 한다는 게
|
||||
같이 확인된 요구사항.
|
||||
- **`B-2`(`getOffsetAt` O(N²)) → 접두합 캐시로.** `bk.offsetCache` +
|
||||
`bk.offsetDirtyFrom`("이 인덱스 초과부터 무효"), 무효화 트리거 셋을 명시
|
||||
(`setLength`는 `i+1`부터 / splice는 그 자리부터 / 베이스 변경은 전체).
|
||||
**남은 작은 확인**: `recompute`의 전체 순회가 그 캐시를 같이 채우게 할지.
|
||||
124
.claude/qa-request/pre-implementation-qa-round5-response.md
Normal file
124
.claude/qa-request/pre-implementation-qa-round5-response.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# 구현 전 QA 5라운드 — 사용자 회신 원문 (2026-08-21)
|
||||
|
||||
**이 파일은 회신 원문 그대로의 기록이다** — 처리 결과의 소스는
|
||||
`pre-implementation-qa-round5-followup.md`. 4라운드와 같은 구성
|
||||
(문항지 / 회신 원문 / 처리 결과 3파일).
|
||||
|
||||
---
|
||||
|
||||
### DE-7
|
||||
_detached 를 모든 slot 이 가져야하나는 의문이 듦. 내부적으로 getOrSet 해서 detached 를 리턴해주는 유틸을 만들고, nilable 해도 되지 않나라는 생각. destroySlotTree 가 _detached 를 인덱스 해보고 nil 아닌지 보고 돌리는게 더 나아보이는데, 테이블 생성 비용을 모든 slot 이 가져야하나는 의문. List 슬롯에서만 작동하는것인데, 너무 광범위하지 않은가? if 확인으로 nil 이면 스킵이 훨씬 싸게 먹히지 않는가?
|
||||
순수 구현 상 아무 문제 없겠지만, 단순 최적화 문제. 최적화에 드는 비용이 거의 없는데, 안 할 이유가 보이진 않음.
|
||||
|
||||
+
|
||||
아무것도 없는데 Detach 를 보내면 어떻게 되느냐. prev 없음을 유저가 추적해야하는가, 아니면 detach 를 그냥 prev == nil 일 때 던지면 무시해주느냐
|
||||
prev 가 마운트 상태에서(Detach 안 함) 다시 prev 를 던지는것은 이번 변경으로 인해서 여전히 문제 없이 잘 작동하는가
|
||||
|
||||
### DE-9
|
||||
error 하면 된다. KeyGone 을 받은 요소는 오직 데이터의 파괴 또는 detach 를 통해 다시 나오는 경우를 위한 캐싱 이외의 새로운 마운트나 생성을 거부한다.
|
||||
|
||||
### DE-11
|
||||
맞다. 문서화에서만 유의하면 되는 부분. 단순 삽입/삭제가 빈번한 경우를 위한 최적화 일 뿐. 그 이상의 동작을 돕지 않는다.
|
||||
|
||||
### DE-13
|
||||
_detachCleanup 이 정확히 어떤건지 모르겠음. _detached 에 들어가게 될 대상을 말하는것인지? 아니면 detach 된 요소들이 나중에 정리되어야할 때인지?
|
||||
나중에 detach 했던 요소들을 청소하는것이라면, 소유한것이면 죽이는게 맞긴 하다.
|
||||
다만 주의해야할 부분이 보인다. 애초에 unowned 의 state 로 받은것은 더이상 가지고 있지 않는다. detach 에 들어가있지도 않는다. 외부로 반출된 것이라 다시 들고와서 자기 자신에 붙이지 않음. 즉 detach 가 unowned 에 대해서 수행되면 prev 가 나중에 nil 이 되는것이다. 그래야 state<Frame> 에서 내부를 교체했을 때, 이전 요소를 안 건들이게 되는것이라 생각하는데, 내 생각이 잘못 흐른건지 검토해달라.
|
||||
|
||||
### DE-17
|
||||
주의 할 점은, state 가 slot 에 바로 안 오기 때문에, updateFn 이 state 를 던져도 싱글 slot화 된다. 그리고 owned = false 가 되는건 이 싱글 슬롯 안 1번째 객체에 대해서 적용이다.
|
||||
나중에 새로운 state<Frame> 같은게 나온다면, 이전거는 prev 로 updateFn 이 받으니 그걸 어떻게 처리할지는 updateFn 의 몫. 특히 slot 래핑 안 된 상태로 받아야하는가도 생각해보아야한다. prev 는 이전에 던진 그대로 주는것이므로. 그리고 이전과 같은 state 를 던지면 멱등으로써 새로운 slot 을 만들지 않고 그대로 두는것도 여전해야한다.
|
||||
|
||||
### DE-22
|
||||
의도가 맞다.
|
||||
|
||||
### AS-5
|
||||
activateList 도 결국 상위에 setlength 를 하는것 아닌가? 게이트가 blocker 없이는 호출되지 않는지 확인 필요
|
||||
|
||||
### DC-6
|
||||
더 정확히는, 슬롯을 뽑아냈을 때, 이전에 렌더된 요소들은 여전히 layout order 등을 위해 offset을 연결해두고 있다. 이 상황에서 아에 다른 값을 slot.Offset 으로 쓴다는것 자체가 나중에 포탈에서 깨지는 부분을 생성한다
|
||||
|
||||
### DC-11
|
||||
필요 이유를 모르겠음. length 업데이트를 위한것임?
|
||||
|
||||
### DC-14
|
||||
사실, 외부 입장에서는 그럴 방법이 없어보인다. crud 가 list 시에는 더이상 불가능해지기 때문.
|
||||
|
||||
### DC-19
|
||||
rawAdd 에서도 필요한가는 모르겠음. 목적이 다르지 않나?
|
||||
|
||||
### SS-2, SS-3
|
||||
단순히 각 state 에, 이전 emit 을 발생시킨 발행 unique table 를 넣는건 어떤지 고민중. source 에서만 발행되고 emit 상 전파된다.
|
||||
지금 보이는 문제로는
|
||||
A -> B --> D
|
||||
-> C -|
|
||||
상황에서 결국 dfs 로 순회되어서 D 에 먼저 A 소스의 emit 신호가 온다. C는 아직 받기 전. invalid 하지 않아서 바로 캐시가 읽히고, 그대로 D에 캐시에 쓰인다. C의 변경으로 다시 emit 되긴 하지만, 이걸 잡을만한 방법이 없어보이진 않는다.
|
||||
이런 문제를 전부 쉽게 푸는 방법이 존재하는데, emit 시 unique 한 테이블 하나를 전파하고 그 테이블 안에 count 값과 invalid 플래그가 들어간다.. 각 source 는 해당 테이블을 가지고 있다가 emit 된다면 invalid=false count=최신으로 값을 로 바꾼다.
|
||||
with 에서는 여럿 받은 곳에 대한걸 관리하도록 둔다.
|
||||
-> 생각하다 위쪽껀 이상했음. 이러면 모든 발행된 unique 를 들고 count 를 관리해야한다
|
||||
아니면 반대로 count 부분만 담는 테이블을 만들고 연결하는건 어떤가?
|
||||
unique 자신의 실제 인덱스와, source 의 count 와 비교해서 일치해지는가를 본다.
|
||||
만일 파이프 뒷 요소로 get 되어진 경우 최상위 소스에서 해당 유니크를 가져와 덮어도 된다.
|
||||
-> bfs 도 생각했는데, 순서를 꼬아두면 문제가 생긴다.
|
||||
재정리: 차라리 이렇게?
|
||||
State()
|
||||
sourceList <- {
|
||||
[source:weak] -> count
|
||||
} weak 로 영향받는 source 들을 담는다. 앞단 요소에서 복사, with 시 합친다
|
||||
rawInvalid <- 단순 emit 시에 바로 false 된다. 캐싱
|
||||
invalid <- rawInvalid 캐시를 보고 true 라면 sourceList 를 보고 계산 필요 상태인지 확인한다
|
||||
더 생각해볼 이야기라 백로깅이나 리서치에 들어가야할듯.
|
||||
Get 이 항상 최신 상태를 가져온다라는 말이 여기서 무력화되는 부분이라 생각이 필요해보인다.
|
||||
어차피 emit 단순히 전파는 false 이라 연산 비용이 없어 잘 전파되고. 복합 state 상태를 잘 관리해주는게 나아보인다.
|
||||
대부분 옵저빙을 하여 state 를 읽지, 폴링해서 get 하는 경우도 잘 없으므로 문제 있는 구현으로 안 보인다.
|
||||
폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게 Ref<T> 로 변환해주는 등의 작업을 하는 슈거를 줘도 된다.
|
||||
비용은 다소 한정적으로 보인다. 해시 for은 이미 빠르고, Source가 중간중간 느는게 아니라 처음 시작점이라 수 자체가 적다. 2~4개에 대해 인덱싱 하는 정도라 충분히 가벼워보이는 단일 for 로 해결이 되는것으로 보임.
|
||||
emit 은 이제, 발행시킨 source 와 count 를 전달하기만 하면 된다. count 를 쓰기 싫다면 단일 테이블을 써도 되는 부분. [source:weak] -> {}
|
||||
선제 최적화라기 보단 확실히 정해진 동작으로 승격하는 일로 보이는데 어떻게 생각하는가?
|
||||
|
||||
### LC-3, LC-4
|
||||
무슨말인지 모르겠다. 애초에 Slot effect 나 다른 요소들을 소유할 수가 없다.
|
||||
심지어 내 생각으로는 slot owned 가 canBound 에 들어갈 이유가 있나? 모르겠다
|
||||
왜냐면 실제 observer/effect 는 실제 inst 에 불림. slot in slot 에서 slot 을 유지하는건 이미 slot 의 강참조 배열이 해결해주는데, 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할 부분인듯?
|
||||
|
||||
### EF-3
|
||||
예
|
||||
|
||||
### AT-1
|
||||
(inst, groupValue) → k 이면 충분하다. group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 필요가 없고, 그건 key->name 이 유일성을 검증해준다. {a,a} 는 단순 그룹이 이미 할당된 키가 있나를 보기만 위함임. AT-2 도 같이 닫는다
|
||||
|
||||
### TW-2
|
||||
Mapped 로 확정 CR-2 도 해결됨
|
||||
|
||||
### DT-4
|
||||
그렇다. 이러면 스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer 가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가 되면 이를 전혀 못 지키기 때문.
|
||||
Gate 가 emit 에 중간에 가로채서 넘길지 말지 처리를 해줄 수 있게하는 방법을 제공하는게 맞다. 그리고 이 API가 비공개일 이유는 없어보인다.
|
||||
|
||||
### CR-3
|
||||
게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가 필요함
|
||||
생각 상 새로운 state 가 나오지는 않고, Blocker 와 유사히 동작한다. 내부 배선 상 이렇게 되면 되는거 아닌가 생각중.
|
||||
Gate(function(emit)
|
||||
^-- 위 emit 은 언제든 사용 가능
|
||||
return function () <- 상위 emit 시 발생. 위 emit 을 쓸지 말지는 자유
|
||||
end
|
||||
end)
|
||||
다만 프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의 문제.
|
||||
|
||||
### CR-4
|
||||
부모가 죽기 전까진 detached 정리가 안되니 그건 맞다.
|
||||
|
||||
+
|
||||
Effect 가 지금은 Ref에 대해서 수행될 수가 없다. 단순히 Effect(, ...) 를 만들고 ... 요소를 With 으로 합치는게 아니라 여러 요소에 대해서 Observe/Callback 하는게 어떻겠냐는 생각이 드는 지점. 처음 분기는 Blocker() 의 다른 사용법을 통해 막는다. 그게 더 나은 구현으로 보이는중.
|
||||
|
||||
+
|
||||
확인할 부분이 있다. state<Slot> -> Slot single { Slot } 모양이 될 때 부모 Slot 이 length 를 잘 따라가는가? 아마 그렇다고 보는데, 정확한지 봐야한다.
|
||||
|
||||
+
|
||||
뽑는것은 자유롭지만, Slot 이 들고있다 죽는건 죽어야하는데. 그 처리가 영향을 받았는지 궁금함. 즉
|
||||
Frame {
|
||||
Slot {
|
||||
State(Frame) <- 여기 바인딩은 상위 Frame 이 죽으면 같이 죽음.
|
||||
}
|
||||
}
|
||||
이건 Effect 쪽에서 뭔가 처리해줄 수 있는게 아닌게, 엔진 자체가 recursive 호출로 전부 죽이는게 일반적이기에 우리가 빼줄 수 있는 요소도 아니고, 같이 죽는게 의도 동작이기 때문.
|
||||
따라서 State 에서 무언가 마운트 된 요소를 뽑아낼 때, 부모가 죽었다면 이미 죽은 요소가 된다. 이건 의도 동작인데, 언급이 되어있나 모르겠음.
|
||||
1302
.claude/qa-request/pre-implementation-qa-round5.md
Normal file
1302
.claude/qa-request/pre-implementation-qa-round5.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -58,6 +58,14 @@
|
|||
callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라
|
||||
**패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`의
|
||||
"`nil` 리턴은 파괴가 기본" 절.
|
||||
- **`Gate`(2순위, 2026-08-21 신설)**: `Blocker`/`Debounce`/`Throttle` 아래의
|
||||
공용 게이트 노드 이름. 사용자 지적 — *"프리미티브 명을 Gater? 뭔가 이상하게
|
||||
들어간다는게 약간의 문제"* — 코퍼스가 `Blocker`/`Modifier`/`Observer`처럼
|
||||
`-er`를 많이 쓰는데 `Gater`는 영어로 어색하다. 에이전트 권고는 **`Gate`
|
||||
그대로**(`gate`는 이미 행위자가 아니라 **장치**를 가리키는 명사라 `-er`가
|
||||
불필요 — `Source`/`Ref`/`Slot`/`Tween`도 같은 계열), 대안 후보는
|
||||
`Valve`/`Relay`. **설계 자체가 다음 세션으로 미뤄졌으므로 이름도 그때 같이**
|
||||
— 3번 절의 `Gate` 항목과 `research/gate-primitive.md`가 소스.
|
||||
- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가
|
||||
다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이
|
||||
헷갈릴 수 있음.
|
||||
|
|
@ -126,8 +134,14 @@
|
|||
|
||||
## 3. 낮은 우선순위 — 열려 있지만 급하지 않음
|
||||
|
||||
- **[신설, 2026-08-18 구현 전 QA 3라운드] M2가 M3의 `Blocker.luau`에
|
||||
구조적으로 의존하게 됨 — 이대로 각주만 두고 로드맵 순서를 유지할지,
|
||||
- **[해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] M2가 M3의 `Blocker.luau`에
|
||||
구조적으로 의존하던 순서 문제 — "게이팅 먼저"로 결정.** 사용자 판정:
|
||||
*"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가
|
||||
필요함."* 즉 로드맵 순서를 유지하지 않고 게이팅을 M2로 앞당긴다. **다만
|
||||
앞당기는 대상이 `Blocker` 그 자체가 아니라 그 아래의 공용 `Gate` 노드로
|
||||
바뀌었고**(같은 라운드 `DT-4`), 그 표면/이름이 미정이라 **새 열린 항목으로
|
||||
이어진다 — 바로 아래 `Gate` 항목.** 아래는 해소 전 서술: 이대로 각주만 두고
|
||||
로드맵 순서를 유지할지,
|
||||
`Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로
|
||||
앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법
|
||||
때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가
|
||||
|
|
@ -137,6 +151,95 @@
|
|||
임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전
|
||||
필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`의
|
||||
"ROADMAP.md 마일스톤 정합성" 절.
|
||||
- **[해소, 2026-08-21 구현 전 QA 5라운드 H절] `mountInst`의 삽입 위치 + 중첩
|
||||
offset 결함 — `Dispatch.getOffsetAt(ownerKey, i)` 신설로 확정.**
|
||||
`setOffsetSource(None)`은 얼리 리턴하고, 숫자가 필요한 쪽이 `getOffsetAt`을
|
||||
직접 부른다(사용자안 — 병렬 배열을 안 늘리는 pull 방식). 베이스는 `isSlot`
|
||||
분기의 정체가 "베이스가 있나"임을 명확히 했고(베이스는 따로 저장하지 않고
|
||||
Slot의 `.Offset`을 그대로 읽는다 — 최상위 물리 inst는 항상 0), 중첩 Slot은
|
||||
자기 `Offset`을 관측해 깊은 전파를 한다. 반영하다 **재마운트가 `Offset` Source를 새로 만들던 결함**도
|
||||
같이 잡았다(identity 재사용). 상세는
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`의 H절.
|
||||
아래는 열려 있던 시점의 서술: 둘이 한 덩어리다:
|
||||
(a) `setOffsetSource`의 `None`이 "아무것도 안 차지함"과 "발행 채널 없음"
|
||||
두 뜻을 겸하고 있어 **plain 요소의 offset 숫자가 계산조차 안 된다**(그래서
|
||||
DOM류 백엔드가 삽입 위치를 알 방법이 없다 — 사용자 지적), (b) `recompute`가
|
||||
`sum = 0`에서 시작해 `ownerKey.Offset`을 안 읽으므로 **depth ≥ 2에서 중첩
|
||||
Slot의 자식 offset이 부모 베이스만큼 어긋난다**(이번에 발견, 지금까지 depth 1만
|
||||
써서 안 드러났음). 제안은 `bk.offsetList` 신설(항상 숫자 계산) +
|
||||
`mountInst(target, element, index)` + `recompute`의 `base` 시드 + 중첩 Slot이
|
||||
자기 `Offset`을 관측해 재계산하는 구독 하나 —
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`의 G절이 소스.
|
||||
- **[해소, 2026-08-21] offset이 바뀌면 이미 배치된 물리 노드를 옮겨야 하는가 —
|
||||
아니오.** **사용자 확정**: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고
|
||||
빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"* — DOM류는 삽입/삭제
|
||||
자체가 뒤 형제를 물리적으로 밀고 당기므로, **이미 놓인 노드를 다시 옮길 일이
|
||||
없다.** offset 숫자는 그 자리가 **다음에** insert/remove할 때 쓰는 것뿐이라는
|
||||
기존 서술이 그대로 맞다. 이게 성립하려면 백엔드 op이 **아토믹한 최소 단위**
|
||||
(`mountInst`/`unmountInst`/reposition)여야 한다는 게 같이 확인된 요구사항.
|
||||
아래는 열려 있던 시점의 서술: `mountInst(target, element,
|
||||
index)`가 **삽입 시점의 위치만** 받는 일회성 호출이라, 이미 마운트된 요소의
|
||||
물리 위치는 offset이 바뀌어도 갱신되지 않는다. Roblox는 `LayoutOrder`가
|
||||
프로퍼티라 `updateFn`이 반응형으로 처리하면 되지만 **DOM은 물리 순서 자체가
|
||||
배치**다. `dispatch-core-plan.md`는 "quad-web의 offset 핸들러는 no-op이고 숫자는
|
||||
*다음에* 스스로 insert/remove할 때만 쓴다"고 적어뒀는데, 앞 형제의 길이가 변해
|
||||
뒤 형제들의 offset이 밀리는 흔한 경우에 **이미 놓인 노드를 옮길지**가 그 서술만
|
||||
으론 안 갈린다. 선택지는 (a) 옮긴다(백엔드가 offset 변경을 관측해 재배치),
|
||||
(b) 안 옮긴다(그러면 DOM에선 순서가 실제로 어긋남), (c) `:List`가 재조정 때
|
||||
필요한 것만 명시적으로 다시 `mountInst`. quad-web이 실제로 생길 때까지 미룰 수
|
||||
있으나, **계약을 지금 정해두지 않으면 M6 구현이 (b)를 전제로 굳는다.**
|
||||
- **[해소, 2026-08-21] `:List` 재조정의 `getOffsetAt` 비용 — 접두합 캐시로.**
|
||||
**사용자 제안 채택**: *"getOffsetAt 은 compute 된걸 캐시해도 될듯. length
|
||||
변경되는 뒤는 캐시가 무효화되도록 shouldRecomputeAfter 등을 둬서 특정 인덱스
|
||||
초과부는 offset 다시 계산하고, 해당 값 위치 자체는 유효하므로 그것을 통해 더
|
||||
length 를 이어붙이면 될듯."* → `bk.offsetCache` + `bk.offsetDirtyFrom`으로
|
||||
반영(`base/dispatch-core-plan.md`). 무효화 트리거 셋(`setLength`는 `i+1`부터,
|
||||
splice는 그 자리부터, 베이스 변경은 전체)까지 명시. **남은 작은 확인 하나** —
|
||||
`recompute`의 전체 순회가 그 캐시를 같이 채우게 할지는 구현 시 판단(그 문서에
|
||||
⚠️로 표시). 아래는 열려 있던 시점의 서술:
|
||||
`settle`이 키마다 `rawAdd`/`rawReplace`를 부르고 그 각각이 `getOffsetAt`(O(i))을
|
||||
부르므로, 이미 마운트된 리스트의 데이터가 통째로 바뀌면 **O(N²)**다(최초 마운트는
|
||||
`_mounted == false`라 얼리 리턴이 막아준다). `DC-9`에서 `setOffsetSource`의 즉시
|
||||
계산이 O(N²)인 걸 "배치 등록 1회니 감수"로 판단했지만 **이건 매 reconcile이라
|
||||
빈도가 다르다.** 해법은 있다 — reconcile이 `pos`처럼 **절대 offset도 러닝
|
||||
누적**으로 들고 다니면 O(n)(그게 `mountSlotTree`가 이미 하는 방식). 그렇게
|
||||
할지, 아니면 실측 전엔 그냥 둘지 판단 필요.
|
||||
- **⭐ [신설, 2026-08-21 구현 전 QA 5라운드] 공용 `Gate` 노드의 이름과 표면 —
|
||||
M2 착수 전 필요.** 위 항목의 결정("게이팅 먼저")에 따라 base에 만들 것이
|
||||
`Blocker`가 아니라 **상류 emit을 가로채 정책이 통과 여부를 정하는 공용 게이트
|
||||
노드**로 확정됐다(`Blocker`/`Debounce`/`Throttle`이 그 위의 정책). 사용자
|
||||
스케치는 `Gate(function(emit) return function() ... end end)` 2단 구조이고,
|
||||
**공개 API로 낸다**(사용자: *"이 API가 비공개일 이유는 없어보인다"*). 남은 것 —
|
||||
(a) **이름**(사용자: *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의
|
||||
문제"*, 에이전트 권고는 `Gate` 그대로 — `gate`는 이미 장치를 가리키는 명사),
|
||||
(b) `:Apply` 팩토리인지 독립 생성자인지, (c) `Blocker`가 그 위에 어떻게
|
||||
얹히는지, (d) M2에 `Gate`만 넣을지 `Blocker`까지 넣을지. 상세는
|
||||
`research/gate-primitive.md`.
|
||||
- **⭐ [신설, 2026-08-21 구현 전 QA 5라운드] State 재계산 판정을 "소스 에포크
|
||||
비교"로 바꿀지 — M3 착수 전 필요.** 사용자 제안: 각 State가 자기 상류 루트
|
||||
`Source`들의 카운트를 들고 있다가 `Get()` 때 비교해 재계산 여부를 정한다.
|
||||
동기는 성능이 아니라 **정확성** — DFS 전파 도중 Observer가 `Get()`을 부르면
|
||||
아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어가는 glitch가 지금 모델에
|
||||
실재한다. 에이전트 분석 결과 진단·방향 모두 타당하고 선례도 있으나
|
||||
(MobX/Adapton류 버전 검증), **중복 *통지*는 안 고쳐지고 "선언 안 된 의존성"을
|
||||
UB로 명문화해야 한다.** State 내부 표현을 바꾸는 결정이라 M3 뒤로 미루면
|
||||
되돌리는 비용이 크다. 상세는 `research/state-epoch-validation.md`.
|
||||
- **[해소, 2026-08-21 구현 전 QA 5라운드 `C-4`] `Dispatch.setLength`의 Observer
|
||||
앵커 — 물리 target으로 확정(4라운드 `D-56` 역전).** `setLength`가
|
||||
`(ownerKey, i, len, anchor)`로 4번째 인자를 받아 **부기 키와 생명주기 앵커를
|
||||
분리**한다. 그래서 `bindLifetime`은 항상 물리 Instance만 상대하고,
|
||||
`isBoundAlive`의 세 번째 분기(형태 미정으로 열려 있던 것)도 **필요 자체가
|
||||
없어졌다.** 역전 원문은 `archive/bindlifetime-slot-owner-reversed.md`, 지금
|
||||
결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단. 아래는
|
||||
열려 있던 시점의 서술: 그때 확정(4라운드 `D-56`)은
|
||||
"`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하라"인데,
|
||||
사용자가 그 전제 자체에 의문을 제기했다: *"애초에 Slot 이 effect 나 다른
|
||||
요소들을 소유할 수가 없다 … 실제 observer/effect 는 실제 inst 에 불림 …
|
||||
우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할
|
||||
부분."* 대안은 **부기 키(`ownerKey`)와 생명주기 앵커(물리 `physicalTarget`)를
|
||||
분리**하는 것 — 그러면 `bindLifetime`은 항상 Instance만 받고,
|
||||
`isBoundAlive`의 세 번째 분기(지금 ⚠️ 미정)도 통째로 불필요해진다. 상세와
|
||||
트레이싱은 `qa-request/pre-implementation-qa-round5-followup.md`.
|
||||
- **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설,
|
||||
같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등
|
||||
`:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level
|
||||
|
|
@ -196,9 +299,11 @@
|
|||
claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼
|
||||
`bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야
|
||||
하므로), **[2026-08-20 QA 4라운드] 이름도 `groupClaimKeys`로 확정**.
|
||||
남은 건 **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey`
|
||||
단위인지)와 기존 `nameClaims`와의 공존 방식 —
|
||||
`base/attribute-plan.md`의 "이름 소유권" 절.
|
||||
**[해소, 2026-08-21 QA 5라운드 `AT-1`] 키도 `(inst, groupValue) → k`로 확정**
|
||||
(사용자: *"group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을
|
||||
필요가 없고, 그건 key->name 이 유일성을 검증해준다"*), `nameClaims`보다
|
||||
**위치 claim을 먼저** 본다 — `base/attribute-plan.md`의 "이름 소유권" 절.
|
||||
**이 항목은 닫혔다.**
|
||||
- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
|
||||
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
|
||||
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
|
||||
|
|
|
|||
99
.claude/research/gate-primitive.md
Normal file
99
.claude/research/gate-primitive.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# `Gate` — emit을 가로채는 공용 게이트 노드 (2026-08-21 신설)
|
||||
|
||||
**상태**: research — **방향은 사용자 확정, 정확한 표면이 미정.
|
||||
[2026-08-21] 사용자 지시로 설계 자체는 다음 세션으로 미룸** — *"고칠것이 많으므로
|
||||
Gate 는 다음 세션에 다루겠음. 해당 부분은 정정이 아니고 추가이고, 새 인터페이스를
|
||||
고민해야하므로 해결해야할 일로 남겨두길 바람. 단지 지금 세션 상 지식만 이전될 수
|
||||
있게 두세요."* 그래서 이 문서는 **다음 세션이 바로 이어받을 수 있게 재료만**
|
||||
모아둔 상태다(아래 "아직 안 정한 것"이 그 목록). 구현 전 QA
|
||||
5라운드(`CR-3`/`DT-4`)에서 "M2 전에 게이팅부터 만들 준비를 하고, 실질적 모양을
|
||||
정의해야 한다"는 사용자 결정이 나와 신설. 회신 원문은
|
||||
`qa-request/pre-implementation-qa-round5-response.md`.
|
||||
|
||||
**한 줄**: `Blocker`가 쓰는 "게이티드 State 노드"를 한 겹 일반화해서, **상류
|
||||
emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 공개 프리미티브로 꺼낸다.
|
||||
`Blocker`/`Debounce`/`Throttle`이 그 위에 얹히는 서로 다른 정책이 된다.
|
||||
|
||||
## 왜 지금인가 — 두 갈래가 같은 자리를 가리켰다
|
||||
|
||||
1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을
|
||||
전제하므로 M2가 M3의 `Blocker.luau`에 구조적으로 의존한다. 사용자 결정:
|
||||
**게이팅을 먼저 만든다.**
|
||||
2. **`DT-4`(Debounce/Throttle)** — 공개 `Blocker` API 위에는 시간 기반 게이트를
|
||||
못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용
|
||||
`Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, M3에서 Blocker를
|
||||
만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다.
|
||||
|
||||
**사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가:
|
||||
*"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐
|
||||
후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer
|
||||
가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가
|
||||
되면 이를 전혀 못 지키기 때문."* → 게이트가 **emit 경로 자체에 끼어들어야**
|
||||
하고, 바깥에서 관측만 해서는 순서를 보장할 수 없다.
|
||||
|
||||
**공개 여부**: 사용자 판단 — *"이 API가 비공개일 이유는 없어보인다."* 즉
|
||||
내부 배관이 아니라 공개 프리미티브로 낸다.
|
||||
|
||||
## 제안된 모양 (사용자 스케치 그대로)
|
||||
|
||||
```lua
|
||||
Gate(function(emit)
|
||||
-- 이 `emit`은 setup 밖으로 캡처해 **언제든** 부를 수 있다(타이머 콜백 등).
|
||||
return function()
|
||||
-- 상류 emit이 도착할 때마다 호출된다.
|
||||
-- 여기서 `emit()`을 부를지 말지는 정책 마음.
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
- `setup(emit) -> onUpstreamEmit` 2단 구조. 바깥 함수는 게이트 인스턴스가
|
||||
만들어질 때 1회, 반환된 함수는 상류 emit마다.
|
||||
- `Blocker`는 이 위의 정책 하나가 된다 — "켜져 있으면 `emit()`을 안 부르고
|
||||
플래그만 세워두고, 꺼질 때 한 번 부른다"(`HasBlockedEmit`이 그 플래그).
|
||||
- `Debounce`/`Throttle`도 정책 — 타이머를 걸고 창이 끝날 때 `emit()`.
|
||||
|
||||
## 아직 안 정한 것 (사용자 판단 필요)
|
||||
|
||||
1. **⭐ 이름.** 사용자 지적: *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게
|
||||
약간의 문제."* — 코퍼스 관례가 `Blocker`/`Modifier`/`Observer`처럼 `-er`가
|
||||
많아서 형태만 맞추면 `Gater`인데 영어로 어색하다.
|
||||
**에이전트 권고: `Gate` 그대로.** `blocker`/`modifier`와 달리 `gate`는 이미
|
||||
**행위자가 아니라 장치를 가리키는 명사**라 `-er`를 붙일 이유가 없다
|
||||
(`Source`/`Ref`/`Slot`/`Tween`도 전부 `-er` 없는 명사). 대안 후보:
|
||||
`Valve`(밸브 — 흐름 제어라는 뜻은 더 정확하지만 코퍼스 어휘와 멀다),
|
||||
`Relay`(전기 릴레이 — "받아서 다시 보낸다"는 뜻은 맞으나 "중계"로 오독 여지).
|
||||
2. **`:Apply` 팩토리인가, 독립 생성자인가.** `Debounce`/`Throttle`이
|
||||
`state:Apply(Debounce{...})` 관용구로 확정돼 있으므로 `state:Apply(Gate(setup))`가
|
||||
자연스럽다. 그런데 `Blocker()`는 **여러 state에 공유되는 외부 객체**라 모양이
|
||||
다르다 — `Blocker`가 `Gate` 위에 어떻게 얹히는지(`blocker`가 각 gated state마다
|
||||
`Gate`를 하나씩 만들어 자기 정책을 심는 형태?)를 같이 정해야 한다.
|
||||
3. **`Get()`과의 관계.** `Blocker`는 `:Get()`에 영향이 없고(`base/blocker-plan.md`),
|
||||
`Debounce`/`Throttle`도 emit-gate로 확정됐다(`base/debounce-throttle-plan.md` §4).
|
||||
`Gate`도 **값이 아니라 통지만 막는다**로 통일하는 게 맞는지 확인 필요 —
|
||||
맞다면 "게이트를 통과하지 않은 값도 `:Get()`으로는 보인다"가 공개 계약이 된다.
|
||||
4. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 —
|
||||
지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적,
|
||||
`debounce-throttle-plan.md` 5-4). `Gate` 자체에 `Flush`/`Cancel` 같은 표면을
|
||||
둘지, 그건 정책(Debounce)만의 것으로 둘지.
|
||||
5. **재진입.** `Blocker`의 "재진입 의도적 미지원"(`blocker-plan.md`)이 `Gate`
|
||||
레벨의 계약으로 올라가는지 — 즉 `onUpstreamEmit` 안에서 같은 게이트의
|
||||
`emit()`을 재귀적으로 부르는 경우.
|
||||
6. **⭐ 소비자가 하나 더 있다 — `Effect(fn, ...deps)`의 최초 1회 억제.**
|
||||
2026-08-21 5라운드 `C-6`에서 확정된 다중 의존성 `Effect`는, 의존성마다 구독을
|
||||
걸면 각 구독의 "등록 즉시 1회 실행"이 N번 발화하므로 **설치 구간 동안 발화를
|
||||
눌러뒀다가 마지막에 한 번만 실행**해야 한다(`base/effect-plan.md`의 그 절).
|
||||
즉 `Gate`(또는 `Blocker`의 직접 사용)가 **"설치 구간을 감싸 최초 발화를 한
|
||||
번으로 접는" 용례까지 커버해야** 한다 — 설계할 때 이 소비자를 같이 볼 것.
|
||||
7. **M2 범위.** M2에 `Gate`만 넣고 `Blocker`는 M3에 그대로 둘지, 아니면
|
||||
`Blocker`까지 같이 앞당길지. `Dispatch.drive`의 배치 등록이 실제로 쓰는 건
|
||||
`blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절), **최소한
|
||||
그 세 메서드가 도는 형태까지는 M2에 필요**하다.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- `base/blocker-plan.md` — 현행 `Blocker` 확정(이 문서가 일반화하려는 대상).
|
||||
- `base/debounce-throttle-plan.md` — "공개 `Blocker` API 위엔 못 얹음" 절이
|
||||
`Gate` 일반화를 처음 권고한 자리, 그리고 정책 쪽 설계 전량.
|
||||
- `base/dispatch-core-plan.md` — "배치 등록을 안전하게 만드는 Blocker 게이팅"
|
||||
절이 M2가 실제로 요구하는 표면.
|
||||
- `ROADMAP.md` M2/M3.
|
||||
153
.claude/research/state-epoch-validation.md
Normal file
153
.claude/research/state-epoch-validation.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# State 재계산 판정을 "소스 에포크" 비교로 바꾸는 안 (2026-08-21 신설)
|
||||
|
||||
**상태**: research — **사용자 제안, 에이전트 분석 완료, 세부 두 건은 2026-08-21에 추가 확정(중복 통지도 접음 / 선언 안 된 의존성 조항 기각). 채택 자체는 여전히 미정.**
|
||||
구현 전 QA 5라운드(`SS-2`/`SS-3`)에서 나왔고 사용자 스스로 *"더 생각해볼
|
||||
이야기라 백로깅이나 리서치에 들어가야할듯"*이라 함. 회신 원문은
|
||||
`qa-request/pre-implementation-qa-round5-response.md`.
|
||||
|
||||
**⚠️ `base/source-state-plan.md`가 여전히 정본이다** — 이 문서는 아무것도
|
||||
확정하지 않는다. 다만 **State 내부 표현을 바꾸는 제안이라 M3(=State 구현)
|
||||
착수 전에 결론이 나야 한다.**
|
||||
|
||||
## 1. 사용자가 지목한 문제 (실재함)
|
||||
|
||||
```
|
||||
A ──> B ──┐
|
||||
└──> C ──┴──> D
|
||||
```
|
||||
|
||||
`A:Set()` 한 번에 대해 지금 모델(push-invalidate + pull-recompute)에서:
|
||||
|
||||
1. `A`가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라 **`B` 쪽 가지가 먼저
|
||||
끝까지 내려간다** — `D`가 `B`를 통해 신호를 받고, 그 아래 Observer가 발화한다.
|
||||
2. 그 Observer가 `D:Get()`을 부른다. `D`는 상류를 `Get()`하는데, **`C`는 아직
|
||||
신호를 못 받아 `invalid`가 아니다** → `C`는 **옛 캐시를 그대로 반환**한다.
|
||||
3. `D`는 `(B_new, C_old)`라는 **섞인 값**을 계산해 캐시하고 `invalid`를 끈다.
|
||||
4. 뒤늦게 `C` 가지의 전파가 도착해 `D`가 다시 무효화되고, Observer가 또 울고,
|
||||
그때서야 `(B_new, C_new)`로 교정된다.
|
||||
|
||||
즉 **(a) 한 사이클 안에서 잘못된 값이 한 번 관측되고**(그 값으로 이미 프로퍼티가
|
||||
써지는 등 부작용이 나간다), **(b) 같은 계산이 두 번 돈다.** 리액티브 문헌에서
|
||||
말하는 전형적인 **glitch**이고, 지금 quad 문서 어디에도 이 현상이 서술돼 있지
|
||||
않다. `base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절은
|
||||
**중복 재계산이 없다**고만 말하는데, 그 논증은 *"누군가 `d:Get()`을 부르는
|
||||
시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에
|
||||
발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다.
|
||||
|
||||
## 2. 사용자 제안 (최종 정리형)
|
||||
|
||||
각 State가 **자기에게 영향을 주는 루트 `Source`들의 에포크**를 들고 있다가,
|
||||
`Get()` 때 그것과 실제 Source의 현재 에포크를 비교해 재계산 여부를 정한다.
|
||||
|
||||
```
|
||||
State
|
||||
sourceList : { [source (weak key)] : count } -- 상류에서 복사, :With에서 합침
|
||||
rawInvalid : boolean -- emit이 오면 그냥 true(값싼 캐시)
|
||||
invalid : rawInvalid가 true일 때만 sourceList를 훑어 "정말 계산이 필요한가" 판정
|
||||
```
|
||||
|
||||
- `emit`은 **발행한 source와 그 count를 같이 전달**한다(값은 여전히 안 싣는다).
|
||||
- `Source:Set()`/`:Emit()`은 자기 count를 증가시킨다.
|
||||
- 캐시를 채울 때 그 시점의 각 source count를 같이 기록해두고, `Get()` 때
|
||||
하나라도 다르면 재계산한다.
|
||||
- count가 싫다면 `[source] -> {}` 처럼 **유니크 테이블 identity**로 같은 판정이
|
||||
가능하다(사용자 대안).
|
||||
|
||||
## 3. 에이전트 분석 — 이게 실제로 무엇을 고치는가
|
||||
|
||||
**결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라
|
||||
`Get()`의 의미론을 바꾸는 결정이다.**
|
||||
|
||||
- **고쳐진다 — 섞인 값.** 위 3단계에서 `D`가 `C:Get()`을 부르면, `C`도 자기
|
||||
`sourceList`에서 `A`의 count가 자기가 기록한 것보다 앞선 걸 보고 **신호가
|
||||
아직 안 왔어도 스스로 재계산**한다. 그래서 `D`는 항상 `(B_new, C_new)`를
|
||||
얻는다. **`Get()`이 "지금 이 순간의 일관된 값"을 반환한다는 보장이 처음으로
|
||||
성립**한다.
|
||||
- **고쳐진다 — 중복 재계산.** 뒤늦게 `C` 쪽 신호가 도착해 `rawInvalid`가 켜져도,
|
||||
count 비교가 "이미 최신"이라 재계산이 안 일어난다.
|
||||
- **⭐ [2026-08-21 후속 확정] 중복 *통지*도 같이 접는다.** 처음엔 "값만
|
||||
고쳐지고 통지는 두 번 그대로"로 정리했는데, **사용자 판정으로 통지도
|
||||
같은 장치로 접기로 했다**: *"중복 통지는 단순히, count 목록을 순회해보고
|
||||
이미 모든 소스가 최신이면 무시하는게 맞아보인다. 특히 emit 은 자신 소스를
|
||||
주게 되므로, 자신 소스 카운트만 빠르게 비교가 쉽다. 우리에게 중요한 점은,
|
||||
앞단의 변경이 뒤로 흘렀냐일 뿐이라서 해당 방식을 택하는데 있어 문제가 없다."*
|
||||
- **판정은 O(1)이다** — emit이 `(source, count)`를 실어 오므로 **그 소스
|
||||
하나만** 비교하면 된다(전체 목록 순회가 아님).
|
||||
- **⚠️ 이건 2026-08-14에 뒤집힌 옛 dedup과 다른 장치다.** 그때 폐기된 건
|
||||
*"이미 `invalid`면 더 안 내려보낸다"*로, `:Get()`을 안 부르는 Observer가
|
||||
**한 번 울고 영구히 침묵**하는 실패 모드가 있었다
|
||||
(`archive/invalidate-dedup-propagation-reversed.md`). 에포크 비교는 그
|
||||
모드가 없다 — 매 `Set`마다 카운트가 **새 값**이라 항상 통과하고, 접히는 건
|
||||
**같은 에포크가 두 경로로 도착한 두 번째**뿐이다.
|
||||
- **그래서 노드가 기록해야 하는 카운트가 둘이다**: 전파 dedup용
|
||||
"이 소스의 이 에포크를 이미 내려보냈다"(`seen`)와, 캐시 검증용 "이 값을
|
||||
계산할 때의 에포크"(`computedAt`). 하나로 합치면 전파 시점에 갱신되는
|
||||
쪽이 캐시를 신선한 것으로 오인하게 만든다 — **구현 시 반드시 분리할 것.**
|
||||
- 비교는 `incoming <= seen[source]`면 삼킨다(단조 증가라 안전).
|
||||
- **선례가 있다** — 값 자체를 비교하는 게 아니라 **버전/에포크를 비교해
|
||||
lazy하게 검증**하는 건 MobX(global state version + observing 검사),
|
||||
Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준
|
||||
기법이다. Fusion처럼 **그래프를 위상정렬해 eager로 미는 방식**(quad가 이미
|
||||
기각한 것)의 대안으로 자주 쓰인다 — 즉 이 제안은 quad가 이미 택한
|
||||
pull 모델과 **결이 같다**.
|
||||
|
||||
## 4. 비용
|
||||
|
||||
사용자 추산(*"해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해
|
||||
인덱싱 하는 정도"*)에 동의한다. 덧붙일 것 둘:
|
||||
|
||||
- `sourceList`의 크기는 **그 노드 상류에 있는 서로 다른 루트 Source의 수**다.
|
||||
체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가
|
||||
큰 경우는 드물다.
|
||||
- `rawInvalid`가 false면 훑지도 않으므로, **정말 안 바뀐 흔한 경로는 지금과
|
||||
같은 비용**(플래그 하나)이다.
|
||||
|
||||
## 5. 열린 질문 (채택 전에 답이 필요)
|
||||
|
||||
1. **[해소, 2026-08-21] 선언 안 된 의존성 — 별도 UB 조항을 만들지 않는다.**
|
||||
에이전트가 "새 모델은 더 강한 약속을 하니 예외를 UB로 못 박아야 한다"고
|
||||
제안했으나 **사용자가 기각**: *"그건 아니다. 이 동작으로 인해 이제 정말로
|
||||
항상 state 는 get 이 최신을 던지는게 맞다. 상류의 상태를 물어보므로 그러함.
|
||||
이전과 다른게 없다고 생각한다."* — 선언 안 한 Source를 클로저로 읽는 건
|
||||
**지금 모델에서도 똑같이 stale**이고 이 변경이 악화시키는 게 없으므로,
|
||||
새 조항 없이 기존 "의존성은 선언한다"는 관례 그대로 둔다.
|
||||
2. **동적 의존성.** 조건에 따라 다른 상류를 읽는 계산이면 `sourceList`가
|
||||
보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다.
|
||||
그대로 감수할지 확인.
|
||||
3. **`Blocker`/`Gate`와의 상호작용.** 게이트는 **통지만** 막고 `Get()`엔 영향이
|
||||
없다는 게 확정 계약이므로(`base/blocker-plan.md`), 게이트 아래에서 `Get()`하면
|
||||
에포크 비교가 "갱신 필요"로 판정해 **막아둔 값이 그대로 보인다**. 지금도
|
||||
같은 동작이지만, 새 모델에선 그게 더 또렷해지므로 문서에 명시할 것.
|
||||
4. **`:Emit()`(값을 제자리에서 mutate하고 알리는 경로)** — count만 올리면 되므로
|
||||
그대로 동작한다. 확인만.
|
||||
5. **weak 키.** `[source] -> count`를 weak-key로 두면 source가 죽을 때 항목이
|
||||
사라진다. 다만 `base/relate-plan.md`가 경고하듯 **값이 키를 되참조하면 안
|
||||
된다** — count는 숫자라 문제없다(테이블 identity 방식을 택하면 그 테이블이
|
||||
source를 참조하지 않게 할 것).
|
||||
6. **`Get`의 계약 문구.** 사용자 지적(*"Get 이 항상 최신 상태를 가져온다라는
|
||||
말이 여기서 무력화되는 부분"*)의 방향은 사실 **반대**다 — 지금 모델이
|
||||
"최신"을 못 지키고 있었고, 이 제안이 그걸 지키게 만든다. 채택하면
|
||||
`base/source-state-plan.md`의 전파 모델 절을 그렇게 다시 써야 한다.
|
||||
|
||||
## 6. 곁가지 — 폴링용 sugar
|
||||
|
||||
사용자 제안: *"폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다.
|
||||
옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게
|
||||
Ref<T> 로 변환해주는 등"*. 즉 "항상 최신 값을 필드로 들고 있는 박스"를 만드는
|
||||
순수 슈가. **이건 이 문서의 결정과 독립**이고(에포크를 채택하든 안 하든 쓸 수
|
||||
있다), `research/operator-sugar-plan.md`의 콤비네이터 계열에 더 가깝다 —
|
||||
채택되면 그쪽으로 옮길 것.
|
||||
|
||||
## 7. 권고
|
||||
|
||||
- **채택 방향에 찬성.** "선제 최적화가 아니라 확정 동작으로의 승격"이라는
|
||||
사용자 판단에 동의한다 — 이건 성능이 아니라 **정확성** 결정이고, State
|
||||
내부 구조를 정하는 M3보다 **뒤에 하면 되돌리는 비용이 크다**.
|
||||
- **[2026-08-21] 에이전트가 붙였던 조건(선언 안 된 의존성을 UB로 명문화)은
|
||||
사용자 기각으로 빠졌다** — §5의 1번 참고.
|
||||
- **채택 시 같이 확정되는 것: 중복 통지도 접는다**(§3) — 그러면 다이아몬드에서
|
||||
값도 통지도 한 번씩만 간다. 대신 노드가 `seen`/`computedAt` 두 카운트를
|
||||
들어야 한다는 게 구현 요구사항으로 추가된다.
|
||||
- 실측은 `luau-test`에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서
|
||||
(a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면
|
||||
사라지는지 대조.
|
||||
|
|
@ -1653,3 +1653,35 @@ quad-제작 Instance는 **GC 폴백이 없다**는 걸 근거로 보존 주체
|
|||
분해** — "부모에게 미는 길이는 최종값"과 "부기가 물리보다 먼저"가 한 함수
|
||||
안에선 동시 만족 불가라는 진단이 근거였고, 사용자가 "지금 의사코드를
|
||||
건들이는 비용이 추후 실수가 누적되는 비용보다 싸다"로 확정.
|
||||
|
||||
## 2026-08-21-02 — QA 5라운드(문항지·회신·1차 처리) + `Gate`/에포크 리서치 신설
|
||||
|
||||
원문: `session/2026-08-21-02-qa-round5-and-gate-epoch-research.md`
|
||||
|
||||
4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설 —
|
||||
**4라운드에 문항이 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`,
|
||||
그리고 문서가 아닌 **실제 커밋된 M1 코드**), **그 이후 확정된 것**
|
||||
(`Detach`/`KeyGone`/`Owned`/`attachSlot` 분해), **큰 문서의 심화**로 범위를
|
||||
좁힌 205문항. 같은 세션에 회신까지 받아 14건 즉시 반영 — `slot._detached`
|
||||
lazy화, `KeyGone`엔 새 값 반환도 error, **`Owned=false`에서 `Detach`는
|
||||
`_detached`에 안 들어감**, 조상 파괴 시 unowned도 같이 죽는다는 계약 신설,
|
||||
`groupClaimKeys` 키 확정, `Tween<T>:Mapped` 확정, 4라운드가 빠뜨렸던 `E-10`
|
||||
실반영, **"게이팅 먼저"(M2로 앞당김)**. 새로 열린 두 갈래는 `research/`로 —
|
||||
공용 **`Gate`** 노드(emit을 가로채는 정책 노드, `Blocker`/`Debounce`가 그 위)와
|
||||
**State 에포크 검증**(DFS 전파 중 `Get()`이 섞인 값을 캐시하는 glitch를
|
||||
정확성 문제로 다룸). **2차 회신으로 되물은 6건도 같은 세션에 전부 확정** —
|
||||
`Slot:Replace` 신설(교체가 시프트 2회 → 0회), 물리 조작을 주입 op로
|
||||
(`mountInst`/`unmountInst`, base는 `Parent`를 모른다), `rawAdd` 의사코드 신설,
|
||||
`rawAdd`의 `Length:Set` 제거, **래핑/언래핑 한 쌍**(`wrapElement`/`unwrapElement`),
|
||||
**`setLength`에 `anchor` 인자**(4라운드 `D-56` 역전 → `isBoundAlive` 세 번째
|
||||
분기 항목까지 닫힘), `Effect(fn, ...deps)`(`Ref`도 의존성). **3·4차에선 `mountInst`가 삽입 위치를 못 받는다는 지적에서
|
||||
시작해 offset 부기 모델이 정리됨** — `None`의 뜻을 "발행 채널 없음"으로 좁히고
|
||||
`Dispatch.getOffsetAt` 신설(pull), 그 과정에 **중첩 offset이 부모 베이스를 못
|
||||
받던 결함**(depth ≥ 2에서 통째로 밀림)과 **재마운트가 `Offset` Source를 새로
|
||||
만들던 결함**(포탈이 깨지는 자리)까지 발견·수정. **커밋 전 감사 2라운드가 또 실질적인 걸 잡았다** — 확정한 `Owned`가
|
||||
`Slot:List` 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, `effect-plan.md`가
|
||||
역전 배너 없이 자기모순이던 것, 그리고 **손대지 않은 문서**(`ROADMAP` 백로그
|
||||
문단·`debounce-throttle-plan.md`)가 "Gate는 M3에서"로 남아 있던 사각지대.
|
||||
감사 회신 자리에서 **`raw*`의 index 통일**(오래 열린 캐비엇 종결), **래핑은
|
||||
`raw*` 바깥**, **`getOffsetAt` 접두합 캐시**까지 확정. **`Gate`만 사용자 지시로
|
||||
다음 세션 — M2를 막는 유일한 항목.**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
# 2026-08-21-02 — 구현 전 QA 5라운드(문항지 → 회신 → 1차 처리), `Gate`/에포크 리서치 신설
|
||||
|
||||
**요약**: 사용자 요청으로 **QA 5라운드 문항지(205문항)** 를 만들고, 같은
|
||||
세션에 회신을 받아 **1차 처리까지** 끝냈다. 즉시 반영 14건, 되물은 것 7건,
|
||||
그리고 **새 research 문서 둘**(`gate-primitive.md`,
|
||||
`state-epoch-validation.md`)이 나왔다. 처리 결과의 소스는
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`.
|
||||
|
||||
## 1. 5라운드가 왜 생겼나 — 4라운드 처리 때의 판단을 뒤집음
|
||||
|
||||
4라운드 종결 시점엔 사용자 지시(*"이후 stale 만 잡는것으로 끝낼 수
|
||||
있어보임"*)로 **5라운드를 안 만들기로** 했고 그 문장이 세 곳
|
||||
(`todos.md` 00번, `README.md` qa-request 행, 4라운드 followup H-7)에
|
||||
적혀 있었다. 같은 날 사용자가 5라운드를 요청 — *"4차에서 예로 넘어갔던건
|
||||
스킵하고, 새로운 부분들이나 다른 깊은 부분을 예가 나와야 정상인 질문들을
|
||||
쌓아보자."* → 세 곳 전부 정정하고 문항지를 신설했다.
|
||||
|
||||
**범위를 셋으로 좁힌 게 이번 라운드의 설계**: (1) 4라운드에 문항이 아예
|
||||
없던 영역(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가
|
||||
아니라 실제 커밋된 M1 코드**), (2) 4라운드 회신 **이후** 확정된 것
|
||||
(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해), (3) 큰 문서의
|
||||
심화(예: `debounce-throttle-plan.md`는 1100줄인데 4라운드 문항이 9개뿐이었음).
|
||||
|
||||
## 2. 문항지 작성 중 이미 잡힌 것
|
||||
|
||||
문항을 쓰는 과정 자체가 감사가 됐다:
|
||||
|
||||
- **`EF-3`** — 4라운드 followup이 "반영 완료"로 적은 `E-10`(dedup 대칭)이
|
||||
`effect-plan.md`에 **실제로는 안 들어가 있었다**. → 일반 교훈으로
|
||||
`CR-1`("followup 표를 신뢰 소스로 쓰면 안 된다, 소스는 `base/` 본문")을
|
||||
문항화했고, 사용자가 "예"로 확인해 이번에 실제로 반영했다.
|
||||
- **`DE-9`** — `KeyGone` 소멸 루프가 새 값을 반환받으면 `settle`의 교체
|
||||
분기로 들어가 `rawAdd(self, result, 0)`(범위 밖 인덱스)로 터진다.
|
||||
- **`IM-1`** — `architecture.md`는 "지금은 `New()` 미노출 싱글톤"이라는데
|
||||
실제 코드는 `module.New = New` + `return New()`.
|
||||
- **`CR-2`/`CR-9`** — `Tween:Map` vs `Mapped` 이름과 `isBoundAlive`의 세 번째
|
||||
분기가 **결정이 필요한데 어느 추적 목록에도 없었다.**
|
||||
- **`DC-9`** — Blocker 게이팅이 `recompute`를 O(N²)→O(N)으로 줄였는데
|
||||
`setOffsetSource`의 즉시 계산이 그 자체로 O(N²)라 상쇄된다.
|
||||
|
||||
## 3. 회신으로 확정된 것 (즉시 반영)
|
||||
|
||||
- **`slot._detached`는 lazy** — 모든 Slot이 빈 테이블을 미리 갖지 않는다
|
||||
(사용자: *"테이블 생성 비용을 모든 slot 이 가져야하나는 의문 … if 확인으로
|
||||
nil 이면 스킵이 훨씬 싸게 먹히지 않는가?"*). `getDetached(slot)` getOrCreate.
|
||||
- **`prev` 없이 `Detach`를 반환해도 nop** — 사용자가 prev 유무를 추적할
|
||||
의무 없음.
|
||||
- **`KeyGone`엔 `nil`/`None`/`Detach`만** — prev도 새 값도 error(*"KeyGone 을
|
||||
받은 요소는 오직 … 캐싱 이외의 새로운 마운트나 생성을 거부한다"*).
|
||||
- **⭐ `Owned = false`에서 `Detach`는 `_detached`에 안 들어간다** — 남의 것에
|
||||
소유권을 유지하는 건 모순이라 `rawUnmount`로 처리하고 다음 `prev`는 `nil`.
|
||||
부수로 unowned에선 `Detach`와 `nil`이 같은 동작이 되고 `_detachCleanup`의
|
||||
`_owned` 분기가 도달 불가가 되어 삭제됐다.
|
||||
- **조상이 죽으면 unowned 요소도 엔진 재귀 파괴로 같이 죽는다** — `Owned=false`가
|
||||
약속하는 건 "quad가 안 죽인다"뿐. 이 계약이 코퍼스에 없어서 신설.
|
||||
- **`groupClaimKeys` 키 = `(inst, groupValue) → k`**, `nameClaims`보다 위치
|
||||
claim이 먼저. `Frame { a, a }` 갭이 이걸로 닫힘.
|
||||
- **`Tween<T>:Mapped`** 확정(`-ed` 관례).
|
||||
- **⭐ "게이팅 먼저"** — M2가 M3의 `Blocker`에 의존하던 순서 문제를 로드맵
|
||||
순서 유지가 아니라 **앞당기는 쪽**으로 결정. 단 앞당기는 대상이 `Blocker`가
|
||||
아니라 그 아래 공용 `Gate` 노드로 바뀌었다(아래 4절).
|
||||
|
||||
## 4. 새로 열린 것 — research 문서 둘
|
||||
|
||||
- **`research/gate-primitive.md`** — `DT-4`에서 사용자가 정리: 시간 기반
|
||||
게이트를 공개 `Blocker` API 위에 못 얹는 이유가 **순서 보존**이다
|
||||
(*"이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이
|
||||
먼저이냐가 문제"*). 그래서 emit을 **가로채는** 공용 노드가 필요하고,
|
||||
공개 API로 낸다. 사용자 스케치는 `Gate(function(emit) return function()
|
||||
... end end)`. 남은 건 이름(`Gater`가 어색하다는 지적 — 에이전트 권고는
|
||||
`Gate` 그대로), `:Apply` 팩토리 여부, `Blocker`의 얹힘 방식, M2 범위.
|
||||
- **`research/state-epoch-validation.md`** — `SS-2`/`SS-3`에서 사용자가
|
||||
제기한 **glitch**: DFS 전파 중 Observer가 `Get()`을 부르면 아직 신호를 못
|
||||
받은 다른 가지의 옛 캐시가 섞여 들어간다. 제안은 각 State가 상류 루트
|
||||
Source들의 **에포크(count)** 를 들고 `Get()` 때 비교하는 것. 에이전트
|
||||
분석: 진단·방향 타당(MobX/Adapton류 선례), **정확성 결정이지 최적화가
|
||||
아님**, 다만 중복 *통지*는 안 고쳐지고 "선언 안 된 의존성"을 UB로 명문화
|
||||
해야 함. **M3(State 구현) 전에 결론 필요.**
|
||||
|
||||
## 5. 2차 회신 — C절 전량 확정 (같은 세션)
|
||||
|
||||
되물은 6건이 그 자리에서 전부 닫혔다(처리 전량은 followup **F절**):
|
||||
|
||||
- **`Slot:Replace` 신설**(`B-5`) — 사용자가 "교체는 제거+삽입이 아니라 replace가
|
||||
나아 보인다"고 제시. `:List`의 교체가 `spliceArraysDown`+`spliceArraysUp`
|
||||
쌍(시프트 2회·`recompute` 2회)에서 **`rawReplace` 한 번(시프트 0)** 으로
|
||||
바뀌었고, 그 부수로 `C-7`(`:Single` 교체 시 Length가 잠깐 줄었다 느는 것)이
|
||||
같이 사라졌다.
|
||||
- **에포크 안에서 중복 통지도 접기로**(`B-7`) — emit이 `(source, count)`를
|
||||
실어오므로 판정이 O(1). **2026-08-14에 폐기된 옛 dedup과 다른 장치**임을
|
||||
문서에 못 박았다(그건 `invalid` 플래그 기반이라 Observer 영구 침묵 모드가
|
||||
있었고, 에포크는 매 `Set`마다 새 값이라 그 모드가 없다). 구현 요구로
|
||||
**`seen`/`computedAt` 두 카운트 분리**가 추가됐다. 에이전트가 붙였던 "선언
|
||||
안 된 의존성을 UB로 명문화" 조건은 **사용자 기각**(*"이전과 다른게 없다"*).
|
||||
- **물리 조작은 주입 op**(`C-1`) — *"slot 의 해당 동작은 base 이므로 parent 를
|
||||
모른다"*. 의사코드 전체의 `element.Parent = ...`/`element:Destroy()`를
|
||||
`mountInst`/`unmountInst`/`disposeInst`로 정정(9곳)하고 경계 절을 신설.
|
||||
**`rawAdd` 의사코드도 이때 처음으로 문서에 들어갔다.**
|
||||
- **`rawAdd`의 `Length:Set` 제거**(`C-2`) — `Length`는 `recompute`만 쓴다.
|
||||
- **래핑/언래핑을 Slot 전체 연산으로**(`C-3`) — `wrapElement`/`unwrapElement`
|
||||
한 쌍 + 래퍼의 `_wrapped` 역참조. 사용자가 예상한 대로 `wrappers[key]` /
|
||||
`mounted[key]` 분리가 **필요 없어졌다**.
|
||||
- **`setLength`에 `anchor` 인자 신설**(`C-4`) — 부기 키와 생명주기 앵커 분리.
|
||||
4라운드 `D-56`이 역전돼 `archive/bindlifetime-slot-owner-reversed.md`로 갔고,
|
||||
형태 미정으로 열려 있던 **`isBoundAlive` 세 번째 분기 항목이 같이 닫혔다.**
|
||||
- **`Effect(fn, ...deps)` 확정**(`C-6`) — `Ref`도 의존성이 될 수 있고, 최소
|
||||
1회 실행(useEffect 동일), trailing lazy 위치 인자, `Ref`는 `Set`될 때만 발화.
|
||||
- **`Gate`만 다음 세션으로** — *"고칠것이 많으므로 … 지금 세션 상 지식만
|
||||
이전될 수 있게 두세요."* 그래서 `research/gate-primitive.md`는 재료만 모아둔
|
||||
상태로 두고 `question.md`/`todos.md`에 **M2를 막는 유일한 항목**으로 남겼다.
|
||||
|
||||
## 6. (1차 시점 기록) 되물었던 것
|
||||
|
||||
파급 큰 순서로: **`rawAdd`의 `Length:Set` 제거**(지금 서술이 "기여도 합"
|
||||
정의와 충돌하고 `recompute`와 이중 기록), **`updateFn`이 State를 반환할 때의
|
||||
래핑/`prev` identity**(래핑이 공개 `Slot:Add`에만 있어 reconcile 경로엔
|
||||
없다는 실제 갭), **`setLength`의 Observer 앵커를 물리 target으로 되돌리기**
|
||||
(사용자 문제 제기 — *"우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지"*,
|
||||
되돌리면 4라운드 `D-56`의 백엔드 요구사항과 `isBoundAlive` 세 번째 분기가
|
||||
통째로 불필요해짐), `rawAdd` 의사코드 초안 승인(문서에 정의가 아예 없었음),
|
||||
`Gate` 이름·표면, `Effect`의 다중 의존성(`Ref` 포함) 안.
|
||||
|
||||
## 6-1. 3·4차 — `mountInst`의 삽입 위치에서 시작해 offset 모델이 정리됨
|
||||
|
||||
2차까지 끝난 뒤 사용자가 `mountInst`/`unmountInst`가 index를 안 받는 걸 문제
|
||||
삼았다(*"웹에서는 어떻게 되냐가 모호함. 어디 둘지 어떻게 아느냐는것"*). 이
|
||||
질문이 결국 offset 부기 모델 전체를 정리하게 만들었다:
|
||||
|
||||
- **`None`이 두 뜻을 겸하고 있었다** — 정의는 "실제 마운트를 하지 않는 위치"인데
|
||||
**plain 요소가 `None` + `setLength(1)`로 등록**되고 있었다. 그래서 그 자리의
|
||||
offset 숫자가 계산조차 안 됐고, DOM류 백엔드가 삽입 위치를 알 방법이 없었다.
|
||||
→ `None`의 뜻을 **"발행 채널 없음"**으로 좁히고(참여 여부는 `lengthList`가 답),
|
||||
숫자가 필요한 쪽은 **`Dispatch.getOffsetAt(ownerKey, i)`로 pull**한다.
|
||||
- **⭐⭐ 그 자리를 파다 별개 결함 발견 — 중첩 offset이 부모 베이스를 못 받았다.**
|
||||
`recompute`가 `sum = 0`으로 시작하고 `ownerKey.Offset`을 읽는 자리가 없어서,
|
||||
**depth ≥ 2에서 자식 offset이 부모 베이스만큼 통째로 밀려 있었다**(depth 1만
|
||||
쓰던 동안 로컬==절대라 안 드러남). → `base`를 시드하고, 중첩 Slot이 자기
|
||||
`Offset`을 관측해 자식 offset을 다시 미는 구독을 추가.
|
||||
- **⭐ 반영하다 하나 더 — 재마운트가 `Offset` Source를 새로 만들고 있었다.**
|
||||
언마운트가 `slot.Offset`을 일부러 보존하는 이유(`SL-75`/`DC-6`: 이미 렌더된
|
||||
요소들이 그 Source를 **구독한 채 딸려 나감**)가 재마운트에서 무너지고 있었음
|
||||
→ `slot.Offset or Source(0)`으로 identity 재사용.
|
||||
- **에이전트 제안(`bk.offsetList` push)은 사용자안(pull + `getOffsetAt`)에
|
||||
밀려 폐기**됐고, 이어서 **`bk.base` 필드도 사용자 지적으로 걷어냈다**
|
||||
(*"이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데"* — 같은 값을 두 곳에 두는
|
||||
중복 상태). `isSlot` 분기는 남는데, 그건 타입 분기가 아니라 **duck-typing이
|
||||
금지돼 있어서**(Roblox userdata의 미정의 키 인덱싱 에러, `brand-plan.md`)
|
||||
브랜드 검사가 유일한 안전 경로이기 때문.
|
||||
|
||||
**이 라운드의 패턴**: 세 결함 전부 *"사용자가 표면적인 질문 하나를 던졌더니 그
|
||||
아래에서 나왔다"* — `mountInst`의 인자 하나가 offset 모델의 두 결함을 끌어냈다.
|
||||
|
||||
## 6-2. 커밋 전 감사 2라운드 — 실제 결함 다수
|
||||
|
||||
핸드오버 준비로 `quad-doc-auditor`를 각도를 바꿔 두 번 돌렸다(1: diff와 그걸
|
||||
인용하는 base / 2: 인덱스 레이어·히스토리). **둘 다 실질적인 걸 잡았다.**
|
||||
|
||||
- **⭐ `Owned`가 코드에 도달하지 못하고 있었다** — `settle`/`destroySlotTree`/
|
||||
`releaseElement`가 `self._owned`를 9곳 넘게 읽는데 `Slot:List`/`Slot:Single`
|
||||
의사코드가 **`opts` 인자를 안 받고 있었다.** 확정한 옵션이 **문서 안에서
|
||||
배선이 끊긴 채** 있었던 셈.
|
||||
- **⭐ `effect-plan.md`가 자기 자신과 모순** — 최상단 시그니처와 "trailing args
|
||||
sugar는 의도적으로 안 만듦" 확정 문단이 그대로인 채 `Effect(fn, ...deps)`
|
||||
절이 추가돼 **역전 배너 없이 정반대 두 서술이 공존**하고 있었다.
|
||||
- **⭐ 손대지 않은 문서의 사각지대** — `ROADMAP.md` 백로그 문단과
|
||||
`debounce-throttle-plan.md`가 "Gate 추출은 M3에서"라고 여전히 말하고 있었다
|
||||
(이번에 M2로 앞당겨졌는데 diff에 안 잡히는 파일이라 그대로 남음). 이건
|
||||
`conventions.md`가 경고하는 "변경한 세션 자신은 자기가 뭘 안 건드렸는지
|
||||
모른다"의 교과서적 사례.
|
||||
- **⭐ 인덱스에 옛 결론이 남음** — State-에포크의 "중복 통지는 안 고쳐진다 /
|
||||
UB 명문화 필요"가 같은 날 정반대로 확정됐는데 `question.md`/`README.md`만
|
||||
안 따라왔다.
|
||||
- 그 외: `indexOfRaw` 미정의, `mountSlotTree`의 전제 미명시, `Replace`의
|
||||
`destroyOld` 미서술, `Gate` 이름이 용어 대기열에 없음, 주입 op 목록 미갱신,
|
||||
M2 체크박스 문장 깨짐 등.
|
||||
|
||||
**그리고 감사 회신 자리에서 결정 셋이 더 확정됐다** — `raw*`를 **index로 통일**
|
||||
(오래 열려 있던 캐비엇 종결), **래핑은 `raw*` 바깥**, 그리고 `getOffsetAt`의
|
||||
**접두합 캐시**(`offsetDirtyFrom`). offset 물리 재배치 질문도 "DOM은 insert가
|
||||
알아서 밀어낸다"로 닫혔다.
|
||||
|
||||
## 7. 남긴 파일
|
||||
|
||||
- `qa-request/pre-implementation-qa-round5.md` — 문항지(205문항)
|
||||
- `qa-request/pre-implementation-qa-round5-response.md` — 회신 원문
|
||||
- `qa-request/pre-implementation-qa-round5-followup.md` — **처리 결과의 소스**
|
||||
(A~E절 1차, **F절이 최신**)
|
||||
- `archive/bindlifetime-slot-owner-reversed.md` — 역전된 `D-56` 원문
|
||||
- `research/gate-primitive.md`, `research/state-epoch-validation.md`
|
||||
|
|
@ -5,8 +5,10 @@
|
|||
(`.claude/question.md`, `luau-test/STATUS.md` 등).
|
||||
|
||||
|
||||
00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~4라운드 전부 `base/`
|
||||
반영 완료로 종결. 열린 항목 없음.**
|
||||
00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~5라운드 전부 `base/`
|
||||
반영 완료. 열린 항목은 **`Gate`(공용 게이트 노드) 하나뿐**이고, 그게 지금
|
||||
**M2 착수를 막는 유일한 항목**이다**(사용자 지시로 설계는 다음 세션 —
|
||||
재료는 `research/gate-primitive.md`).
|
||||
|
||||
**4라운드 — [2026-08-21] 종결.** 문항지는
|
||||
`.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은
|
||||
|
|
@ -16,8 +18,31 @@
|
|||
**`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해**
|
||||
(`materializeSlotTree` + `mountSlotTree`, 근거는
|
||||
`research/slot-attach-decomposition.md`)가 전부 확정·반영됐다.
|
||||
**5라운드 문항지는 만들지 않는다** — 사용자 지시("이후 stale 만 잡는것으로
|
||||
끝낼 수 있어보임"). 아래는 그 회신 전 서술:
|
||||
**[2026-08-21 정정] 5라운드 문항지를 만들었다** — 4라운드 처리 때는 사용자
|
||||
지시("이후 stale 만 잡는것으로 끝낼 수 있어보임")로 안 만들기로 했으나, 같은
|
||||
날 사용자가 5라운드를 요청("4차에서 예로 넘어갔던건 스킵하고, 새로운
|
||||
부분들이나 다른 깊은 부분")해 `qa-request/pre-implementation-qa-round5.md`를
|
||||
신설했다(**회신 대기**). 범위는 셋 — (1) 4라운드에 문항이 아예 없던 영역
|
||||
(`base/project-setup-plan.md`/`base/quad-types-plan.md`, 그리고 문서가 아니라
|
||||
**실제 커밋된 M1 코드**), (2) 4라운드 회신 이후 새로 확정된 것
|
||||
(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해), (3) 큰 문서의 심화.
|
||||
문항 수와 분포는 그 문서 자신이 소스.
|
||||
**[2026-08-21 후속] 5라운드 회신 도착·처리 완료**(라운드 수는 여기서 안 셈) — 회신 원문은
|
||||
`-round5-response.md`, 처리 결과의 소스는 **`-round5-followup.md`**.
|
||||
즉시 반영된 것은 `slot._detached` lazy화, `KeyGone`에 새 값 반환도 error,
|
||||
**`Owned = false`에서 `Detach`는 `_detached`에 안 들어감**, 조상 파괴 시
|
||||
unowned 요소도 같이 죽는다는 계약 신설, `groupClaimKeys` 키 확정
|
||||
(`(inst, groupValue) → k`), `Tween<T>:Mapped` 이름 확정, 4라운드가 반영을
|
||||
빠뜨렸던 `E-10` dedup 대칭 결론 실반영, 그리고 **"게이팅 먼저"(M2로 앞당김)**.
|
||||
**아직 회신 대기인 것은 그 followup의 C절**(`rawAdd` 의사코드 승인,
|
||||
`rawAdd`의 `Length:Set` 제거, `updateFn`이 State를 반환할 때의 래핑/`prev`,
|
||||
`setLength` 앵커를 물리 target으로 되돌리기, `Gate` 이름·표면, `Effect`
|
||||
다중 의존성) — **[2026-08-21 2차 회신으로 전량 처리 완료]**, 소스는 그
|
||||
파일의 **F절**. 그중 **`Gate`(공용 게이트 노드) 설계만 사용자 지시로 다음
|
||||
세션으로 미뤄졌다**(*"고칠것이 많으므로 Gate 는 다음 세션에 다루겠음 …
|
||||
지금 세션 상 지식만 이전될 수 있게 두세요"*) — 재료는
|
||||
`research/gate-primitive.md`에 모아뒀고 **M2 착수를 막는 유일한 항목**이다.
|
||||
아래는 4라운드 회신 전 서술:
|
||||
|
||||
**(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을
|
||||
계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로
|
||||
|
|
|
|||
64
ROADMAP.md
64
ROADMAP.md
|
|
@ -207,9 +207,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M3 체크박스
|
||||
참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라
|
||||
이 게이트를 그대로 탐
|
||||
- [ ] `Dispatch.setLength(inst,i,len:number|State<number>)`/
|
||||
`Dispatch.setOffsetSource(inst,i,offset:Source<number>|None)` —
|
||||
array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브
|
||||
- [ ] `Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?)`/
|
||||
`Dispatch.setOffsetSource(ownerKey,i,offset:Source<number>|None)`/
|
||||
**`Dispatch.getOffsetAt(ownerKey,i)`** —
|
||||
**[2026-08-21 구현 전 QA 5라운드 반영]** `setLength`의 4번째 인자
|
||||
`anchor`(생명주기 앵커, 항상 물리 Instance — 부기 키와 분리, **생략 시
|
||||
`ownerKey`로 폴백**이라 최상위 호출부는 3-인자 그대로),
|
||||
`setOffsetSource`는 `None`이면 얼리 리턴(그 `None`은 "발행 채널 없음"이지
|
||||
"참여 안 함"이 아니다 — 참여 여부는 `setLength`가 답),
|
||||
숫자가 필요한 쪽(예: 물리 삽입 위치)은 `getOffsetAt`으로 pull.
|
||||
`recompute`는 owner의 베이스(Slot이면 자기 `.Offset`, 최상위면 0)에서
|
||||
시작하고 중첩 Slot은 자기 `Offset`을 관측해 자식 offset을 다시 민다.
|
||||
**이 셋이 하는 일**: array part 형제 순서 보장(Length/Offset 누적합→
|
||||
`LayoutOrder` 리액티브
|
||||
바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략
|
||||
UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime
|
||||
경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님
|
||||
|
|
@ -227,11 +237,20 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만
|
||||
호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에
|
||||
있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는
|
||||
`Blocker`를 참조하게 됨.** M2 착수 전 `Blocker`의 최소 표면
|
||||
(`On`/`Off`/`IsOn`/`OffWithoutEmit`)을 M3보다 먼저(또는 M2와 병행)
|
||||
만들 필요가 있는지 사용자 판단 필요 —
|
||||
`Blocker`를 참조하게 됨.**
|
||||
**✅ [해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] "게이팅 먼저"로
|
||||
결정 — 게이팅을 M2로 앞당긴다**(사용자: *"게이팅 먼저. 게이팅을
|
||||
base 에 만들 준비를 해야한다. 실질적 모양 정의가 필요함"*). 단
|
||||
**앞당기는 대상이 `Blocker` 자체가 아니라 그 아래의 공용 `Gate`
|
||||
노드**로 바뀌었다(같은 라운드 `DT-4` — 시간 기반 게이트는 공개
|
||||
`Blocker` API 위에 못 얹히므로, emit을 가로채는 노드를 한 겹
|
||||
일반화해 `Blocker`/`Debounce`/`Throttle`이 그 위의 정책이 되게
|
||||
한다). **표면/이름은 아직 미정** — `research/gate-primitive.md`가
|
||||
소스이고 `question.md` 3번에 열린 항목으로 올라가 있다. 최소한
|
||||
배치 등록이 실제로 쓰는 `On`/`IsOn`/`OffWithoutEmit`이 도는
|
||||
형태까지는 M2에 필요하다. 경위는
|
||||
`qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤
|
||||
정합성" 절.
|
||||
정합성" 절과 `pre-implementation-qa-round5-followup.md`.
|
||||
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
|
||||
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
|
||||
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
|
||||
|
|
@ -321,6 +340,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`luau-test`의 `15-type-compute-trailing-deps-typepack.luau`로
|
||||
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
|
||||
되면 동종 타입 dep 1개로 한정)
|
||||
- [ ] **[2026-08-21 5라운드 — 순서 주의]** 아래 `Blocker.luau`가 올라서는
|
||||
**공용 게이트 노드는 M2에서 이미 만들어져 있다**("게이팅 먼저" 결정,
|
||||
위 M2 각주 + `research/gate-primitive.md`). M3에서 `Blocker`를 짤 때는
|
||||
그 노드를 **다시 만들지 말고 그 위의 정책으로** 얹을 것.
|
||||
- [ ] **[2026-08-21 5라운드 — 착수 전 확인]** State 재계산 판정을 "소스
|
||||
에포크 비교"로 바꿀지가 **미정인 채 열려 있다**(`research/state-epoch-validation.md`,
|
||||
`question.md` 3번). 채택하면 **State 내부 표현이 바뀌므로**(노드가
|
||||
`seen`/`computedAt` 두 카운트를 들고, `Get`이 상류 에포크를 검증)
|
||||
아래 `Source.luau`/`State.luau`를 짜기 **전에** 결론이 나야 한다 —
|
||||
그 문서 자신이 "M3 뒤로 미루면 되돌리는 비용이 크다"고 경고한다.
|
||||
- [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를
|
||||
한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive,
|
||||
State와 밀접히 연관돼 있어 같은 마일스톤에서 개발)
|
||||
|
|
@ -497,7 +526,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔
|
||||
교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개
|
||||
mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/
|
||||
`IndexOf`는 순수 읽기라 가드 대상 아님). base/roblox 경계에
|
||||
`IndexOf`는 순수 읽기라 가드 대상 아님).
|
||||
**[2026-08-21 5라운드]** `Replace(index, newElement)` 추가(그 자리 교체
|
||||
+ 이전 것 **파괴** — `Extract`의 파괴 짝, `Remove` ↔ `Extract`와 같은 축)와
|
||||
`rawReplace`/`rawAdd` 의사코드 확정, 그리고 **래핑/언래핑 한 쌍**
|
||||
(`State`를 요소로 받으면 내부적으로 `:Single` 래퍼 Slot이 되는데,
|
||||
`Get`/`IndexOf`/`Extract`가 돌려주는 값과 `:List`의 `prev`는 전부
|
||||
**언래핑된 원래 값**이다). base/roblox 경계에
|
||||
mount/unmount 외 reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소
|
||||
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
|
||||
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
|
||||
|
|
@ -598,6 +633,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/
|
||||
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
|
||||
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
|
||||
**[2026-08-21 5라운드]** 그 web 경로가 실제로 삽입 위치를 알 수 있도록
|
||||
물리 조작이 주입 op(`mountInst(target, element, index)`/`unmountInst`/
|
||||
`disposeInst`)로 정리됐고, 중첩 offset이 부모 베이스를 못 받던 결함과
|
||||
재마운트가 `Offset` Source를 새로 만들던 결함도 같이 수정됐다.
|
||||
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
|
||||
해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).**
|
||||
**[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** —
|
||||
|
|
@ -970,9 +1009,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
시간 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`)
|
||||
— 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을
|
||||
추가하지 않는 **순수 슈가**로 확인됨(`Blocker`의 gated state + `Ref` +
|
||||
아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **M3에서 `Blocker`를
|
||||
구현할 때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할
|
||||
것**(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함).
|
||||
아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **[정정, 2026-08-21 5라운드]
|
||||
그 공용 `Gate` 추출은 M3가 아니라 M2로 앞당겨졌다** — "게이팅 먼저"
|
||||
결정(위 M2 각주)으로 `Dispatch.drive`의 배치 등록이 쓰는 게이팅부터
|
||||
만들기로 했고, 그때 `Blocker`/`Debounce`/`Throttle`이 공유할 노드를
|
||||
같이 빼둔다(따로 하면 같은 설계를 두 번 함). 표면/이름은 아직 미정 —
|
||||
`research/gate-primitive.md`.
|
||||
프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음.
|
||||
주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`,
|
||||
Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라
|
||||
|
|
|
|||
Loading…
Reference in a new issue