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:
qwreey 2026-08-21 18:19:58 +09:00
parent d5e5e1a1b9
commit c6fdf1b348
Signed by: qwreey
GPG key ID: D28DB79297A214BD
22 changed files with 3708 additions and 194 deletions

View file

@ -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" 절 |

View 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라고 가정하면 안
된다"는 요구사항 자체**뿐.

View file

@ -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`)

View file

@ -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`)
— 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹

View file

@ -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` 함수)는 그 위에 아무
때나 나중에 얹으면 된다.

View file

@ -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` 절

View file

@ -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 여섯 번째 세션, 이전 미해결 절 대체)
**과거 미해결이었던 두 질문 모두 확정**:

View file

@ -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`와 맺는 계약은 정확히 둘**(이 둘이 위 구현의 전부):

View file

@ -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`),

View file

@ -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 로 확정"*).
## 네임스페이스드 객체 (더 이상 유효한 관심사 아님)

View file

@ -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`).

View file

@ -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`.

View 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`의 전체 순회가 그 캐시를 같이 채우게 할지.

View 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 에서 무언가 마운트 된 요소를 뽑아낼 때, 부모가 죽었다면 이미 죽은 요소가 된다. 이건 의도 동작인데, 언급이 되어있나 모르겠음.

File diff suppressed because it is too large Load diff

View file

@ -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)은 사용자가

View 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.

View 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) 에포크 비교를 넣으면
사라지는지 대조.

View file

@ -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를 막는 유일한 항목.**

View file

@ -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`

View file

@ -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라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을
계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로

View file

@ -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`로 배선 — **인자 순서가 반대라