From eb321e1747948853d949480b546aee49f3f30c0e Mon Sep 17 00:00:00 2001 From: qwreey Date: Tue, 25 Aug 2026 23:40:19 +0900 Subject: [PATCH] =?UTF-8?q?qa:=207=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EB=B0=9C?= =?UTF-8?q?=EA=B2=AC=2052=EA=B1=B4=20=EC=B2=98=EB=A6=AC=C2=B7=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20Store=20=EC=9E=AC=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=EC=99=80=20=EC=B2=A0=ED=9A=8C,=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=ED=95=A8=EC=88=98=20=EC=9B=90=EC=B9=99=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `qa-request/pre-implementation-handtrace-round7.md`의 `H-55`~`H-106`을 사용자와 대화형으로 처리하고 `base/`에 전량 반영했다. 결정의 소스는 `-followup.md`(개수·개별 항목은 여기서 세지 않는다). ## 처분 확정 39 / 무효·소멸 4(`H-73`~`H-76`) / 기각 1(`H-77`) / 범위 축소 2 / 다른 항목으로 흡수 6. **부수로 `question.md` 최우선 절이 비었다** — 중간 State GC는 `_hold` 불변식(하류 → 상류 강함)으로, 동적 키 표면 위치는 `store:Of<>(name)` 하나로 닫혔다. **M2 착수를 막는 항목이 없다.** ## 구조가 바뀐 것 - `Ref`가 `Epoch`를 만족(`.Revision` + `EpochBrand`) — 포탈 캐치업 비대칭과 같은 `Ref` 중복 dep이 같이 닫힘 - `Weak*` 등록 표면 신설(`Ref:WeakCallback` / `Observer:WeakSubscribe`) — Weak 쪽이 프리미티브고 강한 쪽이 "GC 킵"을 얹은 것 - `Effect`: dep 등록이 생성자 한 곳으로, 강한 주인은 `_deps` 하나, 억제는 사적 `Blocker`, `bindLifetime`은 핸들 하나에만 적용. `:Rerun()` 정의 신설(재진입은 지연 재실행), `_installed` 신설 - 전파 루프 의사코드 확정 — 구독자 집합의 원소는 Observer **값**이고 **자식 State 노드는 `canExecute`를 안 탄다**(그대로 짜면 파생 State 아래가 전부 침묵했다) - `rawInvalid` → `cacheTargetCount`/`cacheCurrCount` 카운터 쌍 - `recompute` 재진입 차단 + `invalidAfter` 되감기, `gatedRecompute`가 인덱스를 캡처하지 않고 토큰으로 조회 - `emit(commit) -> boolean`, `EpochMap:Peek` — 정책이 흡수 집합을 버리지도 읽지도 못하던 것을 닫음 - error 계약(`level` 이분, 메시지는 영어)과 예외 계약(`pcall`로 안 감쌈)을 `architecture.md`에 신설 ## Store — 같은 날 재설계하고 철회했다 `H-75`/`H-76`으로 `WrapStore`/`ProcessStoreType`이 폐기되자 그 자리를 "`store.key`는 값, `store:Of(k)`가 프리미티브"로 채웠다가 **같은 날 철회**했다(`archive/store-value-field-redesign-withdrawn.md`). 살아남은 건 **명시적 초기화** 하나다. 최종형은 타입 인자에 `Source`를 직접 쓰고 `store.key`는 평범한 레코드 필드이며 **타입 함수를 안 쓴다**. 철회 이유 중 하나가 원칙으로 승격됐다 — **"타입 함수는 타입이 못 잡는 문제를 에러로 띄우는 정도 이상으로 가지 않는다"**(`typing-limits.md` §0). `index<>`/`keyof<>`도 Luau가 predefine한 타입 함수라 같은 함정을 갖는다. ## 툴체인 **`luau` CLI가 심볼릭 링크를 못 탄다**(디렉토리·파일 둘 다)는 것이 최소 재현으로 밝혀졌다 — pesde 워크스페이스 링크가 전부 심볼릭이라 스모크 2개가 안 돌았고 `luau-analyze`는 **조용히 통과**했다("거짓 클린"). `scripts/relink.sh` + `scripts/test.sh` 신설, 이제 스모크 셋 전부 PASS. ## 검증 `/code-review high` 2회(12건 + 14건)와 감사 8패스(12/6/6/4/13/3/6/0건)를 각도를 바꿔가며 돌렸고 전부 반영했다 — 마지막 패스가 무발견으로 수렴. 각 패스의 각도와 대표 발견은 `-followup.md`의 검증 절이 소스. `doc-check.py` ERROR 0. Co-authored-by: qwreey Claude-Session: https://claude.ai/code/session_012oLwATeQdq9TCFdENPutFG --- .claude/README.md | 13 +- .../store-value-field-redesign-withdrawn.md | 355 +++++++ .../audit/luau-test-first-run-2026-08-13.md | 2 +- .../REPORT.md | 2 +- .../audit/type-store-index-keyof/REPORT.md | 201 ++++ .../spikes/01-exploration.luau | 155 +++ .../spikes/02-toplevel-form.luau | 31 + .../spikes/03-value-index-form.luau | 34 + .../spikes/04-reserved-key-silent.luau | 14 + .../spikes/05-checkreserved.luau | 36 + .../spikes/06-generic-instantiation.luau | 15 + .../spikes/07-self-generic-inference.luau | 31 + .../spikes/08-canonical-decl-annotation.luau | 32 + .claude/base/architecture.md | 73 +- .claude/base/attribute-plan.md | 17 + .claude/base/bind-system-plan.md | 8 +- .claude/base/blocker-plan.md | 36 +- .claude/base/component-composition-plan.md | 36 +- .claude/base/debounce-throttle-plan.md | 39 +- .claude/base/dispatch-core-plan.md | 128 ++- .claude/base/effect-plan.md | 402 ++++++-- .claude/base/gate-plan.md | 146 ++- .claude/base/lifecycle-pattern.md | 62 +- .claude/base/modifier-plan.md | 2 +- .claude/base/quad-types-plan.md | 5 + .claude/base/ref-plan.md | 84 +- .claude/base/relate-plan.md | 61 +- .claude/base/slot-plan.md | 58 +- .claude/base/source-state-plan.md | 232 ++++- .claude/base/state-epoch-plan.md | 105 +- .claude/base/store-plan.md | 346 ++++--- .claude/base/typing-limits.md | 87 +- .claude/luau-test/README.md | 4 +- .claude/luau-test/STATUS.md | 31 +- .../16-type-store-key-typefunction.luau | 0 ...21-type-store-undeclared-key-rejected.luau | 0 .claude/project-context.md | 17 +- .../pre-implementation-handtrace-round6.md | 4 +- ...mplementation-handtrace-round7-followup.md | 899 ++++++++++++++++++ .../pre-implementation-handtrace-round7.md | 10 +- .../pre-implementation-qa-round1.md | 2 +- .../pre-implementation-qa-round4-followup.md | 2 +- .claude/question.md | 49 +- .claude/research/documentation-content-map.md | 2 +- .claude/research/pre-implementation-audit.md | 11 +- .claude/research/v1-compat-plan.md | 8 +- .claude/todos.md | 43 +- .gitignore | 1 + CLAUDE.md | 7 +- ROADMAP.md | 241 ++++- scripts/relink.sh | 91 ++ scripts/test.sh | 17 + 52 files changed, 3837 insertions(+), 450 deletions(-) create mode 100644 .claude/archive/store-value-field-redesign-withdrawn.md create mode 100644 .claude/audit/type-store-index-keyof/REPORT.md create mode 100644 .claude/audit/type-store-index-keyof/spikes/01-exploration.luau create mode 100644 .claude/audit/type-store-index-keyof/spikes/02-toplevel-form.luau create mode 100644 .claude/audit/type-store-index-keyof/spikes/03-value-index-form.luau create mode 100644 .claude/audit/type-store-index-keyof/spikes/04-reserved-key-silent.luau create mode 100644 .claude/audit/type-store-index-keyof/spikes/05-checkreserved.luau create mode 100644 .claude/audit/type-store-index-keyof/spikes/06-generic-instantiation.luau create mode 100644 .claude/audit/type-store-index-keyof/spikes/07-self-generic-inference.luau create mode 100644 .claude/audit/type-store-index-keyof/spikes/08-canonical-decl-annotation.luau rename .claude/luau-test/{done => rewrite-required}/16-type-store-key-typefunction.luau (100%) rename .claude/luau-test/{done => rewrite-required}/21-type-store-undeclared-key-rejected.luau (100%) create mode 100644 .claude/qa-request/pre-implementation-handtrace-round7-followup.md create mode 100755 scripts/relink.sh create mode 100755 scripts/test.sh diff --git a/.claude/README.md b/.claude/README.md index 01e07ee..2bd8c1b 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,11 +24,11 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음. **[2026-08-21 확장] 확정된 결정의 "왜 그렇게 정했나" 근거 기록도 여기 둔다** — `research/`(아직 상의 필요)도 `archive/`(뒤집혔거나 기각됨)도 아니고, `base/`가 근거로 인용하는 온디맨드 자료라는 이 폴더의 기준에 정확히 맞기 때문(`slot-attach-decomposition.md`/`epoch-brand-composition.md`가 그렇게 들어옴) | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 최우선 절과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M3가 M2의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영 — **[2026-08-24]** 그때 열어뒀던 마일스톤 재편은 M2/M3 순서 교체로 닫혔다). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M2~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것. **⭐ [2026-08-24] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round6-followup.md`(**[2026-08-24 신설] 6라운드의 결정과 근거가 여기 소스다** — `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정한 기록이고, 반영 후 `/code-review high`가 잡은 7건(그중 셋이 이번 반영이 만든 회귀)도 D절에 있다. 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`). `pre-implementation-handtrace-round7.md`(**[2026-08-25 신설, 회신 대기]** 7라운드: 6라운드와 같은 손 트레이싱이되 범위가 **M2(반응형 코어)와 M2→M3 경계**다. 패스 6개가 각기 다른 각도를 쓴다 — 1차는 프리미티브 사이의 *호출 순서*를 시간축으로 겹쳐 보기, 2차는 문서가 "확인했다"고 적은 런타임/타입 주장을 실제로 `luau`/`luau-analyze`에 걸어보기, 3차는 **커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려보기** + 1·2차가 뺐던 "M2를 *소비하는* 문서", 4차는 **M2 코어를 문서 그대로 옮긴 참조 구현을 돌려보기** + 아무 문서도 안 정한 **예외 경로**, 5차는 **확정된 M2 표면을 실제 Luau 타입으로 선언해 `luau-analyze`에 걸어보기**(확정 시그니처가 확정 관용구를 통과시키는가), 6차는 **그 참조 구현을 M2→M3 경계(Length/Offset 부기·Dispatch 체인)까지 이어 붙여 돌려보기** + **quad 자신이 던지기로 확정한 error 42곳의 계약 감사**. 발견 번호는 6라운드에서 이어서 `H-55`부터, 패스를 가로질러 연속으로 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "문제가 없던 것" 부록은 다시 파지 않아도 되는 자리를 적어둔 것. **아직 아무것도 `base/`에 반영하지 않았다** — 판정은 사용자가 하고, 결정이 나면 6라운드처럼 `-followup.md`를 새로 만든다), `pre-implementation-handtrace-round7-verification.md`(**[2026-08-25 신설]** 그 52건이 **정말 유효한지만** 판정한 검증 패스 — 새 발견은 없다. ⚠️ **재실행이 아니라 대조로 판정했다**: 4·5·6차 패스가 쓴 전사물(`audit/handtrace-round7-reference-impl/spikes/`)과 `base/` 확정 의사코드를 줄 단위로 맞춰봤고(전사 오류로 생긴 발견은 **없었다**, 미신고 차이 3개는 전부 발견을 만들지 않는 방향), Luau 언어 동작·저장소 상태를 주장하는 것만 직접 재실행했다. 판정 분포와 항목별 근거는 그 문서 자신이 소스 — 여기서 세지 않는다. **결정 전에 반드시 읽을 것**: 부정 주장·개수 주장 몇 건이 정정됐고, 특히 **이미 사용자가 판단한 항목을 다시 묻게 되는 자리**가 하나 있다. 마지막 절의 **batch용 요약**이 유효 항목을 "한 결정으로 닫히는 묶음"으로 재편성해뒀으니 회신은 번호순이 아니라 그 묶음 단위로 하는 게 싸다). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 최우선 절과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M3가 M2의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영 — **[2026-08-24]** 그때 열어뒀던 마일스톤 재편은 M2/M3 순서 교체로 닫혔다). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M2~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것. **⭐ [2026-08-24] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round6-followup.md`(**[2026-08-24 신설] 6라운드의 결정과 근거가 여기 소스다** — `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정한 기록이고, 반영 후 `/code-review high`가 잡은 7건(그중 셋이 이번 반영이 만든 회귀)도 D절에 있다. 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`). `pre-implementation-handtrace-round7.md`(**[2026-08-25 신설, 회신 대기]** 7라운드: 6라운드와 같은 손 트레이싱이되 범위가 **M2(반응형 코어)와 M2→M3 경계**다. 패스 6개가 각기 다른 각도를 쓴다 — 1차는 프리미티브 사이의 *호출 순서*를 시간축으로 겹쳐 보기, 2차는 문서가 "확인했다"고 적은 런타임/타입 주장을 실제로 `luau`/`luau-analyze`에 걸어보기, 3차는 **커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려보기** + 1·2차가 뺐던 "M2를 *소비하는* 문서", 4차는 **M2 코어를 문서 그대로 옮긴 참조 구현을 돌려보기** + 아무 문서도 안 정한 **예외 경로**, 5차는 **확정된 M2 표면을 실제 Luau 타입으로 선언해 `luau-analyze`에 걸어보기**(확정 시그니처가 확정 관용구를 통과시키는가), 6차는 **그 참조 구현을 M2→M3 경계(Length/Offset 부기·Dispatch 체인)까지 이어 붙여 돌려보기** + **quad 자신이 던지기로 확정한 error 42곳의 계약 감사**. 발견 번호는 6라운드에서 이어서 `H-55`부터, 패스를 가로질러 연속으로 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "문제가 없던 것" 부록은 다시 파지 않아도 되는 자리를 적어둔 것. **아직 아무것도 `base/`에 반영하지 않았다** — 판정은 사용자가 하고, 결정이 나면 6라운드처럼 `-followup.md`를 새로 만든다), `pre-implementation-handtrace-round7-verification.md`(**[2026-08-25 신설]** 그 52건이 **정말 유효한지만** 판정한 검증 패스 — 새 발견은 없다. ⚠️ **재실행이 아니라 대조로 판정했다**: 4·5·6차 패스가 쓴 전사물(`audit/handtrace-round7-reference-impl/spikes/`), `pre-implementation-handtrace-round7-followup.md`(**[2026-08-25 신설] 그 52건에 대한 사용자 결정과 `base/` 반영 결과 — 결정 단위 12묶음(🅐~🅜) 순서로 대화형 처리. **이 파일이 결정의 소스**이고 발견 원문은 앞의 두 파일. 무효/기각 7건(`H-73`~`H-77` 계열: `<>`가 값 호출부에서 동작함이 실측으로 드러나 Store 재설계로 이어짐, `RunInit` 누수는 성립 안 하는 사용법), 나머지는 전량 반영. **부수로 `question.md` 최우선 두 항목이 같이 닫혀 M2 착수 게이트가 0이 됐다**)과 `base/` 확정 의사코드를 줄 단위로 맞춰봤고(전사 오류로 생긴 발견은 **없었다**, 미신고 차이 3개는 전부 발견을 만들지 않는 방향), Luau 언어 동작·저장소 상태를 주장하는 것만 직접 재실행했다. 판정 분포와 항목별 근거는 그 문서 자신이 소스 — 여기서 세지 않는다. **결정 전에 반드시 읽을 것**: 부정 주장·개수 주장 몇 건이 정정됐고, 특히 **이미 사용자가 판단한 항목을 다시 묻게 되는 자리**가 하나 있다. 마지막 절의 **batch용 요약**이 유효 항목을 "한 결정으로 닫히는 묶음"으로 재편성해뒀으니 회신은 번호순이 아니라 그 묶음 단위로 하는 게 싸다). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `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` | -| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **개수는 폴더가 소스**(여기서 세지 않음): `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션, 열한 번째 세션에 `canBound` 재도입 반영해 재갱신]** 실측된 사실 자체는 그대로 유효하고 `value` 단독 1-인자 재정정으로 오히려 더 중요해졌음 — 이중 바인딩 게이트(`canBound`)/emit 게이팅(`canExecute`)/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/`(개수는 폴더가 소스). 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md`의 `Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`), **`type-recursive-issue-with-typeof/`**(**[2026-08-15 신설]** 사용자가 발견한 `typeof(named fn)` 간접참조가 0-Y(재귀 제네릭 반환 leak)를 실제로 우회하는지 실측 — `REPORT.md` + `spikes/`. 결론: 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 LHS 명시 없이도 다운스트림이 안전해짐(체이닝 50단·타입 변경·중첩 self 호출까지 확인), `typing-limits.md` §1 ③으로 승격. 부수적으로 `setmetatable` 확장 시도에서 quad와 무관한 Luau 0.733 솔버 버그(모순 진단 두 개 동시 발생) 발견, 채택 안 함. `luau-test/16`(type function으로 `Store` 레코드 필드 합성) 복구도 이 조사 중 완료 — API 버전 드리프트였을 뿐 설계 문제 아니었음, `typing-limits.md` §5 승격), **`type-recursive-issue-try-callback/`**(**[2026-08-15 신설]** 콜백 파라미터 무주석 추론을 뚫을 방법이 정말 없는지 type function/메타테이블/오버로드/제네릭 디폴트 등 전방위로 재시도 — `REPORT.md` + `spikes/`(개수는 폴더가 소스 — 최초 라운드 + `/code-review high`가 이중 꺾쇠 명시적 제네릭 인스턴스화를 안 시도했음을 지적해 추가된 후속 조사 라운드로 구성). 결론: quad의 `state:Compute(fn)` 단일 호출 모양을 유지한 채로는 여전히 안 됨. 발견 셋 — (1) 근본 원인이 재귀 자기참조가 아니라 "제네릭 콜백 인자 전반에 컨텍스트 타입 전파가 안 됨"이라는 게 더 정확함(재귀 없는 최소 사례로도 재현), (2) `T`를 명시 중간 변수로 먼저 고정하거나 재사용 가능한 monomorphize 헬퍼를 거치면 실제로 추론이 살아나지만 둘 다 단일 콜론 호출을 2단계 체인으로 바꿔야만 해서 §0 대전제로 채택 안 함, (3) 이중 꺾쇠 명시 인스턴스화(`Compute<>(fn)`)는 leaf 호출에선 sound하게 성립하지만(spurious 진단 원인도 규명 — read-only/read-write 가변성 불일치) 매 호출 T/U 전부 명시 필요 + 중첩 self 호출 여전히 실패라 순손해로 채택 안 함), **`handtrace-round7-reference-impl/`**(**[2026-08-25 신설]** 7라운드 손 트레이싱 4·5·6차 패스가 발견을 재현하는 데 쓴 코드 전량 — `README.md` + `spikes/`(개수는 폴더가 소스) + 실행 결과 스냅샷 둘(`RUN-runtime.txt`/`RUN-typecheck.txt`). `type-recursion-issue/`와 같은 이유로 **스크립트를 같이 둠**. **⚠️ 여기 든 `core.luau`/`dispatch.luau`/`chain.luau`는 `base/`의 확정 의사코드를 손으로 옮긴 *전사물*이라 그 자체가 틀렸을 수 있다** — 그래서 그 README는 "재실행은 검증이 아니다"라고 못박고 **원문 대조표**와 **의도적으로 다른 곳 3개**를 같이 싣는다. 이 폴더에서 나온 발견을 판정하려면 재실행이 아니라 원문 의사코드와 줄 단위 대조가 필요하다) | +| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **개수는 폴더가 소스**(여기서 세지 않음): `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션, 열한 번째 세션에 `canBound` 재도입 반영해 재갱신]** 실측된 사실 자체는 그대로 유효하고 `value` 단독 1-인자 재정정으로 오히려 더 중요해졌음 — 이중 바인딩 게이트(`canBound`)/emit 게이팅(`canExecute`)/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/`(개수는 폴더가 소스). 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md`의 `Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`), **`type-recursive-issue-with-typeof/`**(**[2026-08-15 신설]** 사용자가 발견한 `typeof(named fn)` 간접참조가 0-Y(재귀 제네릭 반환 leak)를 실제로 우회하는지 실측 — `REPORT.md` + `spikes/`. 결론: 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 LHS 명시 없이도 다운스트림이 안전해짐(체이닝 50단·타입 변경·중첩 self 호출까지 확인), `typing-limits.md` §1 ③으로 승격. 부수적으로 `setmetatable` 확장 시도에서 quad와 무관한 Luau 0.733 솔버 버그(모순 진단 두 개 동시 발생) 발견, 채택 안 함. `luau-test/16`(type function으로 `Store` 레코드 필드 합성) 복구도 이 조사 중 완료 — API 버전 드리프트였을 뿐 설계 문제 아니었음, `typing-limits.md` §5 승격), **`type-recursive-issue-try-callback/`**(**[2026-08-15 신설]** 콜백 파라미터 무주석 추론을 뚫을 방법이 정말 없는지 type function/메타테이블/오버로드/제네릭 디폴트 등 전방위로 재시도 — `REPORT.md` + `spikes/`(개수는 폴더가 소스 — 최초 라운드 + `/code-review high`가 이중 꺾쇠 명시적 제네릭 인스턴스화를 안 시도했음을 지적해 추가된 후속 조사 라운드로 구성). 결론: quad의 `state:Compute(fn)` 단일 호출 모양을 유지한 채로는 여전히 안 됨. 발견 셋 — (1) 근본 원인이 재귀 자기참조가 아니라 "제네릭 콜백 인자 전반에 컨텍스트 타입 전파가 안 됨"이라는 게 더 정확함(재귀 없는 최소 사례로도 재현), (2) `T`를 명시 중간 변수로 먼저 고정하거나 재사용 가능한 monomorphize 헬퍼를 거치면 실제로 추론이 살아나지만 둘 다 단일 콜론 호출을 2단계 체인으로 바꿔야만 해서 §0 대전제로 채택 안 함, (3) 이중 꺾쇠 명시 인스턴스화(`Compute<>(fn)`)는 leaf 호출에선 sound하게 성립하지만(spurious 진단 원인도 규명 — read-only/read-write 가변성 불일치) 매 호출 T/U 전부 명시 필요 + 중첩 self 호출 여전히 실패라 순손해로 채택 안 함), **`handtrace-round7-reference-impl/`**(**[2026-08-25 신설]** 7라운드 손 트레이싱 4·5·6차 패스가 발견을 재현하는 데 쓴 코드 전량 — `README.md` + `spikes/`(개수는 폴더가 소스) + 실행 결과 스냅샷 둘(`RUN-runtime.txt`/`RUN-typecheck.txt`). `type-recursion-issue/`와 같은 이유로 **스크립트를 같이 둠**. **⚠️ 여기 든 `core.luau`/`dispatch.luau`/`chain.luau`는 `base/`의 확정 의사코드를 손으로 옮긴 *전사물*이라 그 자체가 틀렸을 수 있다** — 그래서 그 README는 "재실행은 검증이 아니다"라고 못박고 **원문 대조표**와 **의도적으로 다른 곳 3개**를 같이 싣는다. 이 폴더에서 나온 발견을 판정하려면 재실행이 아니라 원문 의사코드와 줄 단위 대조가 필요하다) **[2026-08-25 신설] `type-store-index-keyof/`** — Store 타이핑을 `WrapStore` 합성에서 `index<>`/`keyof<>` + 팬텀 필드로 다시 짠 실측(사용자가 직접 돌린 탐색 원본 포함). `type-recursion-issue/`와 같은 **스크립트 동봉** 구성(판정이 여러 formulation 대조라 개별 파일을 직접 돌려야 재현됨). 싱글톤 보존 규칙, 실패한 formulation 6종, 예약 키 충돌이 조용히 타입 검사를 끄는 것, `f<>()`가 값 호출부에서 동작함(`H-73` 반증)까지 | | `tools/` | **[2026-08-13 신설, `session/2026-08-13-09-structure-and-guardrails.md`]** 코퍼스 기계 점검 — `doc-check.py`가 깨진 파일/절 참조, README 색인 누락, 날짜 없는 시한부 주장("아직 안 돌려봄" 등), 미반영 ⚠️ 배너를 한 번에 훑음. **[2026-08-16]** 절 참조는 WARN이 아니라 **ERROR** — 판정 규칙은 `conventions.md`의 "절 인용 규약"이 소스. **중대 변경 후 커밋 전에 돌릴 것**(`python3 .claude/tools/doc-check.py`) — 수동 감사에서 나온 발견의 대부분이 이 종류였고, 실제로 문서를 쪼개다 잘못 옮긴 참조를 이게 잡아냄. ERROR는 고치고 WARN은 판단 대상 | | `agents/` | **[2026-08-16 신설]** 프로젝트 서브에이전트 정의(`.claude/agents/*.md`, Claude Code 표준 위치). 현재 `quad-doc-auditor.md` 하나 — `doc-check.py`가 못 잡는 의미론적 stale/모순(본문 문장이 뒤집힌 결정을 여전히 서술, 개수/목록 이중 소스 드리프트 등)을 신선한 맥락에서 찾는 읽기 전용 감사자. 중대 변경 커밋 전에 위임하는 게 기본 — **[2026-08-18 재설계] 한 턴에 하나씩만 돌리고(병렬 금지) 발견이 0건인 라운드가 나올 때까지 턴을 늘리는 루프**, 수정은 메인 세션이 일괄로 함(라운드 수·범위 좁히기 규칙은 여기 안 적음 — 소스는 conventions.md)이며 절차는 `.claude/conventions.md` "작업 방식" 절이 소스(**[2026-08-16]** 이 루프를 담던 `workflows/quad-handover-audit.js`는 토큰 과다·픽스 에이전트발 부정확 서술·사용자 질의 불가 때문에 폐기됨). `tools/`의 기계 점검과 짝을 이루는 의미론적 점검 계층 | | `agent-memory/` | **[2026-08-16 신설]** 서브에이전트가 라운드를 넘겨 유지하는 영속 메모리(`agent-memory/<에이전트 이름>/MEMORY.md`가 색인). 지금은 `quad-doc-auditor/` 하나 — 코퍼스 구조, 반복되는 실패 패턴 등을 기억해 감사 라운드마다 처음부터 파악하지 않게 함. **사람이 손으로 채우는 문서가 아니라 에이전트가 스스로 쓰는 것**이지만, `.gitignore` 대상이 아니라 커밋하면 코퍼스 일부가 되고 `doc-check.py` 검사 대상에도 들어감(감사 대상이기도 하다는 뜻 — 여기 적힌 주장도 stale해질 수 있음). **[2026-08-16 확정] 커밋해서 추적함**(사용자 결정). 사용자 논거: "실 기록이고 디펜던시도 아니고, 어차피 `SAFETY.md`에 따라 구현 시점에는 컨테이너에서 개발되며 다른 프라이빗 git에 올라가고 검토 후 머징되는거라, 문제되는 메모리 있으면(환경 노출 등) 사람이 감사처리 마지막으로 함. 결국 프로젝트 사이드 기록이고 같이 올려지는게 맞는게, 개발 환경이 다수라서 필요해보임" — 즉 **개발 환경이 여러 개라 메모리가 따라다녀야 하고**, 노출 위험은 머지 전 사람 검토가 최종 방어선. 커밋하는 쪽이 정해졌으니 여기 내용도 감사 대상이다(에이전트가 자기 메모리에 stale한 결론을 남기는 일이 실제로 있었음 — 2026-08-16에 폐기된 워크플로를 살아있는 것처럼 서술한 2건이 감사로 잡힘) | @@ -47,7 +47,7 @@ | `project-setup-plan.md` | **[2026-08-19 신설, 같은 날 두 차례 후속 갱신]** M0/M1 스캐폴딩을 실제로 pesde/`luau`/Rojo/`selene` CLI로 굴려보고 검증한 결과 — pesde 워크스페이스 구조(`workspace_members`, 패키지 이름은 하이픈 금지), `mise.toml` 툴체인 핀(`rokit.toml`에서 전환, 실제 설치·attestation 검증까지 확인), `init.luau`에서 `@self`가 필수인 이유(Luau RFC `abstract-module-paths-and-init-dot-luau`), 워크스페이스 의존성이 심볼릭 링크로 연결되고 `luau` CLI의 require-by-string은 이를 못 따라가지만 Rojo/Studio 배포 경로는 무관함을 실측 확인, `selene`의 CWD 상대 config 탐색 함정, `.luaurc` alias 런타임 미지원 재확인, `pesde.lock` 커밋 권고(잠정). "확인 완료/아직 확인 안 된 것" 절이 다음에 뭘 검증해야 하는지의 소스 | | `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), §8에 **새 타입·API 설계 시 체크리스트**. **[2026-08-19 신설]** §6 — `type function`을 거친 값(패스스루라도)은 이후 제네릭 self 메소드 체이닝(`AddPlugin`류)이 조용히 깨짐, `quad-types-plan.md`의 `CheckedQuad` 배선 중 실측 발견·회피(원본은 type function을 절대 안 거치게 하고 검사 결과는 별도 필드로 격리). 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` + `luau-test/23` **[2026-08-24 6라운드 `H-24` — 실측]** 영향 범위 표에 **`tween:Mapped`** 추가 — `tween:Mapped(fn: (T) -> U): Tween`가 1번이 지목한 모양(`Foo` 안에서 `-> Foo`)과 **글자 그대로 같은데** 이 문서도 `tween-plan.md`도 그걸 모르고 있었다. 인라인 제네릭 메소드로 선언하면 `luau-analyze`가 **진단 없이 조용히 통과**시키고, ③(`typeof(named function)`)로 바꾸면 정상적으로 잡힌다 — **기존 완화책이 그대로 통하는데 아무도 적용을 지시하지 않고 있었다** | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 **[2026-08-24 6라운드 `H-11`]** `bindLifetime`/`unbindLifetime`이 **`isEffect`를 보고 `Destroying`을 걸고/끊는다** — `LP-2`가 *"`Effect`가 그 훅을 쓰는 유일한 소비자"*라 확정해뒀는데 **실제로 거는 코드가 어디에도 없었다.** `unbind`는 cleanup을 부르지 않는다(대칭이라 포탈이 자연히 성립) | -| `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | +| `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `table.clone` 기반 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), **⭐⭐ [2026-08-25]** 두 가지가 바뀌었다 — (1) 생성이 **명시적 초기화**(타입 인자에 `Source`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접, 옛 lazy `__index` 폐기 → `defaults`가 곧 선언 키 집합이라 `store:Names()`가 성립), (2) 레코드 필드 타이핑이 **타입 함수를 안 쓰는 평범한 레코드**로(`WrapStore`/`ProcessStoreType` 폐기 — `H-75`/`H-76`이 그 한계를 실측). 동적 키는 `store:Of<>(name)` 하나로 옛 `GetDynamic`을 흡수했고, 예약 키(`Of`/`Names`) 충돌은 **조용히 타입 검사를 끄므로** `CheckReserved` 타입 함수가 진단만 띄운다. **같은 날 "`store.key`를 값으로" 재설계를 넣었다가 철회한 경위는 `archive/store-value-field-redesign-withdrawn.md`** — 거기서 나온 원칙(*"타입 함수는 진단까지만"*)이 `typing-limits.md` §0으로 승격됐다. `store.key = value` 폐기 → `store.key:Set(value)`는 **유지**, "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | | `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M2 착수 전 결론 필요) **[2026-08-24 6라운드]** 전파 루프가 구독자 집합을 **스냅샷으로 복사한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 **실행마다 결과가 달라지고 한 Observer가 통째로 누락**됐다(`H-23`, `Epoch` dedup으로는 이중 발화만 접히고 누락은 안 접힌다). `Effect(fn, ...deps)` 역전이 이 문서에 미반영이던 것도 정정 — **`Observer`만 기각 유지**이고 그 근거를 새로 씀("Observer는 리시버 State 하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다", `H-13`) 그리고 **`ObserverEffectLeafHandler`도 자기 배열 자리의 `setOffsetSource`/`setLength`를 안 등록하던 것**을 정정(`H-39` — 말단 핸들러 넷이 같은 결함이었고 이게 그중 하나다, `Frame { someObserver, Frame{} }`가 첫 `recompute`에서 죽었다) | | `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`nativeDispose`에도 재사용 — **[2026-08-22]** 그 op 이름은 `native*` 계층으로 확정, 옛 가칭 `disposeInst`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) **[2026-08-24 6라운드]** (1) **말단 핸들러 4종**(Tag/AttributeGroup/RefLeaf/ObserverEffectLeaf)이 `setOffsetSource`/`setLength`를 **아예 등록 안 하던 것**이 발견돼 계약대로 등록하게 됨 — 안 그러면 `Frame { Tag("x"), Child{} }` 같은 흔한 배치가 첫 `recompute`에서 명시적 error로 죽는다(`H-39`). (2) 접두합 캐시 무효화 3규칙이 **산문으로만 있고 코드 경로가 없던 것**을 `setLength`/`spliceArrays*`/`_baseObserver`에 실제 배치(`H-3`), `bk` 스펙에 `offsetCache`/`invalidAfter` 추가(`H-4`). (3) **`recompute` 호출은 `setLength`의 단독 책임**(`H-19`). (4) Blocker 범위를 **`drive` 전체**로 정정하고 `PostRef` 콜백이 게이트 안에서 돈다는 걸 계약화(`H-17`). (5) *"잔여 부기는 인스턴스 GC로 정리된다"*는 **틀린 안전망 주장 삭제**(gcconn 불멸성과 양립 불가, `H-26`) | | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | @@ -59,20 +59,20 @@ | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | | `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게. **[2026-08-24 재확정]** 구현은 State 코어와 같은 **M2**이고(2026-08-22에 디스패치 쪽으로 앞당겼다가 마일스톤 순서 교체로 되돌아옴), 바닥부터 짜는 게 아니라 공용 `GateNode`(`gate-plan.md`) 위의 **정책**이다. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) **[2026-08-24 6라운드 `H-33`/`H-49`]** **`blocker:Policy(emit) -> onUpstreamEmit`** 신설 — 자기 게이트 정책을 값으로 내주고 `state:Block(b)`가 그 위의 얇은 래퍼가 된다. `Debounce`/`Throttle`이 이걸로 자기 Blocker를 조종한다 | | `debounce-throttle-plan.md` | **[2026-08-14 신설, 2026-08-19 전부 해소돼 `research/`에서 승격]** 시간 기반 전파 게이트 `Debounce`/`Throttle` — 사용자 요청("`Blocker`와 유사하게")으로 신설. 요지: (1) `Blocker`가 이미 쓰는 게이트 노드의 **릴리스 트리거만 타이머로 바꾼 것**이라 새 전파 메커니즘이 아님, (2) 무효화 채널만 만지므로 laziness 안 깨짐, (3) **Debounce/Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐** — 공개 생성자는 둘, 구현은 하나, (4) 알고리즘은 quad-base + 주입 op 2개 `setTimeout(func, delay) -> Timeout`/`clearTimeout`(Roblox `task.delay`/`task.cancel`로 배선 — **인자 순서 반대라 주의**), `Timeout`은 `{ __type_timeout: true, _native: any }`. **[2026-08-19 마지막 라운드]** 의미론은 **(A) emit-gate**(`Blocker`와 동일, `:Get()`은 항상 최신값)로 확정 — 검토했던 값-지연 안은 laziness와 상충해 철회. 제어 핸들은 개별은 `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak 레지스트리)로 확정, `Time`/`MaxTime`은 `number \| State`(스케줄 시점에만 폴링) 허용. **⚠️ [2026-08-24 6라운드 `H-32`/`H-33`] 7절 의사코드에 무효화 배너가 붙었다 — 그 골격은 확정된 `state:Gate(setup)` API로는 성립하지 않아 재작성 대상이다.** 새 모델은 `Debounce`/`Throttle`이 **emit을 아예 안 쥐고** 자기 `Blocker`를 사적으로 하나 갖고(적용 핸들당 하나) `On()`/`Off()` 시점만 정하는 것 — `pending`도 Blocker의 `HasBlockedEmit`으로 흡수되어 `Trailing=false`+`MaxTime`에서 `Flush`가 영구 no-op이던 결함(`H-32`)이 구조적으로 사라진다. 창/타이머 정책 자체(`openWindow`/`onWindowEnd`/`MaxTime` 분기)는 그대로 유효. 이름은 `Debounce`/`Throttle` 유지 + Roblox 관용 "debounce"와 다르다는 문서 경고. **결과적으로 quad-base에 새 코어 메커니즘을 안 더하는 순수 슈가로 귀결**(`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힘) — 우선순위는 `Operator.*`와 같은 급으로 재평가됨. **부수 성과**: 이 설계 중 `source-state-plan.md`의 무효화 dedup 서술이 `Observer` 계약과 모순되는 게 발견돼 base 전면 정정(`archive/invalidate-dedup-propagation-reversed.md`) | -| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, ...deps)` — deps 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 **각 dep에 구독을 따로 걸어**(State/Source면 `Observer`, `Ref`면 `:Callback`) 재실행+cleanup 체이닝(React `useEffect` 동형). **[2026-08-24 표기 정정]** 여기 원래 *"내부적으로 `state:Observer(...)`를 조합"*이라 적혀 있었는데 그건 단수 시절 모델이고, 같은 행 뒤쪽의 `C-6` 서술과 어긋났다. Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음. **[2026-08-20 `E-10` → 2026-08-21 `EF-3`에서 반영]** 그 dedup 경로의 process/retract 대칭은 **성립함이 확인됨**(핸들러가 `old`를 `Relate`로 직접 들고 양쪽이 같은 비교식을 씀 — 남은 건 구현 시 회귀 확인뿐, 설계상 열린 항목 아님). **[2026-08-21 5라운드 `C-6`]** 시그니처가 `Effect(fn, ...deps)`로 확장돼 의존성을 여러 개 직접 받고(`Ref`도 가능) 각각에 구독을 건다. **[2026-08-21]** 다중 의존성이 공통 상류를 공유할 때 한 파동에 `fn`이 여러 번 돌던 미해결 갭은 **`EffectHandle`이 자기 `EpochMap`을 들어** 닫힘(`base/state-epoch-plan.md`). **[2026-08-24 6라운드]** `fn` 시그니처가 **`fn(self: EffectHandle) -> (() -> ())?`**로 확정(**`...deps`는 의존성 선언일 뿐 `fn`에 안 넘어간다**, `H-14`), `_observer`(단수)→`_observers`(배열)(`H-8`), 그리고 **leaf 사망 cleanup을 실제로 발화시키는 배선이 없던 것**이 `bindLifetime`/`unbindLifetime`이 `isEffect`를 보고 `Destroying`을 걸고/끊는 것으로 닫힘(`H-11` — 이게 없어서 `slot._detachCleanup`과 `OnDestroyed`가 통째로 무동작이었다). `Ref` dep 콜백은 해제 경로(`:Uncallback`)와 **발화 시 `canExecute` 확인**을 둘 다 갖는다(`H-7`) | +| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, ...deps)` — deps 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 **각 dep에 구독을 따로 걸어**(State/Source면 `Observer`, `Ref`면 `:Callback`) 재실행+cleanup 체이닝(React `useEffect` 동형). **[2026-08-24 표기 정정]** 여기 원래 *"내부적으로 `state:Observer(...)`를 조합"*이라 적혀 있었는데 그건 단수 시절 모델이고, 같은 행 뒤쪽의 `C-6` 서술과 어긋났다. Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음. **[2026-08-20 `E-10` → 2026-08-21 `EF-3`에서 반영]** 그 dedup 경로의 process/retract 대칭은 **성립함이 확인됨**(핸들러가 `old`를 `Relate`로 직접 들고 양쪽이 같은 비교식을 씀 — 남은 건 구현 시 회귀 확인뿐, 설계상 열린 항목 아님). **[2026-08-21 5라운드 `C-6`]** 시그니처가 `Effect(fn, ...deps)`로 확장돼 의존성을 여러 개 직접 받고(`Ref`도 가능) 각각에 구독을 건다. **[2026-08-21]** 다중 의존성이 공통 상류를 공유할 때 한 파동에 `fn`이 여러 번 돌던 미해결 갭은 **`EffectHandle`이 자기 `EpochMap`을 들어** 닫힘(`base/state-epoch-plan.md`). **[2026-08-24 6라운드]** `fn` 시그니처가 **`fn(self: EffectHandle) -> (() -> ())?`**로 확정(**`...deps`는 의존성 선언일 뿐 `fn`에 안 넘어간다**, `H-14`), `_observer`(단수)→`_observers`(배열)(`H-8`), 그리고 **leaf 사망 cleanup을 실제로 발화시키는 배선이 없던 것**이 `bindLifetime`/`unbindLifetime`이 `isEffect`를 보고 `Destroying`을 걸고/끊는 것으로 닫힘(`H-11` — 이게 없어서 `slot._detachCleanup`과 `OnDestroyed`가 통째로 무동작이었다). `Ref` dep 콜백은 해제 경로(`:Uncallback`)와 **발화 시 `canExecute` 확인**을 둘 다 갖는다(`H-7`) **⭐⭐ [2026-08-25 7라운드 재설계]** dep 등록이 **생성자 한 곳**으로 모이고(`:WeakCallback`/`:WeakSubscribe` — `Weak` 쪽이 프리미티브), 강한 주인이 **`_deps` 하나**로 통합됐다(옛 `_observers`/`_refDeps`/`_refCallbacks`/`_installing` 전부 폐기, 억제는 사적 `Blocker`). `bindLifetime`/`unbindLifetime`은 **핸들 하나에만** 적용되고 내부 Observer로 cascade하지 않으며, `Ref` 콜백을 떼지도 않는다 — 발화 게이팅은 `canExecute(handle)` 하나(`H-58`/`H-59`). `Ref`가 `Epoch`로 승격돼 `_epochs`가 dep 종류를 균일하게 담고, 포탈 캐치업이 `if not self._installed or self._epochs:Refresh() then self:Rerun() end` 한 줄이 됐다(`H-64`/`H-65` — cleanup 반환이 **선택**이라 `_cleanup` 유무로는 설치 여부를 못 판정한다). **`:Rerun()` 정의 신설**(재진입은 지연 재실행, error는 UB) 및 `:_consumeCleanup()`(읽고→지우고→실행), 값 교체 retract가 cleanup을 소진 호출(`H-57`), deps 검증(`nil`/이물 error, 중복 무시). 재사용은 `Clone`/`Userdata`가 아니라 **`({...}) -> Effect` 팩토리 패턴** | | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) **[2026-08-24 6라운드]** `TagHandler`가 **자기 배열 자리의 `setOffsetSource`/`setLength`를 아예 등록하지 않던 것**을 정정(`H-39` — `Frame { Tag("card"), TextLabel{} }`처럼 Tag를 자식보다 앞에 두는 흔한 배치가 첫 `recompute`에서 error로 죽었다). `isHandlable`에 **`type(k) == "number"` 가드**도 추가(`H-52` — `RefLeafHandler`가 2026-08-18에 받은 수정을 이쪽은 못 받고 있었다) | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드). **[2026-08-18 구현 전 QA 반영]** **`Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가 이김)을 둘 다 제공**으로 열린 항목 해소, 그리고 **⚠️ 같은 그룹 객체를 두 위치에 놓는 경우를 잡을 위치별 claim이 필요**하다는 미해결 항목 신설(`Ref`처럼 `bindLifetime` 재사용은 불가) **[2026-08-24 6라운드]** `AttributeGroupHandler`에 (1) 배열 자리 부기 등록(`H-39`, Tag와 같은 결함 — 층위 예외를 두지 않고 다른 말단 핸들러와 똑같이 등록한다), (2) `type(k) == "number"` 가드(`H-52`), (3) **`groupClaimKeys` 위치 claim 배선**(`H-41` — 5라운드 `AT-1`에서 키를 확정해놓고 의사코드에 안 들어가 있었다, `nameClaims`보다 **먼저** 해야 절반만 기록되는 중간 상태가 안 생긴다). 그리고 **attribute 이름을 서로 다른 두 자리 사이에서 옮기는 것은 UB로 확정**(`H-18`/`H-45` — 두 체인이 별개라 emit 순서에 따라 성공하거나 크래시하는데, 사용자 판단: 막으려면 process/retract 계약 전체에 예외가 생겨 오버엔지니어링) | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 특수 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 **[2026-08-24 6라운드 `H-27`]** `process`에 **`v == nil` 얼리리턴**을 추가 — 이 문서가 스스로 *"`event-plan.md`와 같은 결"*이라 결론냈는데 그 "같은 결"의 핵심(`v=nil`이면 해제만 하고 새로 Connect하지 않는다)이 의사코드에 없었다. 없으면 `State`를 `None`으로 꺼서 콜백을 끄는 게 실제로는 **나중에 터질 Connection을 새로 심는** 동작이 된다 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 | -| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`). **[2026-08-18 구현 전 QA 반영]** 내부 구조가 **별도 `.Callbacks` 테이블 + 평범한 `.Value` 필드**로 단순화(`__index` 우회 폐기), `RefLeafHandler.isHandlable`에 빠져 있던 `type(k)=="number"` 추가(leaf 바인딩은 **배열 전용**), "배열 파트의 `None`은 process를 안 탄다"는 옛 명확화 전면 정정. **[2026-08-24 6라운드]** `.Callbacks`가 배열에서 **`{[callback|thread] = true}` 해시맵 셋**으로 재설계되고 **해제 경로 `:Uncallback(fn)`**이 신설됨(`H-7` — `Effect`의 `Ref` dep을 뗄 방법이 아예 없던 갭). 중복 등록은 dedup이 계약이고, 발화는 **순회 전 스냅샷**을 뜬다(`H-23`과 같은 처방). 그 부수로 **"구멍 있는 테이블에서 `#t` border" 실측 항목이 폐기**됐다(해시맵엔 border 개념이 없음). `RefLeafHandler`가 자기 배열 자리의 `setOffsetSource`/`setLength`를 등록하게 된 것(`H-39`)과 `PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부라는 것(`H-42`, Deferred에선 그 레이스가 없음 — 구조는 유지)도 같이 반영 | +| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`). **[2026-08-18 구현 전 QA 반영]** 내부 구조가 **별도 `.Callbacks` 테이블 + 평범한 `.Value` 필드**로 단순화(`__index` 우회 폐기), `RefLeafHandler.isHandlable`에 빠져 있던 `type(k)=="number"` 추가(leaf 바인딩은 **배열 전용**), "배열 파트의 `None`은 process를 안 탄다"는 옛 명확화 전면 정정. **[2026-08-24 6라운드]** `.Callbacks`가 배열에서 **`{[callback|thread] = true}` 해시맵 셋**으로 재설계되고 **해제 경로 `:Uncallback(fn)`**이 신설됨(`H-7` — `Effect`의 `Ref` dep을 뗄 방법이 아예 없던 갭). 중복 등록은 dedup이 계약이고, 발화는 **순회 전 스냅샷**을 뜬다(`H-23`과 같은 처방). 그 부수로 **"구멍 있는 테이블에서 `#t` border" 실측 항목이 폐기**됐다(해시맵엔 border 개념이 없음). `RefLeafHandler`가 자기 배열 자리의 `setOffsetSource`/`setLength`를 등록하게 된 것(`H-39`)과 `PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부라는 것(`H-42`, Deferred에선 그 레이스가 없음 — 구조는 유지)도 같이 반영. **⭐⭐ [2026-08-25 7라운드]** `Ref`가 **`Epoch`를 만족**하게 됨(공개 `.Revision` + `EpochBrand` 등록 — `Effect._epochs`가 State/Source/`Ref`를 균일하게 담게 되어 포탈 캐치업 비대칭(`H-64`)과 같은 `Ref` 중복 dep(`H-70`)이 같이 닫힘), **`:WeakCallback(fn)` 신설**(약한 등록이 프리미티브이고 `:Callback`은 거기에 "GC 킵"을 얹은 것), 그리고 **`unbindLifetime`/`:Unsubscribe()`가 더 이상 `:Uncallback`을 안 부른다**(`H-58` — 그 떼었다 붙이는 춤이 바인드마다 `Rerun`을 돌리던 원인, `:Uncallback`은 사용자 표면으로만 남음). `RefLeafHandler`의 dedup 기록도 `SetStrong`→**`SetWeak`**(`H-71` — 값이 자기 키를 되참조해 100% 누수) | | `event-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 **[2026-08-18 정정] `None`/`nil`을 넣으면 disconnect**(옛 `false` 센티널은 `None` 도입 전의 선택이라 폐기 — `EventHandler.isHandlable`이 `v == nil`에도 매치돼야 함). 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | | `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 branded 타입 전부로 일반화(`isPostRef` 포함, 2026-08-14 아홉 번째 세션). **[2026-08-21 전면 재작성]** 공유 레지스트리 + `Brand.get(x) -> tag`(객체당 태그 하나)에서 **인스턴스 브랜드**(`Brand()` + `:register`/`:is`, **다중 태깅 허용**)로 바뀜 — 발단은 `Source`가 `SourceBrand`이면서 동시에 `EpochBrand`여야 하는데 옛 모양으로는 표현이 안 되던 것. 역조회는 없어졌고(멤버십 질문만 씀), weak-key·테이블 아이덴티티·duck-typing 기각 근거·포함 관계 predicate 합성은 전부 유지. 역전 원문은 `archive/brand-shared-registry-reversed.md`, 근거 기록은 `reference/epoch-brand-composition.md`. 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **[2026-08-18 구현 전 QA 반영]** **`Brand`는 아무 의존성도 갖지 않는다** — `None` 특수 분기 안은 기각(`isNone`은 그냥 `v == None`) | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) **[2026-08-24 6라운드 `H-24`]** `:Mapped` 절에 타입 단서 추가 — 그 시그니처는 재귀 제네릭 누수 패턴이라 **인라인이 아니라 `typeof(named function)`으로 선언해야** 체커가 지켜준다(`base/typing-limits.md`). "타입이 안전하게 성립한다"는 *의미론상* 맞지만 *체커가 지켜준다*는 뜻이 아니었다 | | `fallback-plan.md` | **[2026-08-14 세션, `research/`에서 승격]** `Fallback`/`Traceback` — 컴포넌트 함수를 감싸 에러 시 플레이스홀더를 그려주는 순수 슈가(`additional-primitives-plan.md`의 "Error Boundary" 절이 내린 "빈 자리 아님" 결론 위에 얹힘). `Fallback`은 `pcall` 기반(trace 없음), `Traceback`은 `xpcall`+`debug.traceback` 기반(trace 항상 있음) — 플래그 대신 별도 함수로 분리(`Ref`/`PreRef`와 같은 패턴). `err: any`(Lua `error()`가 임의 값을 던질 수 있음, `error(msg)` 기본 호출의 위치 접두 캐비엇 포함) 확정. 패키지는 `quad-base`, 이름 확정. 메커니즘 실측은 `audit/fallback-xpcall-verification.md`. 구현 우선순위는 형제 백로그(`quad-mock`/`quad-debug`/`Operator`)와 동급, 맨 뒤 **⚠️ [2026-08-24 6라운드 `H-26`] 미해결 항목이 하나 신설됐다** — **실패 이전에 생성된 부분 트리는 회수되지 않는다.** quad Instance는 gcconn 때문에 `Destroy`로만 회수되는데, 컴포넌트가 리터럴을 만들다 던지면 그때까지 완성된 형제/자손이 트리에 붙지도 파괴되지도 않은 채 사라지고 `Fallback`은 그 존재를 알 방법이 없다. **`Fallback`/`Traceback`이 그 경로를 계속 살려두는 걸 존재 이유로 삼는 대표 사용처**라 층위가 다르다 — **백로그**(그 둘이 슈가라 구현 시점에 같이 다룬다, 그 문서의 ⚠️ 절이 소스) | | `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 | | `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Block(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "마일스톤 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 같은 마일스톤**으로 해소.) 구현은 M2 | -| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만 돌며 값만 앞당기고 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 | +| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만** 돌며 값만 앞당기고(**[2026-08-25 `H-85`]** 옛 `rawInvalid` 불린은 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체 — 재계산 *도중* 도착한 무효화를 꼬리가 지우던 것과, `fn`이 던졌을 때 계산된 적 없는 캐시를 유효하다고 확신하던 것 둘을 같이 닫음) 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) @@ -107,6 +107,7 @@ |---|---| | `always-propagate-no-dedup-superseded.md` | **[역전됨, 2026-08-21 신설]** `source-state-plan.md`가 확정해뒀던 "emit은 자기 `invalid`와 무관하게 **항상** 전파된다 / quad가 접지 않는 것은 중복 *통지*뿐이다" — `base/state-epoch-plan.md` 채택으로 "같은 `Epoch`의 같은 리비전이 두 번째로 도착하면 접는다"로 바뀜. ⚠️ 2026-08-14의 `invalid` 기반 dedup 역전을 되돌린 게 아님(그 금지는 유효) | | `brand-shared-registry-reversed.md` | **[역전됨, 2026-08-21 신설]** `Brand`의 옛 표면 — 공유 weak 레지스트리 하나 + `Brand.set`/`Brand.get(x) -> tag`(**객체당 태그 하나**). `Source`가 `SourceBrand`이면서 동시에 `EpochBrand`여야 하는 요구(`base/state-epoch-plan.md`의 `Epoch` 인터페이스)를 표현할 수 없어 **인스턴스 브랜드**로 재작성됨(`base/brand-plan.md`). 같이 버려진 역조회 `Brand.get`은 코퍼스 전수 조사에서 쓰는 자리가 하나도 없어 대가가 아니었고, "포함 관계가 코드에 드러난다"는 우려는 에이전트 착오로 철회됨 | +| `store-value-field-redesign-withdrawn.md` | **[철회됨, 2026-08-25 신설]** Store를 **"값 필드 + 타입 함수 합성"**으로 바꾸려던 시도 — `store.key`를 값으로, `store:Of(k)`를 프리미티브로, `__index`/`__newindex` 슈가, 팬텀 필드 + `index<>`/`keyof<>`, `?` nilable 선언, `store.key = v` 부활. **같은 날 도입하고 같은 날 철회**했다. 철회 이유 다섯(읽기/쓰기 의미론 미정의, `index<>`/`keyof<>`도 타입 함수라 같은 함정, `Names()`가 런타임 구현 불가, `?`를 떠받칠 sentinel 부재 — `None`은 nil hole용이라 의미론이 다름, 표면 증가)과 원문 보존. **살아남은 것은 명시적 초기화 하나.** 여기서 나온 원칙(*"타입 함수는 진단까지만"*)이 `typing-limits.md` §0으로 승격 | | `store-source-proxy-reversed.md` | [역전됨] 2026-08-04에 확정했던 `StoreSource` 프록시 설계(Store가 Source를 감춘 별도 프록시로 감쌈) — 2026-08-06 세 번째 세션에서 "Source가 State를 구조적으로 만족" 재구성으로 완전히 대체됨. 원문·역전 이유·신구 비교표 보존, `quadnomicon` 소재 후보 | | `ref-phase-option-reversed.md` | [역전됨] `CreatedRef`의 `phase` 옵션 — 위치 기반 순서 + `PreRef` 신설로 대체됨 | | `preref-order-unguaranteed-withdrawn.md` | **[철회됨, 2026-08-14 아홉 번째 세션 신설]** 복수 `PreRef`/`PostRef` 간 fire 순서를 "배열 index 순서 보장"에서 **미보장으로 바꾸려던 안** — 같은 세션에 제안·철회, 현재 계약은 **보장**(2026-08-07 결정 그대로). 반례는 `FastQuery(...) -> PreRef`류 조합(앞자리 항목이 뒤 항목의 전제를 만들어주는 정당한 합성), 보장 비용 0 + 배열 파트 index 순서 계약의 자동 귀결이라 새로 내주는 자유도 없음. 양쪽 논거 보존 | diff --git a/.claude/archive/store-value-field-redesign-withdrawn.md b/.claude/archive/store-value-field-redesign-withdrawn.md new file mode 100644 index 0000000..3e436df --- /dev/null +++ b/.claude/archive/store-value-field-redesign-withdrawn.md @@ -0,0 +1,355 @@ +# [검토 후 철회] Store를 "값 필드 + 타입 함수 합성"으로 바꾸려던 시도 + +**상태**: archive — **2026-08-25 오전에 도입했다가 같은 날 철회됨.** +지금 유효한 설계는 `base/store-plan.md`가 소스. 이 문서는 그 시도의 원문과 +**왜 되돌렸는지**를 보존한다(히스토리 전용, **여기 적힌 것을 현재 설계로 +읽지 말 것**). + +## 무엇을 시도했나 + +`WrapStore`/`ProcessStoreType` 타입 함수로 결과 타입을 **합성**하는 옛 +접근이 7라운드 `H-75`/`H-76`으로 무너지자, 그 자리를 다음으로 채우려 했다: + +| 시도한 것 | 지금(철회 후) | +|---|---| +| 타입 인자가 **평범한 값 타입**(`Store<<{hp: number}>>`) | 타입 인자에 **`Source`를 직접**(`Store<<{hp: Source}>>`) | +| `store.key` → **값**(`__index`가 `Of(k):Get()`) | `store.key` → **`Source`**(평범한 레코드 필드) | +| `store.key = v` **부활**(`__newindex`) | **폐기 유지** — `store.key:Set(v)` | +| `store:Of(k)`가 **프리미티브** | `store:Of<>(name)`은 **동적 키 전용**(옛 `GetDynamic` 흡수) | +| 팬텀 필드 `__store` + `index<>`/`keyof<>` | **타입 함수 안 씀** — 평범한 레코드 | +| 선언만 하고 값을 안 주는 키를 `?`로 표현 | **명시적 초기화**(이건 **살아남았다**) | + +**살아남은 것은 명시적 초기화 하나**다 — eager/lazy 이중 모델 폐기, +`store:Names()` 신설, `WrapStore` 폐기. 나머지는 전부 되돌렸다. + +## 왜 철회했나 + +**사용자 지적**(2026-08-25): *"우리가 너무 '필드셋' 으로 처리하고싶다에 +너무 몰두해버린것 같네요. 필드의 read write 의 실질적 의미론이 어떤지도 +정의하지 못해버린채요. 이러면, `.Value = 1` 이 정적 쓰기처럼 보일텐데, +여기서 error 터지는 trace 가 나오면 당황스럽기도 하고요, 약간 마법적 +동작이기도 해요. … 왜 우리가 `Source()` 를 직접 넣는걸 거부하고, 이렇게 +까지 하려 했죠? 단순히, `Store<{ a: Source }>` 로 두고, `store.a:Set,Get` +하지 말아야할 이유가 있을까요?"* + +되짚어보니 **비용이 이득보다 컸다**: + +1. **읽기/쓰기 의미론을 정의하지 못한 채로 문법만 도입했다.** + `store.key = v`가 정적 쓰기처럼 보이는데 실제로는 `Set`을 트리거하고, + 실패하면 그 자리에서 error trace가 난다 — 2026-08-06이 대입 문법을 + 폐기한 논거 2번(*"`=`가 암시하는 즉시 커밋이 lazy와 안 맞는다"*)이 + 그대로 되살아난 셈이다. +2. **`index<>`/`keyof<>`도 타입 함수다.** **사용자 지적**: *"사실 keyof 와 + index 도 타입함수입니다(구현을 뜯어보면, 루아우에서 프리디파이닝한 + 타입함수임). 타입함수가 가지는 고질적 문제를 그대로 가져요."* + 실제로 `H-76`이 지적한 부류의 문제가 새로 하나 생겼다 — `Of`의 + **`Self` 제네릭을 거치면 콜백 파라미터 추론이 깨져** `s`가 `unknown`으로 + 떨어졌다(평범한 레코드 필드에선 정상 추론된다, 실측 대조). +3. **`store:Names()`가 런타임에 구현 불가능했다.** Luau가 타입 인자를 + 런타임에 지우므로 `Store<<{hp: number, name: string?}>>({hp = 100})`에서 + `name`이 선언됐다는 사실을 런타임이 알 방법이 없다. 그런데 + `attr:NameMap()`은 선언된 전체 키를 요구한다 — 즉 `H-79`가 "닫혔다"고 + 한 문제가 옵셔널 미설정 키에 대해 그대로 재발한다(감사 4패스 발견). +4. **`?`(nilable) 선언을 떠받칠 sentinel이 없었다.** `None`은 **핸들러 + 배열의 nil hole** 때문에 생긴 물건이라 "값이 없다"가 아니고, + `Source`을 정의한 적도 없어 `Compute`가 뭘 받을지부터 + 안 정해진다(**사용자 분석**). 그렇다고 `nil`과 `None`을 가르는 새 + 도구를 두기엔 의미론이 부딪힌다. +5. **표면이 늘었다** — `Of` + `Names` + `GetDynamic` 셋이었는데, 철회 후엔 + `Of`가 `GetDynamic`을 흡수해 둘이 된다. + +**여기서 나온 원칙 하나가 `base/typing-limits.md` §0으로 승격됐다** — +*"타입 함수는 타입이 못 잡는 문제를 **에러로 띄우는** 정도 이상으로 가지 +않는다."* 이 시도가 정확히 그 선을 넘은 사례다. + +경위 원문은 `qa-request/pre-implementation-handtrace-round7-followup.md`의 +🅖🅗 절, 발견 원문은 `qa-request/pre-implementation-handtrace-round7.md`의 +`H-73`~`H-76`. + +--- + +## 철회된 시도의 원문 (보존) + +아래는 **2026-08-25 오전에 `base/store-plan.md`에 실제로 들어갔던 네 절**을 +그대로 옮긴 것이다. 같은 날 철회돼 지금은 `base/store-plan.md`에 없다. +**여기 적힌 것을 현재 설계로 읽지 말 것** — 특히 `store.key`가 값이라는 +서술, `__index`/`__newindex` 슈가, 팬텀 필드 `__store`, +`index<>`/`keyof<>`, `?` nilable 선언, `store.key = v` 부활은 **전부 철회**됐다. + +--- + +## Store = Source들의 이름 붙은 모음 (명시적 선언, 2026-08-25 재설계) + +> **⭐⭐ [2026-08-25 재설계] 이 절은 통째로 다시 쓰였다.** 옛 모델 +> (`store.key`가 `Source`를 직접 반환하는 레코드 필드 + eager/lazy +> 이중 생성 + `WrapStore` 타입 함수)은 **역전**됐고 원문은 +> 이 파일(당시 이름은 store-source-record-model-reversed.md였다)에 있다. 경위는 +> `qa-request/pre-implementation-handtrace-round7-followup.md`. + +**Store는 키를 타입 인자로 명시해서 만든다.** 타입 인자에 담는 것은 +`Source`가 아니라 **평범한 값 타입**이다. + +```lua +local store = quad.Store<<{ + hp: number, + name: string?, -- 안 넘겨도 되는 값은 `?`로 선언 +}>>({ hp = 100 }) +``` + +- **`store.key`는 값**이고 **`store.key = v`는 대입**이다 — + `__index`/`__newindex`가 아래 `Of`를 대신 불러준다. 읽기에 계산이 끼지 + 않는다(Store가 담는 건 `Source`뿐이므로 `Get`이 곧 현재 값이다). + 사용자: *"`__index` 는 이제 `:Get` 해주어도 될것 같다. (연산이 안 + 일어난다. 진짜 현 값이 맞음)"* +- **⭐ `store:Of(key)`가 프리미티브다** — 그 키의 `Source`를 돌려준다. + `__index`/`__newindex`는 그 위의 슈가다(사용자: *"`__index` 를 통한 + get 에선 `Of()` 결과에서 `:Get` 하도록 합시다. `__newindex` 도 + 유사하죠, 그 이후 `Set` 되는 식"*). 반응형으로 묶을 때는 이걸 쓴다. + ```lua + store.hp -- number + store.hp = 5 -- 대입 + store:Of("hp") -- Source + store:Of("name") -- Source ← `?` 선언이 그대로 드러남 + ``` +- **이름이 `Peek`이 아니라 `Of`인 이유** — 사용자: *"Peek 자체가, 애초에 + 여긴 다 확정된 값들의 무더기라서, Peek 아닌 다른게 좋긴 해보여요. + `Of` 가 가장 좋아보입니다."* 다른 프레임워크에서 `Peek`은 관례적으로 + "구독 없이 값을 읽는다"인데 여기선 `store.key`가 이미 그 일을 하고 + 이 메소드는 **핸들**을 준다. +- **⭐ 선언은 필수, 값은 선택.** 안 넘겨도 되는 것은 타입에서 `?`로 + 선언한다 — 그러면 `store:Of("name")`이 `Source`로 나와 + **타입이 정직해진다**. 옛 lazy 모델이 갖던 구멍(사용자: *"`Set` 을 안 + 해주면, 초기 값이 타입에 어긋날 수 있거든요. 애초에, + `Source` 인데, nil을 조용히 가지고 있을수도 있고, 타입으로 못 + 막네요"*)이 이걸로 닫힌다. 부모 컴포넌트가 기본값 있는 것까지 전부 + 넘길 필요도 없어진다. +- **`Source` 실체는 그림자 백킹 테이블에 있다** — `store.key`가 값이면 + `__index`/`__newindex`는 그 키가 store 테이블에 **없을 때만** 발동하므로 + 실제 `Source`를 store 테이블에 raw로 넣을 수 없다. **[2026-08-25 + 정정]** 옛 서술의 *"별도 `__values`류 그림자 실값 저장소도 불필요"*는 + 이 재설계로 뒤집혔다. +- **미선언 키의 방어선은 여전히 타입이다.** `Store<{field: T}>`로 선언된 + Store에 없는 이름을 쓰면 타입 에러가 난다 — `luau-analyze` 실측에서 + `store:Of("nope")`이 정확히 거부된다. 런타임에 이름이 정해지는 정당한 + 용도는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구. +- **`store:Names()`가 선언된 키 집합을 준다** — 타입은 + `{ keyof<...> }`로 정확히 나온다. 그룹 `Attribute(...)`/`attr:NameMap()`이 + 이걸 요구한다(`base/attribute-plan.md`). **[2026-08-25 신설, 7라운드 + `H-79`]** 이 표면이 없어서 그룹 `Attribute`의 키 집합이 접근 이력에 + 좌우되던 문제를 닫는다. +- **구현 스케치**: 생성 시 `table.clone(defaults or {})` 후 그 결과를 + 순회하며 각 슬롯을 `Source(v)`로 교체해 그림자 테이블로 삼는다 + (`table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에 + 키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로). + **`or {}`가 필수다** — 무인자 `Store<<{}>>()`도 유효한데 + `table.clone(nil)`은 `table expected, got nil`로 죽는다(**[2026-08-25]** + 7라운드 `H-83` 실측). + `Source()`(인자 없이 호출)는 `Source(nil)`과 동치. +- **⭐ [2026-08-25 신설] 선언은 됐는데 `defaults`에 없는 키는 `Of`가 그 + 자리에서 만든다.** "선언은 필수, 값은 선택"이 계약이므로 + `Store<<{hp: number, name: string?}>>({hp = 100})` 뒤 + `store:Of("name")`은 **정상 경로**인데, 위 스케치는 `defaults`에 있는 + 키만 그림자 테이블에 넣으므로 그 자리가 비어 있다. `Of`가 비어 있으면 + `Source(nil)`을 만들어 저장하고 반환한다 — 타입이 `Source`라 + **정직하고**, 옛 lazy 모델이 갖던 구멍(`Source`인데 조용히 `nil`)은 + `?` 선언 강제로 이미 닫혀 있다. + ```lua + function Store:Of(key) + local src = shadow[key] + if src == nil then + src = Source() -- == Source(nil) + shadow[key] = src + end + return src + end + -- __index(t, k) == t:Of(k):Get() + -- __newindex(t,k,v) == t:Of(k):Set(v) + ``` + **미선언 키는 여기 안 온다** — 타입에서 이미 거부되고(`store:Of("nope")` + 실측), 런타임에 이름이 정해지는 경로는 `:GetDynamic`이 정식 창구다. + +v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게 +쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 타입이 핵심 +제약이라 그 전제 자체가 더 이상 안 맞고, 2026-08-06 후속 세션의 정리로 +Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐. 값 하나만 +반응형으로 다루고 싶으면 Store를 통째로 만들지 말고 독립 +`Source(default)`를 쓸 것(`base/source-state-plan.md`의 "Source는 독립 +공개 프리미티브로 격상" 절). + +## Store 값 설정 문법 — `store.key = v`와 `store:Of(k):Set(v)`는 같은 것 (2026-08-25 재역전) + +> **⭐⭐ [2026-08-25 재역전] 2026-08-06에 확정했던 "`myStore.key = value` +> 폐기, `source:Set(value)`로 전환"은 뒤집혔다.** 원문과 역전 이유는 +> 이 파일(당시 이름은 store-source-record-model-reversed.md였다). 여기 옛 결정을 남겨두면 +> 앞에서부터 읽는 구현자가 그걸 그대로 믿으므로 포인터만 남긴다. + +**둘 다 정식 경로이고 같은 것이다.** + +```lua +store.hp = 5 -- __newindex → store:Of("hp"):Set(5) +store:Of("hp"):Set(5) -- 프리미티브 +``` + +- **왜 되살아났나**: 옛 결정의 첫 근거는 *"읽기(`Source`)/쓰기(`T`) + 타입이 갈려 mismatch가 남음"*이었는데, 위 재설계로 **읽기도 `T`가 되어 + 대칭**이다. `luau-analyze` 실측에서 `store.hp = 5`는 통과하고 + `store.hp = "five"`는 정확히 걸린다. +- 옛 결정의 둘째 근거(*"`=`가 암시하는 즉시 커밋이 quad의 lazy와 안 + 맞음"*)도 이 자리에선 약하다 — lazy한 것은 **하류 전파**이지 Store 값 + 자체가 아니다. 사용자: *"연산이 안 일어난다. 진짜 현 값이 맞음."* +- 옛 결정의 셋째 근거(**"값을 바꾸는 연산엔 `:` 체이닝 허용"** 원칙, + `base/architecture.md`)는 그대로 유지된다 — `store:Of(k):Set(v)`가 + 그 사례다. +- **`myStore "key"`(문자열 커링)는 여전히 기각**이다(2026-08-18 사용자 + 판정) — *"저러면 "a" 가 string 으로 들어가서, Source 의 타입을 모르기도 + 하고, 우린 더이상 필요하지 않게 된 요소임."* 동적 키는 아래 + `:GetDynamic`으로 간다. + +`base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께 +읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 +"불변 빌더"를 흉내낸 것이었지, `:` 체이닝 문법 자체나 대입 문법 자체가 +아니었음. + +## 타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정 (2026-08-04 3차 라운드, 2026-08-25 재작성) + +- `store "key"`(문자열 커링)로 `state`를 오버로드 함수 타입으로 정확히 + 추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**, + 위 절), **`store.key`(dot-access)를 1급 경로로 확정**. Store 타입을 + `index<>`/`keyof<>`로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, + 문자열 리터럴 narrowing 문제 자체가 안 생김(구체적인 모양은 아래 + "`store.key` 레코드 필드 타이핑" 절). +- **동적 키 경로는 명시적 메소드다** — `store:GetDynamic<>(name): Source`. + 런타임 동작 자체는 dot-access와 같고 문제는 **타입**뿐이었다: 선언되지 + 않은 이름은 결과 타입에 없어서 타입 에러가 난다(그게 방어선이라는 게 + 사용자 확정). 그래서 "런타임에 이름이 정해지는" 정당한 용도를 위해 + **타입을 호출자가 직접 주는 명시적 창구**를 둔다 — 사용자 판정: + *"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐. 타입 애러가 난다는 + 점인데, 이는 GetDynamic(name): Source 로 제공하는게 최선으로 보임."* + - **⭐ [2026-08-25 실측 정정] `<>`는 값 호출부에서 동작한다.** + 7라운드 `H-73`이 *"Luau엔 호출부 명시 타입 인자 문법이 없다"*고 + 단정했으나 **틀렸다** — Luau의 **generic type instantiation** + (`luau.org/types/generics/#generic-type-instantiation`)이 값 호출부에서도, + 콜론 메소드에서도 `T`를 실제로 묶는다. `luau-analyze` 실측: + ```lua + local ok: Source = store:GetDynamic<>("x") -- 진단 없음 + local bad: Source = store:GetDynamic<>("y") -- 정확히 걸림 + local none: Source = store:GetDynamic("z") -- Source + ``` + 원문은 **인스턴스화를 생략한 호출만** 돌려보고 단정했다. 따라서 + `base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다. +- **⭐ [2026-08-25 확정] 표면은 전부 콜론 메소드이고, 예약 키 충돌은 + 타입 함수가 잡는다.** 예약 이름은 `Of`/`Names`/`GetDynamic`/`__store` + (팬텀 필드)다. + - **안 잡으면 조용히 죽는다** — 실측에서 사용자 키가 예약 이름과 겹치면 + 교집합이 뭉개져 **그 필드의 타입 검사가 통째로 꺼진다**(음성 대조군이 + 진단 0건으로 통과했다). 시끄럽게 막히는 게 아니라 그냥 지나간다. + - 그래서 `T`를 **검증만 하고 그대로 통과시키는** 작은 `type function`을 + 둔다. 겹치면 사용 지점에 + `TypeError: quad.Store: "Of" is a reserved key`가 뜬다. + ```lua + type function CheckReserved(t: type): type + -- 겹치면 print(...) + return types.never, 아니면 t를 그대로 반환 + end + type Store = StoreOf> + ``` + - **`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼 + 버려진다. `print(...)` + `return types.never` 조합만 된다 + (`luau-test/done/23-type-quadtypes-checkversion-addplugin.luau`가 + 기록해둔 사실이고 `type-version-check`가 이미 쓰는 패턴). + - **[2026-08-25 해소] 옛 "탑레벨 함수로 옮길지" 미결은 닫혔다** — + 콜론 유지로 확정했고, 예약 키 문제는 위 타입 함수가 받는다. 팬텀 + 필드(`__store`)의 사용은 UB로 둔다. +- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 + 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 + **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). + `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" + 절이 최신 확정 내용. + +### `store.key` 레코드 필드 타이핑 — `index<>`/`keyof<>`로 해결 (2026-08-25 재작성, `WrapStore` 폐기) + +> **⭐⭐ [2026-08-25] 옛 `WrapStore`/`ProcessStoreType` 타입 함수 접근은 +> 폐기됐다.** 원문은 이 파일(당시 이름은 store-source-record-model-reversed.md였다). +> 7라운드 `H-75`/`H-76`이 그 접근의 두 한계를 실측했는데(평평한 선언이면 +> `store.key:Compute(무주석 콜백)`이 깨짐 / `type function`이 바깥 타입 +> 별칭을 참조 못 해 구조를 통째로 중복 작성해야 하고 메소드 self +> 파라미터가 불변이라 필드 하나만 어긋나도 대입 실패), 아래 모양은 +> **결과 타입을 합성하지 않으므로** 두 한계에 아예 안 닿는다. + +**확정 형태** — 팬텀 필드에 선언 원형을 싣고, `index<>`/`keyof<>`로 꺼낸다. + +```lua +type StoreOf = { + __store: T, -- 팬텀. 사용은 UB. + Of: (self: Self, key: K & keyof>) + -> Source, K>>, + Names: (self: Self) -> { keyof> }, + GetDynamic: (self: any, name: string) -> Source, +} & T +type Store = StoreOf> +``` + +- `& T`가 dot-access(`store.hp`)를 그대로 만들어준다 — 타입 인자가 + **평범한 값 타입**이므로 `store.hp`가 `number`다. +- `K & keyof<...>`가 **싱글톤을 보존**한다. 사용자가 실측으로 확인한 + 성질이다: 타입 후보 중에 싱글톤이 있으면 `string`으로 뭉개지지 않고 + `"hp"` 그대로 넘어간다. 그래서 `index`가 그 키의 실제 타입을 + 뽑아낸다. +- **`luau-analyze` 실측**(전부 `--!strict`, 양성 + 음성 대조군): + + | 검사 | 결과 | + |---|---| + | `store.hp` | `number` ✅ (음성 대조군 걸림) | + | `store.hp = 5` / `= "five"` | 통과 / **걸림** ✅ | + | `store:Of("hp")` | `Source` ✅ (음성 대조군 걸림) | + | `store:Of("name")`(`string?` 선언) | `Source` ✅ | + | `store:Of("nope")` | **거부** ✅ | + | `store:Names()` | `{ "hp" \| "name" }` ✅ | + | `store:Of("hp"):Set(3)` / `:Set("3")` | 통과 / **걸림** ✅ | + | `Store<{ Of: number }>` | **`"Of" is a reserved key`** ✅ | + | `store:Of("hp"):Compute(무주석 콜백)` | ⚠️ **파라미터 주석 필요**(아래 캐비엇) | + +- **⚠️ [2026-08-25 정정] `store.key:Compute(…)`라고 적었던 표 행은 거짓이었다.** + 실측한 적이 없었고(`/code-review high`가 잡음), 애초에 새 모델에서 + `store.key`는 **값**이라 `:Compute`가 없다 + (`Type 'number' does not have key 'Compute'`). 파생은 프리미티브를 + 거친다 — `store:Of("hp"):Compute(...)`. +- **⭐ `store:Of(k)`의 반환값 추론은 무주석으로 정확하다 — Store 특유의 + 타입 문제는 없다.** 실측(`audit/type-store-index-keyof/spikes/08`): + ```lua + local hp = store:Of("hp") -- 무주석 + local a: number = hp:Get() -- ✅ (string 대조군은 정확히 걸림) + hp:NoSuchMethod() -- ✅ 걸림 + hp:Set(5) -- ✅ (`:Set("5")`는 걸림) + local nm = store:Of("name") -- Source ← `?` 선언이 그대로 드러남 + ``` +- **걸리는 자리는 하나 — `:Compute`/`:With`의 콜백 파라미터 무주석 추론**이고, + 그건 Store가 만든 게 아니라 `base/typing-limits.md` §1의 **문제 A** 그 + 자체다. 해법도 그 문서가 정한 그대로 — **파라미터에 주석을 단다**(실측 + 통과): + ```lua + hp:Compute(function(s) return s:Get() * 2 end) -- ❌ 진단 + hp:Compute(function(s: StateData) return s:Get() * 2 end) -- ✅ + ``` + §1②쪼개기가 평범한 `Source`에서 이 자리를 덮어주는 것과 달리 `Of`를 + 거치면 안 걸린다(`Self` 제네릭 때문 — `spikes/07`). `& T` 형태에서 + `Self`를 빼는 건 불가능하고(`self: any`는 탐색 기록의 `Store4`처럼 통째로 + 깨진다), §0의 *"Luau의 한계를 우회하려고 타입/API를 비틀지 않는다"*에 + 따라 표면을 비틀지 않는다. +- **파생 결과에 명시 주석을 다는 상시 규약은 그대로 유효하다** — + `audit/type-recursion-issue/REPORT.md` 3-1절이 실측(`43`)으로 확인한 + 대로, 명시 주석 이후 **다운스트림 전체는 정상 체크**되고 구멍은 그 한 + 줄(RHS가 실제로 그 타입인지)뿐이다. +- **`__call` 경로는 죽었다** — 타입 레벨 `__call`은 `self`를 못 받고, + `typeof(f<>)`로 타입 인자를 넘기는 것도 실패한다(사용자 실측). + 그래서 Store를 콜러블로 만드는 안은 성립하지 않는다. 같은 사실이 + `base/source-state-plan.md`의 `:Apply`에도 적용된다(7라운드 `H-94`). + +--- + +## 이 시도 이전의 원문은 archive에 없다 + +옛 `WrapStore`/`ProcessStoreType` 접근(2026-08-12~2026-08-15)은 `H-75`/`H-76` +실측으로 폐기됐고, 그 폐기는 **철회되지 않았다** — `base/typing-limits.md` +§5의 폐기 배너와 `luau-test/STATUS.md`의 `16`/`21` 항목이 소스다. +`store.key = value` 폐기(2026-08-06)와 dot-access 1급 경로도 **현행**이라 +`base/store-plan.md`에 그대로 있다. diff --git a/.claude/audit/luau-test-first-run-2026-08-13.md b/.claude/audit/luau-test-first-run-2026-08-13.md index 1b42555..8078aa7 100644 --- a/.claude/audit/luau-test-first-run-2026-08-13.md +++ b/.claude/audit/luau-test-first-run-2026-08-13.md @@ -124,7 +124,7 @@ relate4의 살아있는 엔트리 총 개수: 0 (기대 0) | `13-type-ref-preref-subtype` | ✅ 통과 | `PreRef`가 `Ref` 자리에 대입 가능 — 진단 0건 | | `14-type-nilable-default-overload` | ⚠️ 부분통과 | 의도한 오용은 정확히 막지만 **정상 nilable 사용례까지 같이 막아** 현 스케치로는 채택 불가 | | `15-type-compute-trailing-deps-typepack` | ❌ 검증불가 | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 파일 전체가 파싱 실패. 다만 파서가 복구 후 낸 진단에서 **아래 1번 이슈**가 드러남 | -| `16-type-store-key-typefunction` | ❌ 실패(당시) → **[2026-08-15] ✅ 통과로 복구** | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음(레코드 대신 배열을 넘기고 있었음) — 설계 문제 아니라 API 버전 드리프트였음이 나중에 확인됨. 상세: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절, `luau-test/done/16-type-store-key-typefunction.luau` | +| `16-type-store-key-typefunction` | ❌ 실패(당시) → **[2026-08-15] ✅ 통과로 복구** | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음(레코드 대신 배열을 넘기고 있었음) — 설계 문제 아니라 API 버전 드리프트였음이 나중에 확인됨. 상세: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절, `luau-test/rewrite-required/16-type-store-key-typefunction.luau` | ## ⚠️ 실측으로 드러난 진짜 설계 이슈 — `:Compute(fn)`의 lazy 핸들 계약이 Luau 추론과 충돌 diff --git a/.claude/audit/type-recursive-issue-with-typeof/REPORT.md b/.claude/audit/type-recursive-issue-with-typeof/REPORT.md index 0f3080b..3192ec5 100644 --- a/.claude/audit/type-recursive-issue-with-typeof/REPORT.md +++ b/.claude/audit/type-recursive-issue-with-typeof/REPORT.md @@ -197,7 +197,7 @@ quad 설계 문제가 아니라 **`setmetatable`+`typeof(genericFn<>())` ## 6. `type function` — `Store` 필드 합성은 고쳐짐, 재귀 `Compute`는 막다른 길 -### 6-1. `luau-test/done/16-type-store-key-typefunction.luau` 복구 성공(원래 `rewrite-required/`) +### 6-1. `luau-test/rewrite-required/16-type-store-key-typefunction.luau` 복구 성공(원래 `rewrite-required/`) `spikes/09-typefunction-store-key-FIXED.luau` — 원래 스파이크가 깨진 이유는 설계 문제가 아니라 **API 버전 드리프트**: `types.newfunction`의 diff --git a/.claude/audit/type-store-index-keyof/REPORT.md b/.claude/audit/type-store-index-keyof/REPORT.md new file mode 100644 index 0000000..f56d1cf --- /dev/null +++ b/.claude/audit/type-store-index-keyof/REPORT.md @@ -0,0 +1,201 @@ +# 실측 — Store 타이핑을 `index<>`/`keyof<>`로 다시 짜기 (2026-08-25) + +> **⛔⛔ [2026-08-25, 같은 날] 이 실측이 뒷받침하던 설계는 철회됐다.** +> 여기 기록된 **측정값 자체는 전부 유효**하다(재현 가능). 다만 그 위에 +> 세웠던 결론 — "`index<>`/`keyof<>` + 팬텀 필드로 Store를 짓는다" — 은 +> 같은 날 철회됐고, 지금 Store는 **타입 함수를 안 쓰고** 타입 인자에 +> `Source`를 직접 써서 평범한 레코드로 짓는다(`base/store-plan.md`). +> 철회 이유는 `archive/store-value-field-redesign-withdrawn.md`, +> 원칙은 `base/typing-limits.md` §0(*"타입 함수는 진단까지만"*). +> +> **그래도 이 문서를 남기는 이유**: (1) `H-73` 반증(`f<>()`가 값 +> 호출부에서 동작)은 지금도 유효한 확정 사실이고, (2) 실패한 formulation +> 표는 "다시 시도하지 말 것" 목록으로 계속 값을 하며, (3) 싱글톤 보존 +> 규칙과 `__call`이 죽은 경로라는 것도 Luau 자체의 성질이라 그대로다. +> (4) `spikes/07`이 잰 **`Self` 제네릭이 콜백 파라미터 추론을 깬다**는 +> 것이 철회의 직접 근거 중 하나였다. + +**무엇인가**: `WrapStore`/`ProcessStoreType` 타입 함수로 결과 타입을 +**합성**하던 Store 타이핑을 폐기하고 `index<>`/`keyof<>` + 팬텀 필드로 +다시 지을 수 있는지를 `luau-analyze`로 판정한 기록. **계획이 아니라 +실측 결과**다 — 지금 유효한 설계는 `base/store-plan.md`가 소스이고, +뒤집힌 옛 모델은 `archive/store-value-field-redesign-withdrawn.md`. + +**발단**: 7라운드 손 트레이싱 `H-75`/`H-76`이 `WrapStore` 접근의 두 한계를 +실측했고(평평한 합성이면 `store.key:Compute(무주석 콜백)`이 깨짐 / +`type function`이 바깥 타입 별칭을 참조 못 해 구조를 통째로 중복 작성해야 +하고 self 파라미터 불변성 때문에 필드 하나만 어긋나도 대입 실패), +**사용자가 직접 `test.luau`로 대안을 탐색**했다. + +**⚠️ 스크립트를 같이 두는 예외적 구성** — 판정이 "여러 formulation 대조"라 +개별 파일을 직접 돌려야 재현된다(`audit/type-recursion-issue/`와 같은 이유). +전부 `luau-analyze <파일>`로 돌린다. **[2026-08-25 기준] `.luaurc`가 +`languageMode: strict`이므로 진단이 이 프로젝트에 그대로 적용된다.** + +| 파일 | 무엇을 보나 | +|---|---| +| `spikes/01-exploration.luau` | **사용자 탐색 원본**(루트 `test.luau`). `const` → `local`만 고쳤다. 주석에 적힌 기대 진단이 곧 판정이고, 아래 "실패한 formulation" 절이 이 파일을 읽는 지도다 | +| `spikes/02-toplevel-form.luau` | 조작 표면을 **탑레벨 함수**로 뺀 형태 — 예약 키가 팬텀 하나로 줄어드는지 | +| `spikes/03-value-index-form.luau` | **확정된 형태** — 타입 인자가 평범한 값 타입이고 `Of`가 `Source<>`를 씌움 | +| `spikes/04-reserved-key-silent.luau` | 예약 키 충돌 시 **타입 검사가 조용히 죽는지** | +| `spikes/05-checkreserved.luau` | `CheckReserved` 타입 함수가 그 침묵을 **시끄럽게** 만드는지 | +| `spikes/06-generic-instantiation.luau` | `f<>()`가 값 호출부·콜론 메소드에서 `T`를 실제로 묶는지(`H-73` 반증) | +| `spikes/07-self-generic-inference.luau` | **[2026-08-25 추가]** 무주석 콜백 추론이 어느 구성에서 깨지는지 — `Self` 제네릭만 걸리고 `index<>`/`keyof<>`/`K` 제네릭은 안 걸린다 | + +--- + +## 결론 + +### ✅ 성립 — 마법 타입 함수 없이 Store 전체가 타이핑된다 + +```lua +type StoreOf = { + __store: T, -- 팬텀. 사용은 UB. + Of: (self: Self, key: K & keyof>) + -> Source, K>>, + Names: (self: Self) -> { keyof> }, +} & T +type Store = StoreOf> +``` + +`spikes/03`의 양성 6건이 전부 진단 0건이고 음성 대조군 5건이 전부 정확히 +걸렸다(**[2026-08-25 정정]** 아래 표의 마지막 두 행 — `:Names()`와 +`Compute` — 은 `03`이 아니라 각각 `02`/`07`이 근거다): + +| 검사 | 결과 | +|---|---| +| `store.hp` | `number` ✅ (음성 대조군 걸림) | +| `store.hp = 5` / `= "five"` | 통과 / **걸림** ✅ | +| `store:Of("hp")` | `Source` ✅ (음성 대조군 걸림) | +| `store:Of("name")`(`string?` 선언) | `Source` ✅ | +| `store:Of("nope")` | **거부** ✅ | +| `store:Names()` | `{ "hp" \| "name" }` ✅ | +| `store:Of("hp"):Set(3)` / `:Set("3")` | 통과 / **걸림** ✅ | +| `store:Of("hp"):Compute(무주석 콜백)` | ⚠️ **파라미터 주석 필요**(아래 캐비엇) | + +**⚠️ [2026-08-25 정정] 이 표에 있던 +*"`store.key:Compute(무주석 콜백)` 파라미터 추론 정상 ✅"* 행은 **실측한 적이 +없고 거짓이었다**(`/code-review high`가 잡음 — `spikes/03`엔 `Compute`가 +아예 없다). 새 모델에서 `store.key`는 **값**이라 `:Compute`가 없고 +(`Type 'number' does not have key 'Compute'`), 파생은 +`store:Of("hp"):Compute(...)`로 만든다. + +**정본 선언으로 다시 재보니 두 축이 갈린다**(`spikes/08`): + +| 검사 | 결과 | +|---|---| +| `local hp = store:Of("hp")` → `hp:Get()` | **`number`** ✅ (무주석, `string` 대조군 걸림) | +| `hp:NoSuchMethod()` | ✅ 걸림 | +| `hp:Set(5)` / `hp:Set("5")` | 통과 / **걸림** ✅ | +| `store:Of("name")`(`string?` 선언) | `Source` ✅ (`number` 대조군 걸림) | +| `hp:Compute(function(s) … end)` | ❌ **콜백 파라미터**만 안 잡힘 | +| `hp:Compute(function(s: StateData) … end)` | ✅ 통과 | + +- **반환값 추론은 무주석으로 정확하다** — `store:Of(k)` 자체엔 Store 특유의 + 타입 문제가 **없다**. +- **걸리는 건 `:Compute`/`:With`의 콜백 파라미터 하나**이고, 그건 Store가 + 만든 게 아니라 `base/typing-limits.md` §1의 **문제 A** 그 자체다. + 해법도 그 문서가 정한 대로 파라미터 주석이고, 실측으로 통과한다. +- §1②쪼개기가 평범한 `Source`에서 이 자리를 덮어주는 것과 달리 `Of`를 + 거치면 안 걸린다(`spikes/07` — `Self` 제네릭). `& T` 형태에서 `Self`를 + 빼는 건 불가능하다(`self: any`는 `Store4`처럼 통째로 깨진다). §0의 + *"Luau의 한계를 우회하려고 타입/API를 비틀지 않는다"*에 따라 표면을 + 비틀지 않는다. +- 파생 결과에 명시 주석을 다는 상시 규약은 그대로다 — + `audit/type-recursion-issue/REPORT.md` 3-1절(`43`)이 확인한 대로 명시 + 주석 이후 다운스트림 전체는 정상 체크된다. + +별도로 확인한 것 하나: `:Compute`의 **반환 타입** 음성 대조군은 평범한 +`Source`에서도 안 걸리는데, 그건 이 Store 설계와 무관한 `Compute` 시그니처 +쪽 성질이다(과잉 주장하지 말 것). + +### ⭐ 싱글톤 보존 규칙 — 어떤 형태가 `string`으로 안 뭉개지는가 + +이 설계 전체가 **`K`가 `"hp"` 같은 싱글톤으로 넘어온다**는 데 기대고 있어서 +따로 실측했다(`spikes/01`의 `singletonTest1`~`4`). + +| 선언 | `f("bbb")`의 `T` | 진단 | +|---|---|---| +| `f(input: T): T` | `string` ❌ 뭉개짐 | 없음 | +| `f(input: T & string): T` | `unknown` ❌ | 없음 | +| `f(input: T & ""): T` | `"bbb"` ✅ | **에러**(`"bbb"`가 `""`의 서브타입이 아님) | +| **`f(input: T \| "" \| string): T`** | `"bbb"` ✅ | **없음** ✅ | +| **`f(input: T & ("A" \| "B")): T`** | `"B"` ✅ | 없음(범위 밖이면 정확히 걸림) ✅ | + +**규칙**: 타입 후보 중에 **싱글톤이 있으면** 추론이 `string`으로 안 뭉개고 +있는 그대로 넘긴다. `K & keyof<...>`가 바로 그 형태라(키 이름들의 싱글톤 +유니온) 의도대로 동작하고, **범위 밖 키는 교집합이 비어 정확히 거부된다.** +사용자 관찰: *"K& 를 걸고 유니온 스트링을 걸면, K 가 싱글톤으로써, string 으로 +뭉개지지 않고 전해진다."* + +### ⛔ 실패한 formulation — 왜 다른 모양은 안 되는가 + +`spikes/01`이 순서대로 밟은 막다른 길들. 다시 시도하지 말 것: + +| 형태 | 무엇이 막았나 | +|---|---| +| `Store2`: `__call: typeof(getter2<>)` | **완전 실패** — `type function` 밖에서도 타입 별칭 인자를 `typeof`에 실어 나를 수 없다. `H-76`이 관측한 것과 같은 벽 | +| `Store`: `__call: (K & keyof) -> index` | `Argument count mismatch. Function expects 1 argument, but 1 is specified` — **타입 레벨 `__call`은 `self`를 못 받는다**(인자 목록만 남음) | +| `Store3`: `Set`/`Peek`을 **점 호출**로 | 동작은 하나 `Set`의 시그니처만 봐선 뭘 넣어야 할지 감이 안 온다(사용자 판정). 값 타입을 알기 어려워 직접 인덱스를 허용해야 함 | +| `Store4`: `{ Peek: (self: any, ...) } & T` | `Property 'any' does not exist on type '{...}'` — `& T`가 `self: any` 자리에 끼어들어 깨진다 | +| `Store5`: `self: Store5`로 self를 명시 | `Peek`의 인자가 **에러 타입**이 된다 | +| `Store6`: **`__realtype` 팬텀 + `index, K>`** | ✅ **성립** — 이게 확정 형태의 원형이다 | + +**`__call` 경로는 죽었다**는 게 이 표의 요지이고, 그 사실이 +`base/source-state-plan.md`의 `:Apply`(7라운드 `H-94`)와 +`base/gate-plan.md` 2번에 반영됐다 — 애플리커티브 팩토리는 `__call`이 +아니라 **지정된 필드**로 자기를 노출한다. + +### ⚠️ 예약 키 충돌은 **조용히** 타입 검사를 끈다 + +`spikes/04`에서 `Store<{ Peek: number, __store: boolean }>`로 걸었더니 +**진단 0건**인데, 그 안엔 걸려야 할 음성 대조군이 있다: + +```lua +local g3: string = clash.Peek -- Peek 은 number 인데 진단 없음 ❌ +``` + +즉 사용자 키가 예약 이름과 겹치면 교집합이 뭉개져 **그 필드에 대한 타입 +검사가 통째로 죽는다.** 시끄럽게 막히는 게 아니라 그냥 지나간다. + +### ✅ `CheckReserved`가 그 침묵을 없앤다 + +`spikes/05`에서 확인: + +``` +TypeError: quad.Store: "Of" is a reserved key +``` + +**`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼 버려진다. +`print(...)` + `return types.never` 조합만 된다 +(`luau-test/done/23-type-quadtypes-checkversion-addplugin.luau`가 기록해둔 +사실이고 `type-version-check`가 이미 쓰는 패턴). 이 타입 함수는 `T`를 +**검증만 하고 그대로 통과**시키므로 `H-75`/`H-76`이 지적한 합성의 두 한계에 +아예 안 닿는다. + +### ✅ `f<>()`가 값 호출부에서 동작한다 — `H-73` 반증 + +`spikes/06`: + +```lua +local ok: Source = store:GetDynamic<>("x") -- 진단 없음 ✅ +local bad: Source = store:GetDynamic<>("y") -- 정확히 걸림 ✅ +local none: Source = store:GetDynamic("z") -- Source +``` + +7라운드 `H-73`은 **인스턴스화를 생략한 호출만** 돌려보고 *"Luau엔 호출부 +명시 타입 인자 문법이 없다"*로 단정했다. 실제로는 Luau의 **generic type +instantiation**(`luau.org/types/generics/#generic-type-instantiation`)이 +값 호출부에서도, **콜론 메소드에서도** `T`를 묶는다. 그래서 +`base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다. + +--- + +## 이 실측이 바꾼 문서 + +- `base/store-plan.md` — "Store = Source들의 이름 붙은 모음" / "Store 값 설정 문법" / + "타입 추론 문제" / "`store.key` 레코드 필드 타이핑" 네 절 재작성 +- `base/typing-limits.md` — §5가 폐기 배너를 달았고, §7에 싱글톤/인스턴스화 항목 추가 +- `base/quad-types-plan.md` — 이중 꺾쇠 관례가 값 호출부까지 확장 +- `archive/store-value-field-redesign-withdrawn.md` — 뒤집힌 옛 모델 원문 +- `luau-test/STATUS.md` — 스파이크 `16`/`21`이 `rewrite-required/`로 diff --git a/.claude/audit/type-store-index-keyof/spikes/01-exploration.luau b/.claude/audit/type-store-index-keyof/spikes/01-exploration.luau new file mode 100644 index 0000000..aaece49 --- /dev/null +++ b/.claude/audit/type-store-index-keyof/spikes/01-exploration.luau @@ -0,0 +1,155 @@ +--!strict +-- [보존] 사용자가 2026-08-25에 직접 돌린 탐색 원본(루트 `test.luau`). +-- `const` -> `local`만 고쳤고 나머지는 그대로다. 주석의 기대 진단이 곧 판정. + +local function test2(k: K & ("A" | "B"), v: number): K + return nil :: any +end + +local t: "B" = test2("B", 1) +-- TypeError: Expected this to be '"A"', but got '"B"' +local t: "A" = test2("B", 1) -- 에러 발생 + +local function test3(k: K & ("A" | "B")): index<{ A: number }, K> + return nil :: any +end + +-- number +local a = test3("A") +-- TypeError: Expected this to be 'string', but got 'number'Luau1000 +local a: string = test3("A") + +local function test4(s: S, k: K & ("A" | "B")): index + return nil :: any +end + +-- boolean +local b = test4({ A = true}, "A") + + +-- now we should test index +local function getter(self: Self, key: K & (keyof>)): index, K> + return nil :: any +end + +local me = { + __realtype = { + name = true + }, + getter = getter +} +-- name 이 자동완성됩니다. 게다가 +me:getter("name") +local t = me:getter("name") -- boolean +-- 음성 대조해보면. 이렇습니다 +local t: string = me:getter("name") + +-- __call 에 대해서 시도해보죠 +local mock = (nil :: any) :: setmetatable<{ + __realtype: { + name: boolean, + } +}, { + __call: typeof(getter), +}> +-- mock() -- self 를 받지 못하네요. __call 은 타입에 인자만 남아야하나봅니다. + +type Store = setmetatable<{ +}, { + __call: (K & keyof) -> index +}> +local sinst = (nil :: any) :: Store<{ + a: boolean, + b: number, +}> +-- TypeError: Argument count mismatch. Function expects 1 argument, but 1 is specifiedLuau1008 +-- 이해가 안가지만, 아마 어디선가 문제를 일으키나보네요 +local b = sinst("a") + +-- 완전히 실패합니다. 이전에 확인한것과 같게, T는 넘어갈 수 없다는 논리 +local function getter2(key: K & (keyof)): index + return nil :: any +end +type Store2 = setmetatable<{ +}, { + __call: typeof(getter2<>) +}> +local sinst2 = (nil::any)::Store2<{ + a: boolean, b: number +}> +sinst2() + +-- 따라서 이것이 최선일 수 있어요 +type Store3 = { + Set: (key: K & keyof, value: index) -> Store3, + Peek: (key: K & keyof) -> index, +} +local sinst3 = (nil::any)::Store3<{ + test: boolean +}> +-- 에러가 남. 다만, set 의 시그니처 보기로는 뭘 넣어야할지 바로 감이 안 잡힘. +sinst3.Set("test", 1) -- test 가 자동완성됩니다 +-- Set 에 있어서는, 값이 뭔지 알기 힘들기에 직접 인덱스를 허용해야함. + + +type Store4 = { + Peek: (self: any, key: K & keyof) -> index, +} & T +local sinst4 = (nil :: any) :: Store4<{ + test: boolean +}> +-- TypeError: Property 'any' does not exist on type '{ test: boolean }'Luau1038 +-- T 가 들어간 것이 self: any 자리에 들어가니, 에러가 납니다 +sinst4:Peek("test") + + +type Store5 = { + Peek: (self: Store5, key: K & keyof) -> index, +} & T +-- peek 의 인자가 에러타입이 됨 +local sinst5 = (nil :: any) :: Store5<{ + test: boolean +}> + + +type Store6 = { + __realtype: T, + Peek: (self: Self, key: K & keyof>) -> index, K>, +} & T +-- peek 의 인자가 에러타입이 됨 +local sinst6 = (nil :: any) :: Store6<{ + test: boolean +}> +-- TypeError: Expected this to be 'number', but got 'boolean'Luau1000 +local aaa: number = sinst6:Peek("test") +sinst6:Peek("bbb") -- TypeError: Property '"bbb"' does not exist on type '{ test: boolean }' + +-- 이것으로 남는 결론: K& 를 걸고 유니온 스트링을 걸면, K 가 싱글톤으로써, string 으로 뭉개지지 않고 전해진다. +-- K & (...) 상태에서 bbb 를 넣을 순 있음. 그치만 잘 에러가 남. 보면 그대로 index 함수로 잘 싱글톤으로 들어간다는게 이상하게 보이는 부분. + +local function singletonTest1(input: T): T + return input +end +local singletonTest1v = singletonTest1("bbb") -- string + +local function singletonTest2(input: T & string): T + return input +end +local singletonTest2v = singletonTest2("bbb") -- unknown + +local function singletonTest2(input: T & ""): T + return input +end +local singletonTest2v = singletonTest2("bbb") -- "bbb" +-- TypeError: Expected this to be '"" & "bbb"', but got '"bbb"'; +-- the 2nd component of the intersection is `""`, and `"bbb"` is not a subtype of `""` +-- **여기서 얻는점: 타입 후보 중에서 싱글톤이 있으면, 싱글톤으로 들어간다. 아마도 +-- 이것은 싱글톤 중에서 메치를 해야하니, 있는 그대로 string 으로 뭉개지 않고 bbb 로 넘기는듯 +-- 싱글톤으로 넘기는 조건이 있다는것이다 + +local function singletonTest3(input: T | "" | string): T + return input +end +local singletonTest3v = singletonTest3("bbb") -- "bbb", 에러도 없다. +local singletonTest4v: "a" = singletonTest3("bbb") -- TypeError: Expected this to be '"a"', but got '"bbb"' +-- 확실하다. diff --git a/.claude/audit/type-store-index-keyof/spikes/02-toplevel-form.luau b/.claude/audit/type-store-index-keyof/spikes/02-toplevel-form.luau new file mode 100644 index 0000000..5e65c45 --- /dev/null +++ b/.claude/audit/type-store-index-keyof/spikes/02-toplevel-form.luau @@ -0,0 +1,31 @@ +--!strict +type Source = { Value: T, Set: (self: Source, v: T) -> Source } + +-- 예약 키를 팬텀 하나로 줄인다. Peek/Set/Names 는 전부 탑레벨. +type Store = { __store: T } & T + +local function peek(s: Self, key: K & keyof>) + : index, K>, "Value"> + return nil :: any +end +local function names(s: Self): { keyof> } + return nil :: any +end + +local store = (nil :: any) :: Store<{ hp: Source, name: Source }> + +local f: Source = store.hp -- 직접 접근 +local g: Source = store.hp -- 음성 대조군 + +local a: number = peek(store, "hp") +local b: string = peek(store, "hp") -- 음성 대조군 +local c: string = peek(store, "name") +local d = peek(store, "nope") -- 음성 대조군 +local n: { "hp" | "name" } = names(store) + +-- 사용자가 Peek / Set / Names 를 키로 써도 되는가 +local free = (nil :: any) :: Store<{ Peek: Source, Set: Source, Names: Source }> +local p: number = peek(free, "Peek") +local q: boolean = peek(free, "Set") +local w: string = peek(free, "Names") +local z: number = peek(free, "Set") -- 음성 대조군 diff --git a/.claude/audit/type-store-index-keyof/spikes/03-value-index-form.luau b/.claude/audit/type-store-index-keyof/spikes/03-value-index-form.luau new file mode 100644 index 0000000..2793140 --- /dev/null +++ b/.claude/audit/type-store-index-keyof/spikes/03-value-index-form.luau @@ -0,0 +1,34 @@ +--!strict +type Source = { Value: T, Get: (self: Source) -> T, Set: (self: Source, v: T) -> Source } + +-- T 는 평범한 값 타입. __index/__newindex 가 Get/Set 을 대행하고, +-- Peek 이 Source<> 를 씌워 준다. +type Store = { + __store: T, -- 팬텀. 사용은 UB. + Peek: (self: Self, key: K & keyof>) + -> Source, K>>, +} & T + +local store = (nil :: any) :: Store<{ hp: number, name: string }> + +-- (1) __index → 값 +local a: number = store.hp +local b: string = store.hp -- 음성 대조군 + +-- (2) __newindex → 대입 +store.hp = 5 +store.hp = "five" -- 음성 대조군 + +-- (3) Peek → Source<값> +local c: Source = store:Peek("hp") +local d: Source = store:Peek("hp") -- 음성 대조군 +local e: Source = store:Peek("name") +local f = store:Peek("nope") -- 음성 대조군 + +-- (4) Peek 결과를 반응형으로 씀 +store:Peek("hp"):Set(3) +store:Peek("hp"):Set("3") -- 음성 대조군 + +-- (5) 사용자가 Peek 을 자기 키로 쓰면 +local clash = (nil :: any) :: Store<{ Peek: number }> +local g = clash:Peek("Peek") diff --git a/.claude/audit/type-store-index-keyof/spikes/04-reserved-key-silent.luau b/.claude/audit/type-store-index-keyof/spikes/04-reserved-key-silent.luau new file mode 100644 index 0000000..ad756e4 --- /dev/null +++ b/.claude/audit/type-store-index-keyof/spikes/04-reserved-key-silent.luau @@ -0,0 +1,14 @@ +--!strict +type Source = { Value: T, Get: (self: Source) -> T, Set: (self: Source, v: T) -> Source } +type Store = { + __store: T, + Peek: (self: Self, key: K & keyof>) + -> Source, K>>, +} & T + +-- 사용자 키 이름이 예약 이름과 겹칠 때 +local clash = (nil :: any) :: Store<{ Peek: number, __store: boolean }> +local g1: Source = clash:Peek("Peek") -- 메소드로 부를 수 있나 / 무엇이 나오나 +local g2: number = clash.Peek -- 필드로 읽으면 +local g3: string = clash.Peek -- 음성 대조군 +local g4 = clash:Peek("__store") -- 팬텀 이름을 키로 diff --git a/.claude/audit/type-store-index-keyof/spikes/05-checkreserved.luau b/.claude/audit/type-store-index-keyof/spikes/05-checkreserved.luau new file mode 100644 index 0000000..0d91704 --- /dev/null +++ b/.claude/audit/type-store-index-keyof/spikes/05-checkreserved.luau @@ -0,0 +1,36 @@ +--!strict +type Source = { Value: T, Get: (self: Source) -> T, Set: (self: Source, v: T) -> Source } + +type function CheckReserved(t: type): type + if t:is("table") then + for k in t:properties() do + if k:is("singleton") then + local name = k:value() + if name == "Of" or name == "__store" then + print("quad.Store: \"" .. tostring(name) .. "\" is a reserved key") + return types.never + end + end + end + end + return t +end + +type StoreOf = { + __store: T, + Of: (self: Self, key: K & keyof>) + -> Source, K>>, +} & T +type Store = StoreOf> + +-- 정상 +local good = (nil :: any) :: Store<{ hp: number, name: string? }> +local a: number = good.hp +local b: string? = good.name +local c: Source = good:Of("hp") +local d: Source = good:Of("name") +local e: Source = good:Of("name") -- 음성 대조군 + +-- 예약 키 충돌 → never 로 무너뜨림 +local bad = (nil :: any) :: Store<{ Of: number }> +local f = bad.Of diff --git a/.claude/audit/type-store-index-keyof/spikes/06-generic-instantiation.luau b/.claude/audit/type-store-index-keyof/spikes/06-generic-instantiation.luau new file mode 100644 index 0000000..7584516 --- /dev/null +++ b/.claude/audit/type-store-index-keyof/spikes/06-generic-instantiation.luau @@ -0,0 +1,15 @@ +--!strict +type Source = { Value: T, Revision: number } +type Store = { + GetDynamic: (self: Store, name: string) -> Source, + [string]: any, +} + +local store = (nil :: any) :: Store + +-- 양성: 명시 인스턴스화가 T를 묶는가 +local ok: Source = store:GetDynamic<>("x") +-- 음성 대조군: 묶였다면 여기서 반드시 걸려야 한다 +local bad: Source = store:GetDynamic<>("y") +-- 인스턴스화 생략하면? +local none: Source = store:GetDynamic("z") diff --git a/.claude/audit/type-store-index-keyof/spikes/07-self-generic-inference.luau b/.claude/audit/type-store-index-keyof/spikes/07-self-generic-inference.luau new file mode 100644 index 0000000..a84b68a --- /dev/null +++ b/.claude/audit/type-store-index-keyof/spikes/07-self-generic-inference.luau @@ -0,0 +1,31 @@ +--!strict +type State = { Value: T, Get: (self: State) -> T, + Compute: (self: State, fn: (v: T) -> U) -> State } +type Source = State & { Set: (self: Source, v: T) -> Source } +type Rec = { hp: number } + +-- 1) 제네릭 없이 그냥 Source 를 주는 함수 +local function direct(): Source return nil :: any end +local r1 = direct():Compute(function(v) return v + 1 end) + +-- 2) index<> 만 거친 것 (Self 없음, K 없음) +local function viaIndex(): Source> return nil :: any end +local r2 = viaIndex():Compute(function(v) return v + 1 end) + +-- 3) K 제네릭 + index (Self 없음) +local function viaK(key: K & keyof): Source> return nil :: any end +local r3 = viaK("hp"):Compute(function(v) return v + 1 end) + +-- 4) Self 제네릭까지 (지금 확정 형태) +type Store = { __store: T, + Of: (self: Self, key: K & keyof>) + -> Source, K>> } & T +local store = (nil :: any) :: Store +local r4 = store:Of("hp"):Compute(function(v) return v + 1 end) + +-- 5) 중간 변수에 명시 주석을 달면? +local s5: Source = store:Of("hp") +local r5 = s5:Compute(function(v) return v + 1 end) + +-- 6) 콜백 파라미터에 주석 +local r6 = store:Of("hp"):Compute(function(v: number) return v + 1 end) diff --git a/.claude/audit/type-store-index-keyof/spikes/08-canonical-decl-annotation.luau b/.claude/audit/type-store-index-keyof/spikes/08-canonical-decl-annotation.luau new file mode 100644 index 0000000..56a9da2 --- /dev/null +++ b/.claude/audit/type-store-index-keyof/spikes/08-canonical-decl-annotation.luau @@ -0,0 +1,32 @@ +--!strict +export type StateData = { Get: (self: StateData) -> T } +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} +export type Source = State & { Value: T, Set: (self: Source, v: T) -> Source } +type Rec = { hp: number, name: string? } +type Store = { + __store: T, + Of: (self: Self, key: K & keyof>) + -> Source, K>>, +} & T +local store = (nil :: any) :: Store + +-- (A) 반환값 무주석 추론 — 먹히는가? +local hp = store:Of("hp") +local a1: number = hp:Get() -- 통과해야 함 +local a2: string = hp:Get() -- 음성 대조군: 걸려야 함 +local a3 = hp:NoSuchMethod() -- 음성 대조군: 걸려야 함 +hp:Set(5) +hp:Set("5") -- 음성 대조군: 걸려야 함 + +-- (B) name 은 string? 인가 +local nm = store:Of("name") +local b1: string? = nm:Get() +local b2: number = nm:Get() -- 음성 대조군 + +-- (C) Compute 콜백 파라미터 무주석 (문제 A) +local c1 = hp:Compute(function(s) return s:Get() * 2 end) + +-- (D) 콜백 파라미터에 주석을 달면 +local d1 = hp:Compute(function(s: StateData) return s:Get() * 2 end) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 2da358e..39a5e9a 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -262,8 +262,9 @@ quad/ │ └── src/ │ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션) │ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행)/`:Gate`(`GateNode`, `ComputeNode`와 같은 층위 — `base/gate-plan.md`) 전부 여기 소속 +│ ├── Observer.luau # ⭐ [2026-08-25 신설, 7라운드 `H-99`] `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()` 전역 레지스트리의 소유 모듈** — `EpochMap.luau`와 같은 이유로 `State.luau`에 묻지 않는다(`Effect`/`Gate`/leaf 핸들러가 전부 이 레지스트리를 본다) │ ├── EpochMap.luau # 재사용 가능한 Epoch 부기 객체(`:Update`/`:Refresh`/`:Sync`/`:TrackFrom`) — `State.luau`에 묻지 않고 별도 모듈, `GateNode`/`State`/`Effect`가 전부 씀(`base/state-epoch-plan.md`) -│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환 +│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환(**평범한 레코드 필드** — 타입 함수 안 씀). **[2026-08-25]** 생성은 **명시적 초기화**(타입 인자에 `Source` 직접, `defaults`에도 `Source(v)` 직접 — 옛 lazy `__index` 폐기), 동적 키는 `:Of<>(name)` 하나(옛 `GetDynamic` 흡수), `:Names()`, 예약 키 진단용 `CheckReserved`(`base/store-plan.md`) │ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`) — 위 `state:Gate`의 `GateNode` 위에 얹히는 **정책**, 바닥부터 짜지 않음 │ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`) │ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`) @@ -316,6 +317,53 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로 확정을 막던 항목도 아니었음(`purity-and-effects-plan.md`/`tween-plan.md`는 이미 `base/`로 승격 완료). +## error 계약 — `level` 이분과 메시지 언어 (2026-08-25 확정, 7라운드 `H-104`/`H-105`) + +quad가 던지는 error 자리는 약 29곳이고(`base/` 전수), **쓰기 전에 정해두면 +한 번에 맞고 나중에 바꾸면 전수를 다시 훑어야 한다.** + +- **⭐ `level` 이분** — *즉시 error*가 quad의 주 방어선인데, 지금까지 **한 + 군데도 `level` 인자가 없어** 전부 quad 내부 줄을 가리켰다. 사용자는 자기 + 코드 어디서 틀렸는지를 볼 수 없었다. + + | 종류 | `level` | 예 | + |---|---|---| + | **사용자 입력 검증** | **2**(호출부를 가리킴) | deps 타입/`nil`, 이중 바인드, 예약 키, 요소 타입 | + | **내부 불변식 위반** | **1**(그 자리를 가리킴) | 부기가 깨짐(`lengthList[i] == nil` 등) | + + ```lua + error("Effect: dep #3 is not a State/Source/Ref", 2) + error("Dispatch.getOffsetAt: lengthList[3] is nil — bookkeeping is broken", 1) + ``` + + 내부 불변식 위반은 곧 **quad 자신의 버그**라, 호출부가 아니라 터진 자리를 + 가리켜야 리포트가 쓸모 있다. +- **⭐ 메시지는 영어로 통일한다**(**사용자 확정**, 2026-08-25). 지금 + 코퍼스는 영어 6 / 한국어 약 23으로 이미 갈려 있고, 공개 표면인데 + 정해진 적이 없었다. `.claude/conventions.md`의 *"사용자가 보게 될 것은 + 한국어"*는 **이 프로젝트의 대화·문서** 규칙이고 **quad 라이브러리 + 사용자**에게까지 적용된다고 정해진 적이 없다 — 여기서 정한다. 이미 + 영어인 6곳(동적 경로 가드 4형제, 모듈 초기화, attribute 이름 충돌)이 + 핵심 경로라는 것도 같은 방향이다. `base/`의 예시 메시지도 영어로 쓴다. + +## 예외 안전성 계약 — 감싸지 않는다 (2026-08-25 확정, 7라운드 🅒) + +**예외가 나면 그 파동/그 자리의 부기는 복구되지 않는다.** `pcall`로 +감싸는 자리는 하나도 두지 않는다. + +- 2026-08-21에 `base/slot-plan.md`의 `materializeSlotTree`에 대해 내린 + 판단(*"마운트 도중 예외는 quad가 복구를 보장하지 않는 상태이고 … 아직 + 실제로 밟은 적 없는 경로다 … 실제로 물리면 그때 넣는다"*)을 **전 자리로 + 확장**한 것이다. `.claude/conventions.md`의 *"드문 오용이나 가상의 미래 + 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다"* 원칙 그대로. +- **적용 자리 넷**(7라운드가 찾은 것): State 전파 루프(구독자당 hot path), + 게이트 flush + `Blocker:Off()` 순회, `Dispatch.drive`의 배치 게이팅, + Dispatch 체인 슬롯. 각각의 실패 모드는 그 문서들이 적는다. +- **같은 원칙이 `EffectHandle:Rerun()`에도 적용된다** — `fn`이 던지면 + `_running`이 참으로 남는 것까지 포함해 복구하지 않는다(사용자: *"에러가 + 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작"*). +- **yield 금지와 같은 톤으로 사용자 문서에 명시할 것.** + ## 코드 스타일 — 네이밍 케이싱 (2026-08-08 두 번째 세션 신설) 지금까지 각 문서가 예시 코드를 쓰며 암묵적으로 따라온 패턴을 사용자가 @@ -442,15 +490,20 @@ CollectionService 태그 등)를 흉내낼 필요가 없고, quad-base 자체 ## Store/State/Source 온톨로지 — 확정됨 (요약) Store는 source(실제 값이 존재하는 단일 지점) 집합체이고, `store.key`로 -접근하면 이미 만들어져 있는 Source 객체를 그대로 반환하거나(defaults로 -Store 생성 시 미리 만들어둔 경우), 아직 없으면 그 자리에서 만들어 저장한 -뒤 반환한다(별도 wrapper 없음 — **[2026-08-06 후속 세션 정정, 2026-08-07 -추가 정정]** 원래 "매번 새 State를 감싸 반환"이었으나, `Source`가 구조적으로 -`State`를 만족하도록 재구성되며 wrapper 계층 자체가 불필요해짐. 이후 -"Store 생성 시 전부 eager하게만 만들어진다"로 한 차례 더 정리됐다가, Luau -타입이 런타임에 강제되지 않아 defaults 없이 만든 키를 나중에 `:Set()`하면 -크래시난다는 점이 지적돼 lazy `__index`+저장 생성도 같이 필요함이 확인됨 — -상세는 `base/source-state-plan.md` "Source가 State를 만족함" 절). 전파는 +접근하면 **그 키의 Source 객체를 그대로 반환한다** — 별도 wrapper도, +프록시도, **타입 함수도** 없다(`Source`가 구조적으로 `State`를 만족하도록 +재구성되며 wrapper 계층 자체가 불필요해짐 — `base/source-state-plan.md`의 +"Source가 State를 만족함" 절). +**⭐ [2026-08-25] 두 가지가 확정됐다** — (1) 생성은 **명시적 초기화**다: +타입 인자에 `Source`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접 +넣는다. 옛 lazy `__index`(없는 키를 그 자리에서 만들어 저장)는 폐기됐고, +그래서 `defaults`가 곧 선언 키 집합이라 `store:Names()`가 성립한다. +(2) 레코드 필드 타이핑에 **타입 함수를 안 쓴다** +(`WrapStore`/`ProcessStoreType` 폐기). 쓰기는 `store.key = v`가 아니라 +**`store.key:Set(v)`**이고, 동적 키는 `store:Of<>(name)` 하나다. +상세는 `base/store-plan.md`가 소스 — 같은 날 "`store.key`를 값으로" +재설계를 넣었다가 철회한 경위는 +`archive/store-value-field-redesign-withdrawn.md`. 전파는 push-invalidate(신호만)/ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림(**[2026-08-14 보강]** 푸는 주체는 diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index c95eabf..9b8f9c6 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -260,6 +260,17 @@ end - **`nameClaims`와의 순서**: 위치 claim이 **먼저**다 — 같은 그룹의 두 번째 위치는 이름 claim까지 갈 것 없이 그 자리에서 거부되어야 하고, 그래야 `nameClaims`에 절반만 기록되는 중간 상태가 안 생긴다. + - **⚠️⚠️ [2026-08-25 신설, `/code-review high`] GC 계약 — 그룹 값 + 객체(`groupValue`)는 `inst`를 되참조하면 안 된다.** 이 값은 + `groupClaimKeys`의 **내부 키**로 쓰이는데, `Relate`의 내부 키는 + `SetStrong`/`SetWeak` **어느 쪽이든 항상 강하다**(Luau엔 ephemeron이 + 없다 — `base/relate-plan.md`의 "위험한 패턴" 절 슬롯 표). 그래서 값이 + `inst`로 되돌아가면 `buckets`의 weak 키가 자기 버킷을 통해 살아남아 + **그 `inst`가 영원히 안 죽는다.** 실제로 밟기 어려운 모양은 아니다 — + 같은 트리의 `Ref`가 그 `inst`로 채워진 뒤 그 Store에 담기면 된다. + **되참조가 필요해지면 내부 키를 값 객체가 아니라 이름/토큰으로 + 바꿔야 한다**(값 객체는 그때 값 슬롯으로 내려가고, 그 슬롯은 + `SetWeak`으로 도망갈 수 있다). 지금은 그 요구가 없어 계약으로 둔다. - 이걸로 `Frame { a, a }` 갭(구 `AT-11`/5라운드 `AT-2`)도 **같이 닫힌다.** - **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게 **정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`) @@ -354,6 +365,12 @@ Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김 attr:NameMap(): {[string]: Source} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것) +-- ⭐ [2026-08-25, 7라운드 `H-79`] `:NameMap()`이 Store를 평탄화하려면 **Store가 +-- 자기 키 집합을 알아야 한다** — 그 표면이 없어서 키 집합이 접근 이력에 +-- 좌우되고 있었다(lazy `__index` 시절엔 0개/1개/2개로 갈렸다). Store가 +-- **명시적 초기화**로 바뀌며 `defaults`가 곧 선언 키 집합이 됐고, +-- `store:Names()`가 그걸 준다(`base/store-plan.md`). `:NameMap()`은 각 +-- Store에 대해 `store:Names()`를 돌며 `store[name]`을 모은다. -- [명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 자신의 이름 겹침 정책: -- Attribute(a, b)에서 a와 b가 같은 이름을 가지면 **뒤에 온 인자가 이긴다** -- (= Overridden과 같은 정책, error 아님). Merged의 "겹치면 error"는 그 함수만의 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index d646904..24d034e 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -48,7 +48,9 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 `:Subscribe()`/`:Unsubscribe()`, 이중 바인딩 금지 게이트 → **`base/source-state-plan.md`**. - **Store** — 이름 붙은 Source 모음, `defaults`와 eager/lazy 생성, `store.key` - dot-access 타이핑(+Luau `type function`), `store.key:Set(value)` 문법, + dot-access 타이핑(**[2026-08-25]** `type function` 합성은 폐기 — + 타입 인자에 `Source`를 직접 써서 **평범한 레코드**로 짓는다, + `base/store-plan.md`), `store.key:Set(value)` 문법, "Store가 Store를 저장 가능한가", Store 부작용 정책 → **`base/store-plan.md`**. - **`Ref` / `PreRef`** — 용도 재정의, `.Value`/`:Set`/`:Callback`/`:Wait` API, `Ref`의 retract, PreRef 호이스팅/1회용 가드 → **`base/ref-plan.md`**. @@ -239,10 +241,12 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 **Store 쪽 dot-access는 그대로 유지**: `store.key`는 실질적으로 Luau가 타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: Source, ...}`류 평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 유지. +**[2026-08-25]** 그 레코드를 **타입 함수로 합성하던 것만** 폐기됐다 — +지금은 타입 인자에 `Source`를 직접 쓴다. **[정정, 2026-08-18] `store "key"` 문자열 커링은 기각됐다** — 여기 폴백으로 같이 적혀 있었으나 폐기됨(`"a"`가 그냥 `string`으로 들어가 `Source`의 `T`를 알 수 없고, dot-access + `type function` 타이핑이 자리잡아 더 이상 -필요 없어짐). 동적 키는 명시적 `store:GetDynamic<>(name)`으로 간다 — +필요 없어짐). 동적 키는 명시적 `store:Of<>(name)`으로 간다(**[2026-08-25]** 옛 이름 `GetDynamic`) — `base/store-plan.md`가 소스. **이벤트가 이 관습의 예외인 성격도 바뀜** — "타입을 포기하는 예외"가 아니라 **이름 지정 방식만 문자열 키인 예외**다(타입은 위 정정대로 생성기가 준다). diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index ba6f33e..129328c 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -59,7 +59,7 @@ blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를 state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에 -- 처음 블록될 때가 아니라) onunblock 핸들을 - -- blocker의 weak 배열에 등록. + -- blocker의 weak-키 셋에 등록(아래 "onunblock 핸들 보관"). blocker:Policy(emit) -> onUpstreamEmit -- [2026-08-24 신설] 이 blocker의 게이트 정책을 -- **값으로** 돌려준다. `state:Block(b)`가 @@ -98,6 +98,30 @@ gated state의 동작: `blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지 정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다. +**⭐⭐ [2026-08-25 신설, 7라운드 `H-63`] onunblock 핸들 보관 — 세 자리를 +정한다.** 지금까지 *"blocker의 weak 배열"*이라고만 적혀 있었고 셋이 +비어 있었다. + +1. **자료구조는 weak-키 해시맵 셋** `{[handle] = true}`(`__mode = "k"`)이다. + 값-weak **배열**이면 항목이 수거될 때 구멍이 생겨 `ipairs`가 첫 구멍에서 + 멈추고(`#`도 border 미정) **뒤의 살아있는 게이트가 `Off()`를 못 받는다.** + `H-7`이 `Ref.Callbacks`를 배열에서 해시맵 셋으로 바꾼 것과 같은 문제이고 + 같은 처방이다. +2. **⭐ 핸들을 강하게 드는 주체는 정책이 반환하는 `onUpstreamEmit` 클로저다** — + 그 클로저가 onunblock 핸들을 upvalue로 잡는다. 체인은 + `GateNode → onUpstreamEmit → onunblock 핸들`이고, Blocker 쪽은 weak이라 + **게이트 노드가 죽을 때만** 수거된다. 이게 안 정해져 있으면 + `Debounce`의 `setup`을 문서 그대로 짰을 때 핸들이 다음 GC에서 사라져 + `b:Off()`가 **조용히 아무것도 안 한다**(디바운스가 영영 안 나감) — + 실패가 GC 타이밍에 따라 간헐적이라 나중에 잡기 제일 어려운 종류다. + - **Blocker가 강하게 드는 안은 기각** — 오래 사는 Blocker 하나가 거기 + 걸렸던 **모든 gated state와 그 상류 체인을 영원히 살려둔다**(`:List` + 항목마다 게이트를 무는 패턴에서 직행 누수). +3. **`Off()`/`OffWithoutEmit()`은 스냅샷을 뜬 뒤 순회한다** — 순회 중 + 새 등록(핸들 → flush → 하류 Observer가 `state:Block(b)`를 새로 만듦)이 + `pairs`에서 미정의이기 때문. `Ref.Callbacks`/State 구독자 집합과 같은 + 처방(`base/source-state-plan.md`의 `H-23` 확정). + **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-33`/`H-49`] 그 정책을 값으로 꺼내는 표면 `blocker:Policy(emit)`을 추가한다** — 표면이 하나 늘고, `Blocker()` 생성자와 `state:Block`은 그대로다. @@ -111,7 +135,15 @@ gated state의 동작: 풀 때 그 `emit`이 정확히 1회 불린다. - **`Debounce`/`Throttle`은 Blocker를 사적으로 하나 갖는다** — 적용 핸들당 하나(커링 결과가 여러 곳에 적용될 수 있으므로 `Apply` 시점 생성). - `pending` 같은 상태는 별도로 안 들고 `HasBlockedEmit`으로 흡수한다. + `pending` 같은 상태는 별도로 안 든다. + **⭐⭐ [2026-08-25 정정, 7라운드 `H-86`] "`HasBlockedEmit`으로 흡수한다"는 + 성립하지 않았다.** 아래 *"`HasBlockedEmit`은 게이트 흡수 집합의 특수형"* + 절이 그 값을 **게이트 노드의 `withheld`로** 흡수해버려, `Blocker` 객체 + 쪽엔 **실체가 없다**. 두 흡수가 반대 방향이라 결과적으로 **아무도 안 들고 + 있었다** — 정책이 "지금 쌓인 게 있나"를 읽을 통로가 0개였고, + 그래서 `Throttle`의 창이 idle로 못 돌아왔다. **통로는 + `emit(commit: boolean?) -> boolean`의 반환값**이다 + (`base/gate-plan.md` 2번). 상세와 의사코드는 `base/gate-plan.md`의 5번 항목이 소스. **`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index 59ae6d2..17c6dd6 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -57,18 +57,31 @@ State는 `:With`/`:Compute`로 만들어진 파생값일 수 있어 쓰기가 의미 있음 — **사용자 확정**("맞음. 확실해"). 이 원칙 자체는 그대로 유지되고, 아래 3번의 구체적 메커니즘만 2026-08-06 후속 세션에서 더 단순하게 갱신됨. -### 3. Store는 내부 Source를 그대로 반환 — Source가 State를 구조적으로 만족 +### 3. Store는 내부 Source를 `store:Of(key)`로 준다 — Source가 State를 구조적으로 만족 + +> **[2026-08-25] 이 절의 결론은 유지된다.** 같은 날 오전에 +> "`store.key`가 값이고 `store:Of(k)`가 프리미티브"라는 재설계를 넣었다가 +> **같은 날 철회**했다 — `archive/store-value-field-redesign-withdrawn.md`. +> 바뀐 것은 **생성이 명시적 초기화가 됐다는 것 하나**다(아래 ⭐). **확정**: `Source`가 구조적으로 `State`를 만족하므로(단방향 호환, -Svelte `Writable extends Readable`와 같은 모양), `store.key`는 Store -생성 시 이미 만들어둔 진짜 Source 객체를 그대로 반환한다 — 별도 프록시 -타입도, 별도 캐싱 계층도 없음(Source 자체가 이미 State의 읽기 계약을 -전부 만족하고 거기에 `:Set(value)`/`:Emit()`이 추가로 있을 뿐이라 "원본이라 -쓰기 가능"이라는 위 2번 규칙과도 자연히 맞아떨어짐). 쓰기 문법도 같이 -바뀜: `store.key = v`가 아니라 `store.key:Set(v)`(레코드 타입 읽기/쓰기 -대칭 + lazy 동작에 `=`가 암시하는 "즉시 커밋"이 안 맞는다는 논거). 상세 -근거·타입 설계·Luau 솔버 검증 필요 항목은 `base/source-state-plan.md`의 -"Source가 State를 만족함" 절이 최종 소스. +Svelte `Writable extends Readable`와 같은 모양), **`store.key`는 +진짜 Source 객체를 그대로 반환하는 평범한 레코드 필드다** — 별도 프록시 +타입도, 별도 캐싱 계층도, **타입 함수도** 없다(Source 자체가 이미 State의 +읽기 계약을 전부 만족하고 거기에 `:Set(value)`/`:Emit()`이 추가로 있을 +뿐이라 "원본이라 쓰기 가능"이라는 위 2번 규칙과도 자연히 맞아떨어짐). +쓰기는 `store.key = v`가 아니라 `store.key:Set(v)` +(`base/store-plan.md`의 "Store 값 설정 문법" 절). + +- **⭐ [2026-08-25] 생성은 명시적 초기화다** — 타입 인자에 `Source`를 + 직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index` + (없는 키를 그 자리에서 만들어 저장)는 폐기됐고, 그래서 **선언 키 집합의 + 런타임 소스가 하나**가 된다. 부모가 값을 다 안 넘겨도 되게 하려면 + **컴포넌트가 자기 `DEFAULTS`로 채워** 넘긴다. +- 동적 키는 `store:Of<>(name)` 하나다(옛 `GetDynamic` 흡수). + +상세 근거·타입 설계는 `base/source-state-plan.md`의 +"Source가 State를 만족함" 절과 `base/store-plan.md`가 최종 소스. **[이전에 확정했다가 폐기된 `StoreSource` 프록시 설계는 이 결론으로 완전히 대체됨 — 원문·역전 이유·신구 비교표는 @@ -327,7 +340,8 @@ Ref를 받으면 그냥 전부 실행하면 됨 — Ref 콜백 리스트는 애 (`Modifier.Overridden`은 2026-08-08 세션에서 이름 확정, 이 목록에서 빠짐). (`GetSource` 계열 접근자는 위 3번 정정으로 아예 불필요해짐 — `store.key`가 직접 Source를 반환하므로 별도 접근자 자체가 - 없음.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/ + 없음. **[2026-08-25]** 단 **동적 키** 전용 창구 `store:Of<>(name)`은 + 있다 — 옛 이름은 `GetDynamic`이었다.) `base/bind-system-plan.md`의 "남은 열린 질문" 절(정확한 함수/ 생성자 이름 미정)과 같은 급의 후순위 항목 — 구현 단계에서 다른 이름들과 함께 확정. - **`quad2-try`는 확인 불필요로 재확인** — 진행이 중단된 상태라 이 논의와 diff --git a/.claude/base/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md index e3f4331..ee01871 100644 --- a/.claude/base/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -833,14 +833,35 @@ function clearTimeout(timeout: Timeout) timeout._native() end > > - `gate:passThrough()` → **`b:Off()`**(보류분 1회 방출), 그 뒤 다시 `b:On()`. > - `Trailing = false` 경로 → **`b:OffWithoutEmit()`**. -> - **`pending`은 없앤다** — "보류된 게 있는가"는 Blocker의 `HasBlockedEmit`이 +> - **`pending`은 없앤다** — "보류된 게 있는가"는 게이트 노드의 흡수 집합이 > 이미 들고 있다(중복 상태를 안 만든다). 이게 `H-32`를 구조적으로 없앤다. +> - **⭐⭐ [2026-08-25 정정, 7라운드 `H-86`] 읽는 통로는 `HasBlockedEmit`이 +> 아니라 `emit`의 반환값이다.** 여기 한때 *"Blocker의 `HasBlockedEmit`이 +> 이미 들고 있다"*고 적었는데, `base/blocker-plan.md`가 그 값을 **게이트 +> 노드의 `withheld`로** 흡수해 `Blocker` 객체 쪽엔 실체가 없다 — 정책이 +> 읽을 방법이 **0개**였다. 그대로 짜면 창을 닫을 조건을 못 읽어 +> **창이 영원히 열린 채**가 되고, `Throttle`의 정의(*"첫 신호는 즉시 +> 통과"*)가 사라져 사실상 "항상 trailing"이 되며 타이머 체인이 자기를 +> 무한 재무장해 아래 8절의 "유계 GC" 분석도 깨진다(실측 대조 확인). +> - **확정된 통로**: `setup`이 받는 `emit`이 +> `emit(commit: boolean?) -> boolean`이 되어, 반환값이 **"실제로 +> 내보내거나 버릴 게 있었는가"**를 준다(`base/gate-plan.md` 2번). +> `onWindowEnd`는 `if not emit() then window = nil else rearm() end`, +> `MaxTime` 재무장 조건도 같은 반환값으로 판정한다. +> - **버리는 경로도 같은 핸들이다** — `Trailing = false`/`Cancel`은 +> `b:OffWithoutEmit()`만으로는 집합이 안 비므로 `emit(false)`가 필요하다 +> (`H-55`). > - `Flush`/`Cancel` 핸들은 `setup` 클로저 안에서 `b`를 캡처해 만든다 — > 노드 객체 참조가 필요 없다(`Blocker`가 onunblock 핸들을 등록하는 방식과 > 같은 우회). > > 아래 코드는 **창/타이머 정책 자체의 참고용**으로만 읽을 것 — > `openWindow`/`onWindowEnd`/`MaxTime`의 분기 구조는 그대로 유효하다. +> **[2026-08-25 추가, 7라운드 `H-94`] 팩토리를 `setmetatable`+`__call`로 +> 만드는 표기도 이 배너 범위에 들어간다** — `__call` 테이블은 `:Apply`의 +> 함수 타입 자리에 **안 들어간다**(실측). 확정된 형태는 **지정된 필드**로 +> 자기를 노출하는 것이다(`base/source-state-plan.md`의 +> "`state:Apply(factory)`" 절). **⭐ [2026-08-24 `H-32`] 같이 고쳐야 할 논리 결함 하나 — `Trailing = false`에서 `pending`이 영구히 참으로 남는다.** 아래 코드는 창이 열려 있는 동안 오는 신호를 @@ -887,8 +908,20 @@ local function makeGate(reset: boolean, opts) for gate in instances do gate._cancel() end end - -- 팩토리 자체 — :Apply(factory)가 호출할 수 있는 함수이면서, 동시에 - -- 전체 브로드캐스트 :Flush()/:Cancel()도 갖는 콜러블 객체(5-4절) + -- 팩토리 자체 — :Apply(factory)가 쓸 수 있으면서, 동시에 + -- 전체 브로드캐스트 :Flush()/:Cancel()도 갖는 객체(5-4절) + -- + -- ⛔⛔ [2026-08-25 폐기, 7라운드 `H-94`] **`__call`은 안 쓴다.** + -- `luau-analyze` 실측에서 `__call` 테이블은 `(State) -> U` 함수 타입 + -- 자리에 **안 들어간다**(제네릭·비제네릭 양쪽) — 런타임은 멀쩡하고 + -- 타입만 막히므로 `--!nocheck` 스파이크에선 안 드러난다. 타입 레벨 + -- `__call`은 `self`도 못 받는다. **확정된 형태는 `__call`이 아니라 + -- 지정된 필드**로 자기를 노출하는 것이고, `Debounce`/`Throttle`/ + -- `Blocker`가 전부 같은 계약을 만족한다 — + -- `base/source-state-plan.md`의 "`state:Apply(factory)`" 절이 소스 + -- (필드 이름과 정확한 시그니처는 구현 시 정한다). + -- 아래 `__call` 표기는 **창/타이머 정책 본문을 읽기 위한 자리표시자**로만 + -- 볼 것 — 위 7절 배너와 같은 취급이다. local factory = setmetatable({}, { __call = function(_, self) local gate = Gate(self) -- Blocker가 쓰는 것과 같은 게이트 노드 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 2ec07ee..a0f19d3 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -983,6 +983,18 @@ function Dispatch.process(inst, k, v, index) list[index] = { handler = h, retractor = retractor } end end +--[[ +⚠️ [2026-08-25 신설, 7라운드 `H-103`] `h.process`가 던지면 그 자리에 `NOOP` +마커가 **영구히 남는다** — 그 자리의 정리가 통째로 사라지고, 명시적 철거로도 +회수되지 않는다(`retractFrom`이 `NOOP`을 부르면 아무 일도 안 한다). 예컨대 +`AttributeKeyHandler.process`는 `nameClaims:SetStrong` **뒤에** `setAttribute`를 +부르므로, 주입 op가 미주입 에러 스텁이면 **이름 claim만 남고 해제 경로가 +없는** 상태가 된다. + +**`pcall`로 감싸지 않는다** — `base/architecture.md`의 "예외 안전성 계약" 절이 +소스다. 자리당 hot path이고, 예외 이후의 부기 무결성을 quad가 보장하지 +않는다는 일반 계약을 여기에도 그대로 적용한다. **실제로 물리면 그때 넣는다.** +]] function Dispatch.retractFrom(inst, k, index) -- index부터(포함) 끝까지, 꼬리(가장 깊은 인덱스)부터 역순으로 정리. @@ -1664,6 +1676,11 @@ function Dispatch.getOffsetAt(ownerKey, at) end local cur = bk.offsetCache[bk.invalidAfter] for i = bk.invalidAfter, at - 1 do + -- ⭐ [2026-08-25, 7라운드 `H-106`] `nil` 가드 — `recompute`만 갖고 있던 + -- `C-6` 진단이 이 경로에선 우회돼 익명 산술 에러로 먼저 터졌다. + if bk.lengthList[i] == nil then + error("Dispatch.getOffsetAt: lengthList[" .. i .. "]가 nil — bookkeeping is broken", 1) + end cur += contribution(bk, i) -- lengthList[i](State면 :Get()) bk.offsetCache[i + 1] = cur -- **지금 자리의 길이가 다음 자리의 offset을 정한다** end @@ -1729,7 +1746,19 @@ local function recompute(ownerKey, bk) -- 요소가 하나도 없는 Slot(`Slot()` 직후, 데이터가 빈 `:List` 등)은 `N`이 `nil`인 -- 채로 `materializeSlotTree` 끝의 recompute에 도달한다 — `for i = 1, nil`은 -- 그 자리에서 터진다. 빈 Slot은 완전히 정상적인 상태라 이건 방어가 아니라 계약. - for i = 1, bk.N or 0 do + -- ⭐⭐ [2026-08-25 재작성, 7라운드 `H-101`] 재진입 차단 + 되감기. + -- (a) 자기 전용 `recomputeBlocker`를 켜 재진입 recompute를 막는다 + -- (배치 게이팅용 Blocker와 **별개 객체** — 합치면 배치 `Off()`의 + -- onunblock 순회 도중 같은 Blocker가 다시 꺼져 핸들이 재귀한다. + -- `base/blocker-plan.md`가 네스팅을 의도적으로 미지원한다). + -- (b) 상한 `bk.N`을 **매 반복 재평가**한다 — 진입 시 한 번만 평가하면 + -- 재진입이 끝에 붙인 자리를 바깥 루프가 아예 안 본다. + -- (c) `bk.invalidAfter`가 낮아지면 **그 지점 다음부터 되감는다**. + -- 접두합을 남겨두면 되감기 지점의 `sum`이 공짜로 복원된다. + bk.recomputeBlocker:On() + local prefix, i = {}, 1 + while i <= (bk.N or 0) do + prefix[i] = sum local offset = bk.sourceList[i] -- offset은 실제 Source이거나 None(발행 채널 없음) — None은 truthy라 -- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함. @@ -1742,18 +1771,83 @@ local function recompute(ownerKey, bk) error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)") end local abs = Dispatch.getOffsetAt(ownerKey, i) -- 절대 offset(캐시 경유) + bk.invalidAfter = i -- 여기까지 유효해짐 if offset ~= None and offset:Get() ~= abs then -- 실제로 다를 때만 Set - offset:Set(abs) + offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리 end local v = bk.lengthList[i] sum += (if isState(v) then v:Get() else v) + + if bk.invalidAfter < i then -- 누군가 낮췄다 → 되감기 + -- ⭐ [2026-08-25] `+1`이 아니라 **그 자리부터** 다시 돈다. + -- `prefix[j+1]`은 **옛** `lengthList[j]`로 누적된 값이라, 길이가 + -- 바뀐 자리를 건너뛰면 `sum`이 낡은 채 `Length`에 실린다 + -- (재진입은 블로커에 막혀 자가치유도 안 된다). `j`를 다시 돌아도 + -- offset 쓰기는 바로 위 `~=` 가드가 막아 no-op다. + i = bk.invalidAfter + sum = prefix[i] + else + i += 1 + end end + -- ⭐ [2026-08-25] 캐시 리셋과 블로커 해제를 **`Length:Set` 앞에** 둔다. + -- `Length:Set`은 상위 owner의 사용자 코드를 돌릴 수 있는데, 그 도중 + -- 낮춰진 `invalidAfter`를 뒤에서 무조건 덮으면 **캐시가 낡은 채로 + -- "유효"로 표시**되고, 그때 불린 `gatedRecompute`는 블로커가 아직 + -- 켜져 있어 조기 반환했으므로 아무도 다시 안 돈다. + bk.invalidAfter = bk.N or 0 + bk.recomputeBlocker:OffWithoutEmit() if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then ownerKey.Length:Set(sum) -- **Length엔 base를 안 더한다** — 길이는 위치와 무관 end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절) end ``` +**⭐⭐ [2026-08-25 신설, 7라운드 `H-101`/`H-102`] 재진입과 되감기.** + +- **재진입 경로는 실재한다** — `offset:Set(abs)`가 **동기 전파**라 그 + offset을 관측하는 사용자 코드가 그 자리에서 같은 owner를 건드릴 수 + 있다. 그러면 배치 게이팅 Blocker가 꺼진 정상 상태에서 **재진입 + `recompute`가 완주해 올바른 값을 써놓고, 바깥 루프의 꼬리가 자기 + 옛 `sum`으로 그걸 덮는다.** 실제 재현에서는 `bk.N`이 자란 경우가 + 걸렸다(바깥은 옛 상한까지만 돌아 `sum`이 모자랐다). + - **원문이 든 트리거는 틀렸다** — `:List`의 `updateFn`은 offset 변경으로 + **재실행되지 않는다**(offset을 State로 넘겨 관측하게 할 뿐이다). + 남는 트리거는 그보다 좁은 것 하나 — **`slot.Offset` State를 관측하는 + 사용자 코드**. + - **`sum`이 낡는다는 서술도 대체로 틀렸다** — `contribution`을 + `offset:Set` **직후**에 읽으므로, 그 Set이 유발한 자식 길이 변경은 + 이미 반영된 값이다(동기 계약 하에서). +- **왜 되감아야 하나** — 앞자리가 당겨지는 splice가 도중에 나면 순차 + 순회로는 복구가 안 된다. **사용자 예시**: *"`{a,a,a, b,b, c,c}` 여기서 + a,a 두개가 소멸했는데, 이미 c 에 왔다면, b,b 가 c,c 로 덮여지고 a,a 는 + 달라지는게 없을 가능성이 생기죠. 따라서 recompute 도중 변경이 생긴다면, + 변경이 생긴 곳으로 위로 올라가야할것 같습니다."* +- **되감기 신호는 `bk.invalidAfter` 하나로 통일한다** — 새 필드를 안 + 만든다. 두 뜻("캐시가 여기까지 유효"와 "여기 다음부터 다시 해야 함")이 + 실제로 같은 것이기 때문이고, `getOffsetAt`도 이미 `for i = bk.invalidAfter, + at - 1`로 그렇게 읽는다. + - **⭐ [2026-08-25 정정, `/code-review high`] 재개 지점은 `invalidAfter` + 자신이다(`+1` 아님).** 한때 *"길이가 바뀐 자리의 자기 offset은 여전히 + 유효하다"*를 근거로 `+1`로 적었는데, **offset은 유효해도 `sum`이 + 아니다** — `prefix[j+1]`은 **옛** `lengthList[j]`로 누적된 값이라 그 + 자리를 건너뛰면 낡은 합계가 `ownerKey.Length`에 실리고, 재진입은 + 블로커에 막혀 있어 **자가치유도 안 된다.** `j`를 다시 도는 비용은 + offset 쓰기 하나인데 그건 `offset:Get() ~= abs` 가드가 막아 no-op다. + - **그래서 splice도 `j - 1`이 아니라 `j`로 낮춘다** — 재개가 + `invalidAfter`니까 `j`가 곧 "그 자리부터 다시"다. `getOffsetAt`의 캐시 + 의미(`offsetCache[j]` = 1..j-1의 합)도 splice/길이변경 어느 쪽에서든 + `j` 이전은 안 바뀌므로 그대로 성립한다. +- **`H-102`(splice가 observer를 옮겨도 클로저에 박힌 인덱스는 안 고쳐진다)가 + 이걸로 같이 닫힌다** — 아래 `setLength`의 `gatedRecompute`가 **인덱스를 + 캡처하지 않고 조회**한다. `slot._elemIndex`(물리 요소 → 인덱스 역방향 + 맵, 6라운드 `H-39`)와 같은 개념을 **Dispatch 층위로 격상**해 `bk`가 + 소유하고, splice가 배열을 당길 때 같이 갱신한다 — 그래서 + `base/slot-plan.md`의 splice 요구 목록에 항목이 늘지 않는다. + (사용자: *"그것을 dispatch 로 격상시키는게 더 나아보이는 지점"*.) + `bk.invalidAfter = 0`으로 뭉개는 안은 기각 — *"0 으로 두면, 모든 부분에 + 있어 캐시가 무관해져요"*. + **`offset`/`sum`은 0-based *개수*이지 Lua 배열 인덱스가 아님(2026-08-11 세션 명시화).** Luau/Lua 배열은 1-based 관례지만, 여기서 계산하는 `offset[i]`는 "그 앞에 몇 개가 있는가"라는 순수 카디널 수라 자연스럽게 @@ -1804,15 +1898,37 @@ function Dispatch.setLength(ownerKey, i, len, anchor) bk.lengthList[i] = len bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고 + -- ⭐ [2026-08-25] 이 자리의 유일한 토큰 — 아래 클로저가 인덱스 대신 이걸 캡처한다. + local token = bk.tokens[i] + if token == nil then + token = {} + bk.tokens[i] = token + end + bk.indexOfToken[token] = i -- [2026-08-24 `H-3`] 접두합 캐시를 여기까지 당긴다 — `i` 자리의 offset은 -- `1..i-1`의 합이라 안 바뀌고, 바뀌는 건 그 **뒤**뿐(위 무효화 표). bk.invalidAfter = math.min(bk.invalidAfter, i) local function gatedRecompute() - bk.invalidAfter = math.min(bk.invalidAfter, i) -- 나중 emit도 같은 무효화가 필요 - if not blocker:IsOn() then - recompute(ownerKey, bk) - end + -- ⭐ [2026-08-25, 7라운드 `H-102`] `i`를 **캡처하지 않는다** — splice가 + -- 자리를 당기면 박힌 인덱스가 낡는다. `bk`가 소유한 역방향 맵에서 + -- 현재 인덱스를 조회한다(`slot._elemIndex`와 같은 개념을 Dispatch로 + -- 격상한 것 — 위 `recompute` 절). + -- + -- ⚠️ [2026-08-25 정정, `/code-review high`] 키는 **`len`이 아니라 이 + -- 자리의 토큰**이다. `len`은 자리마다 유일하지 않다 — 같은 + -- `Source`/`State`가 두 자리의 길이를 몰면 역방향 맵에서 **두 + -- 자리가 한 항목으로 접혀** 엉뚱한 인덱스를 무효화하고, 앞선 + -- 자리는 영영 다시 offset을 못 받는다. `token`은 등록 시점에 + -- 만드는 유일한 테이블로 `bk.tokens[i]`에 같이 저장되고 splice가 + -- 다른 배열과 **함께** 당긴다. (`slot._elemIndex`가 물리 요소를 + -- 키로 쓰는 건 요소가 자리마다 유일해서 성립하는 것 — 그 성질을 + -- Dispatch에선 토큰이 맡는다.) + local cur = bk.indexOfToken[token] + bk.invalidAfter = math.min(bk.invalidAfter, cur) -- 나중 emit도 같은 무효화가 필요 + if blocker:IsOn() then return end -- 배치 등록 중 + if bk.recomputeBlocker:IsOn() then return end -- ⭐ recompute 재진입 중 + recompute(ownerKey, bk) end if isState(len) then diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 92b4582..0ad786c 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -36,7 +36,10 @@ Effect는 내부적으로 각 dep에 구독을 거는 걸로 구현 — State/So `state:Observer(...)`, `Ref`면 `:Callback`. **⚠️ [표기 정정, 2026-08-24 6라운드 `H-14`] 여기 원래 *"`fn`은 포지셔널 인자로 `state`를 받고(`fn(state)`)"*라고 적혀 있었는데 그건 폐기된 단수 시절 -표기다** — 확정 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고 +표기다** — 확정 시그니처는 **`fn(self: EffectHandle) -> ...(() -> ())`**이고 +(**[2026-08-25 `H-95`]** 가변 반환 팩 — 옛 `-> (() -> ())?`는 콜백이 "선언보다 +적게 반환"할 때 strict에서 막혀 `Effect(function() print("x") end, s)` 같은 +정상 용례가 안 통과했다) **deps는 `fn`에 안 넘어간다**(아래 "`Effect(fn, ...deps)`" 절이 소스, dep 값은 사용자가 클로저로 직접 읽는다). 같이 붙어 있던 *"이 `fn(state)`가 lazy `State` 핸들을 받는다는 전제는 확정 @@ -130,37 +133,138 @@ gcconn/gchold 복사가 전부다 — **`Destroying`도, cleanup 저장도, 그 cleanup을 필요로 하므로 필드 쪽이 자연스럽고, `Destroying` 클로저와 `Rerun`이 같은 자리를 읽게 된다. -**의사코드 — `bindLifetime`이 부르는 두 훅**(**[2026-08-24 신설]** 감사 3라운드가 -*"호출부만 있고 정의가 없다"*고 지적해 보강. 다른 신설 헬퍼는 전부 바디가 -있는데 이 둘만 산문뿐이었다): +**⭐⭐ [2026-08-25 재설계, 7라운드 `H-58`/`H-59`/`H-64`/`H-70`] dep 등록은 +`bindLifetime`이 아니라 생성자에서 한 번만 한다.** 옛 의사코드는 `Ref` 콜백을 +`_bindDestroying`에서 (재)등록했는데, `ref-plan.md`가 확정한 *"등록 즉시 그 +값으로 1회 호출됨"*과 겹쳐 **바인드마다 `Rerun`이 `Ref` dep 수만큼 돌았다**. +같은 문제가 State dep에도 있다 — `source-state-plan.md`가 확정한 +*"`fn`은 등록 시점에 즉시 1회 실행된다"* 때문이다. + +### 확정 구조 — 강한 주인은 항상 `Effect`, 발화 게이트는 `canExecute` 하나 + +``` +Effect ──강──▶ _deps = { [Ref | State] = fn | Observer } ← 강한 주인은 언제나 Effect +Ref.Callbacks ──약──▶ fn (`ref:WeakCallback(fn)`) +Observer 전역 레지스트리 ──약──▶ Observer (`observer:WeakSubscribe()`) +발화 게이트: 전부 `canExecute(handle)` 하나로 +``` + +**사용자 확정(2026-08-25)**: *"그냥 간단하게 저 강한 map 을 Effect 가 가지고, +WeakSub/WeakUnsub 를 WeakCallback 처럼 넣어줍시다. 의미론은 같습니다. +callback 을 잡고 있지 않거나, sub 대상인 observer 를 잡고 있지 않으면 gc 될 +수 있다. **그러면 bindLifetime 은 effect 하나 구현을 한 이후 canExecute 로 +모두 처리한다. 간단해집니다.**"* + +- **dep마다 바인드/언바인드에서 등록·해제하던 춤이 통째로 사라진다.** + `bindLifetime`/`unbindLifetime`은 **`Effect` 핸들 하나**에만 적용되고, + 내부 Observer와 `Ref` 콜백의 발화 여부는 `canExecute(handle)`이 전담한다. +- 그래서 아래 `H-7` 절이 확정한 *"`unbindLifetime`과 `:Unsubscribe()`에서 + `:Uncallback`한다"*는 **더 이상 필요 없다** — `ref-plan.md`의 + *"해제는 누수를, 게이팅은 발화를 막는다"* 중 **앞쪽 절반을 `Weak*`가 + 대신한다**. +- **왜 `WeakRef`가 아니라 `WeakCallback`인가**(사용자 지적): *"Ref 안에 + 항상 콜백이 쌓인다는것도 문제가 됨."* 클로저가 핸들을 약하게 잡는 + `WeakRef`는 "`Ref`가 `Effect`를 붙든다"만 풀고 "`Ref.Callbacks`에 죽은 + 클로저가 쌓인다"는 못 푼다. `WeakCallback`은 둘 다 푼다 — + `Effect ↔ cb` 순환이 자기완결이라 Luau GC가 통째로 수거하고 `Ref` 쪽 + 항목도 같이 사라진다. **`WeakRef`는 만들지 않는다.** +- 이건 `blocker-plan.md`의 onunblock 핸들 보관과 **같은 패턴**이다 — + 강한 주인은 소비자 쪽, 등록처는 weak. + +### 의사코드 — 생성자 / `bindLifetime`이 부르는 두 훅 / `Rerun` + +```lua +-- quad-base, Effect.luau +function Effect(fn, ...) + local self = setmetatable({ fn = fn, _deps = {}, _epochs = EpochMap() }, EffectHandle) + self._blocker = Blocker() + + -- (0) deps 검증 — 생성자에서 한 번만 도는 검사라 hot path가 아니다. + -- `select("#", ...)`로 순회해야 `nil` 구멍이 조용히 배열을 자르지 않는다. + local seen = {} + for i = 1, select("#", ...) do + local d = select(i, ...) + if d == nil then error("Effect: dep #" .. i .. " is nil", 2) end + if not (isState(d) or isSource(d) or isRef(d)) then + error("Effect: dep #" .. i .. " is not a State/Source/Ref", 2) + end + if not seen[d] then seen[d] = true end -- 중복 dep은 조용히 무시(error 아님) + end + + -- (1) dep 등록 — **여기서 한 번만**. 즉시-1회 호출은 Blocker로 억제한다. + -- ⭐ 클로저는 **하나**로 통일한다 — 아래 "공통 상류를 공유해도 한 + -- 파동에 fn은 한 번만" 절의 그 클로저다. `from`을 받아 + -- `_epochs:Update(from)`가 참일 때만 `Rerun`해야 다이아몬드 dedup이 + -- 산다(안 하면 `A → b`, `A → c`, `Effect(fn, b, c)`에서 `A:Set()` + -- 한 번에 `fn`이 두 번 돈다 — 2026-08-21에 닫은 그 버그). + -- 시그니처가 `(self, from)`인 것은 `state:Observer(fn)`의 확정 + -- 계약이다(`base/source-state-plan.md`). + self._blocker:On() + local function onDepFire(_, from) + if not canExecute(self) then return end -- 발화 게이트 + if self._blocker:IsOn() then return end -- 등록 구간 억제(Update보다 먼저) + if self._epochs:Update(from) then + self:Rerun() + end + end + for d in seen do + if isRef(d) then + self._deps[d] = onDepFire -- ⭐ 강한 주인 = Effect + d:WeakCallback(onDepFire) -- Ref 쪽은 약함 + else + local o = d:Observer(onDepFire) + self._deps[d] = o -- ⭐ 강한 주인 = Effect + o:WeakSubscribe() -- 전역 레지스트리는 약함 + end + -- ⭐ dep이 `Epoch`인지로 갈린다 — `state-epoch-plan.md` §4의 시딩 규칙 + -- 그대로다. **`Source`/`Ref`는 `Epoch`지만 `State`는 아니다**(§2·§8) — + -- 무조건 `Sync`하면 State dep이 `.Revision` 없는 키로 들어가 + -- `Refresh()`가 영영 변화를 못 보고 포탈 캐치업이 죽는다. + if isEpoch(d) then + self._epochs:Sync(d) + else + self._epochs:TrackFrom(d.valueEpochMap) + end + end + self._blocker:OffWithoutEmit() + + -- (2) 설치 — 생성 즉시 1회. **바인드로 미룰 수 없다**(아래 캐비엇). + self:Rerun() + return self +end +``` + +- **`_installing` 플래그는 폐기됐다** — 그건 생성자 구간만 덮어 바인드 + 구간을 놓쳤다. 억제는 사적 `Blocker` 하나가 전담한다(**사용자 지적**: + *"해당 맥락의 도구인 Blocker 가 존재함 … 이미 Slot 에서 사용중임. 모든 + 옵저버와 callback 등록에 있어서 이를 수행해야할 것임."*). +- **⚠️ 생성 즉시 1회 실행은 바인드로 미룰 수 없다.** **사용자 판단**: + *"Effect 가 바운딩 될 때 실행되는건 문제가 있습니다. 그 이팩트 실행 + 결과를 바로 받아서 처리하는 아래쪽 요소가 있으면, 순차 처리가 전혀 안 + 되거든요. 초기 값이 못 쓰게 되는거죠."* 그 따름정리로 **바운딩 없이 + 버려지는 `Effect`는 UB**다 — cleanup이 안 불린다. `Observer`와 달리 + `Effect`는 "죽기 전에 처리해주겠다"가 계약이라 성격이 다르다. ```lua --- quad-base, Effect.luau — `base/lifecycle-pattern.md`의 bindLifetime에서만 불린다. --- 비공개(`_` 접두사): 사용자 표면이 아니라 배관이다. function EffectHandle:_bindDestroying(inst) - -- 재바인드(포탈 재마운트)면 옛 연결부터 끊는다 — 멱등. - self:_unbindDestroying() + self:_unbindDestroying() -- 재바인드(포탈 재마운트)면 옛 연결부터 — 멱등 -- (1) leaf가 죽는 순간 cleanup을 정확히 1회. `LP-2`가 확정한 유일한 훅 지점. self._destroyConn = onDestroying(inst, function() - self:_unbindDestroying() -- 자기 연결/콜백을 먼저 정리(재진입 방지) - local cleanup = self._cleanup - self._cleanup = nil -- 두 번 안 불리게 - if cleanup then cleanup() end + self:_unbindDestroying() + self:_consumeCleanup() end) - -- (2) `Ref` dep 콜백 (재)등록 — State/Source dep의 `_observers` cascade와 대칭. - -- `unbind`가 뗐던 걸 여기서 다시 건다(그래서 포탈이 성립한다). - for _, ref in ipairs(self._refDeps) do - local cb = function(value) - -- ⭐ 발화 게이트 — State dep이 전파 루프에서 `canExecute(observer)`로 - -- 걸러지는 것과 같은 자리(아래 `H-7` 절). 해제가 늦거나 누락되는 - -- 창을 이게 덮는다. - if not canExecute(self) then return end - self:Rerun() - end - self._refCallbacks[ref] = cb -- 해제 때 정확히 이 클로저를 떼려면 보관해야 함 - ref:Callback(cb) + -- (2) 캐치업 — **조건부 최대 1회**. dep 등록은 이미 생성자에서 끝났다. + -- 설치돼 있지 않으면(파괴로 소진됐으면) 재설치, dep이 변했으면 재실행. + -- ⚠️ `Refresh()`를 **먼저 부른다** — 그건 비교만 하는 게 아니라 자기 + -- 키를 라이브로 다시 읽어 **갱신**한다. `or` 단축평가에 걸면 + -- `not self._installed`가 참일 때(재설치 경로) 건너뛰어, 재설치 뒤에도 + -- `_epochs`가 파괴 전 리비전을 들고 있어 **다음 emit이 헛되이 한 번 + -- 더 돈다**(`Rerun`은 `_epochs`를 안 건드린다). + local depsChanged = self._epochs:Refresh() + if not self._installed or depsChanged then + self:Rerun() end end @@ -169,28 +273,116 @@ function EffectHandle:_unbindDestroying() self._destroyConn:Disconnect() self._destroyConn = nil end - for ref, cb in pairs(self._refCallbacks) do - ref:Uncallback(cb) -- `base/ref-plan.md`의 신설 표면(`H-7`) - self._refCallbacks[ref] = nil - end - -- **`_cleanup`은 안 부른다** — 위 2번 계약. 여기서 부르면 `destroySlotTree`가 - -- `_detachCleanup`을 손으로 비운 뒤 unbind하는 경로에서 이중 호출이 된다. + -- **`Ref` 콜백도 Observer도 안 뗀다** — `Weak*`로 걸려 있고 발화는 + -- `canExecute`가 막는다. **`_cleanup`도 안 부른다**(아래 2번 계약). +end + +-- cleanup 소진: 읽고 → 지우고 → 실행. 이 순서라야 이중 호출이 없고, +-- `_cleanup`의 유무가 곧 "설치돼 있는가"가 된다. +function EffectHandle:_consumeCleanup() + local c = self._cleanup + self._cleanup = nil + self._installed = false -- ⭐ 아래 캐비엇 참고 — cleanup 유무로는 판정 못 한다 + if c then c() end end ``` +**⚠️ [2026-08-25 `/code-review high` 정정] "설치돼 있는가"를 `_cleanup`의 +유무로 판정하면 안 된다 — 별도 `_installed` 플래그가 필요하다.** 여기 한때 +`if self._cleanup == nil or ...`라고 적어뒀는데, **`fn`의 cleanup 반환은 +선택**이라(`Effect(function() print("x") end, s)`처럼 아무것도 안 돌려주는 +게 흔한 정상 용례) `_cleanup`이 **항상 `nil`**인 Effect가 존재한다. 그러면 +바인드/포탈 재마운트마다 조건이 참이 되어 `fn`이 다시 돌고 — 이 재설계가 +없애려던 `H-58`(바인드마다 `Rerun`)이 **그대로 되살아난다.** +`_installed`는 `Rerun`이 끝날 때 참, `_consumeCleanup`에서 거짓이 된다. + +**⭐ [2026-08-25 신설, 7라운드 `H-60`] `EffectHandle:Rerun()` 정의.** +지금까지 호출부만 다섯 곳이고 정의가 없었다. + +```lua +function EffectHandle:Rerun() -- 공개 메소드, 무인자 + if self._running then + self._pending = true -- 실행 중 재진입 → 지연 + return + end + self._running = true + repeat + self._pending = false + self:_consumeCleanup() + self._cleanup = self.fn(self) + self._installed = true -- cleanup 반환 여부와 무관하게 "설치됨" + until not self._pending -- 재요청이 또 오면 또 돈다 + self._running = false +end +``` + +- **재진입은 지연 재실행**이다. **사용자 판단**: *"Effect 의 실행 안에서 + 뭔가 수행되어 rerun 해야할 상황이 발생하면, 지연해 두었다 나중에 재실행 + 하는건 어떤지(실행이 끝나고 나서). 실제로 Effect 안에서 state 등을 바꾸는 + 상황은 react 등지에서 흔함."* +- **`canExecute` 확인은 호출부가 한다** — `Ref` 콜백·전파 루프가 이미 + 그렇게 한다. 사용자가 `fn` 안에서 직접 부르는 경로는 게이트하지 않는다. +- **error 시 UB** — 전파되고 복구하지 않는다(`_running`이 참으로 남는 것 + 포함). *"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 + 일반 동작"*(사용자). 수렴 책임은 사용자 `fn`에 있고 무한 루프도 UB다. + +**⭐ [2026-08-25 신설, 7라운드 `H-65`] 재바인드는 재설치, 재사용은 팩토리 +패턴.** 파괴로 cleanup이 소진된 `Effect`를 다시 바인드하면 위 (2)의 +`not self._installed`가 참이라 **재설치**된다. 죽음을 표시하는 별도 부기는 +만들지 않는다 — **사용자 지적**: *"파괴 클린업은 결국 inst.Destroying 에 +이벤트 바인딩인데 이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도 +알아서 잘 풀린 상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시 +실행해줘야한다는 것 이외엔 아무 문제가 없어요."* + +같은 `fn`을 **여러 인스턴스로** 쓰고 싶으면 `Effect`를 넘기지 말고 +**팩토리를 넘긴다**: + +```lua +local function TimerEffectFactory(data: { timerSource: Source }): Effect + return Effect(function(self) + ... + return function() ... end + end, data.timerSource) -- 주입받은 것을 그대로 deps로도 쓸 수 있다 +end +``` + +- **사용자 결론**: *"차라리 Effect 를 만들어내는 팩토리를 넘기는 패턴을 + 권장해야할듯 해요 … Clone 도, Userdata 도, 템플릿도 필요하지 않다."* + 초기 1회 실행 문제가 자연히 해결되고, 템플릿이 실행되어 찌꺼기로 남는 걸 + 막으려 따로 뭘 할 필요도 없다. 자식의 계약은 `({...}) -> Effect` 하나이고 + 부모가 더 큰 타입을 넘겨도 **부분 성립**으로 해결된다. 무엇보다 **주입받은 + `Source`/`Ref`를 그대로 deps로 넣을 수 있다** — userdata로는 안 되던 것이다 + (*"이건 ud 가 deps 에 대해서는 아무 처리가 못 했던것과 비교해 더 간단하면서도, + 기능적임"*). modifier에서 이미 권해온 패턴이기도 하다. +- 부수로 *"이팩트를 여러곳에 바인딩하면?"*도 자연히 해결된다(매번 새 인스턴스). +- **검토 후 안 만들기로 한 것**: `Effect:Clone()`, `Effect:Userdata()` / + `SetUserdata`·`GetUserdata`, `Effect.Template`, `WeakRef`. + - **`onDestroying(inst, fn)`은 백엔드 주입 op이다** — base는 `Instance`를 모른다(`base/slot-plan.md`의 `native*` 절과 같은 이유). quad-roblox 구현은 - `inst.Destroying:Connect(fn)` 한 줄. **[2026-08-24 반영 완료]** 주입 op - 전체 목록의 단일 소스는 `base/architecture.md`의 `EngineOps.luau` 줄이고 - 거기에 등재했다(`ROADMAP.md` M5 배너에도 같이 적었다) — 한때 여기 - *"`ROADMAP.md` M5에 추가할 것"*이라고만 적어 **소스를 잘못 지목**했었다. -- **필드 셋이 새로 생긴다**: `_destroyConn`(연결 핸들), `_refDeps`(생성자가 - `...deps` 중 `isRef`인 것만 모아둔 배열), `_refCallbacks`(`ref → 내가 건 - 클로저`). 앞의 둘은 `_observers`와 같은 층이고, 마지막 것은 **해제 시 정확히 - 자기 클로저만 떼기 위해** 필요하다(`Ref.Callbacks`가 셋이라 값으로 떼야 한다). + `inst.Destroying:Connect(fn)` 한 줄. 주입 op 전체 목록의 단일 소스는 + `base/architecture.md`의 `EngineOps.luau` 줄이다. +- **필드 목록**: `_destroyConn`(연결 핸들), **`_deps`**(`Ref|State` → 내가 건 + `fn|Observer`, **강참조**), `_epochs`(`EpochMap` — `Ref`도 `Epoch`라 균일), + `_blocker`(등록 구간 억제), `_cleanup`, **`_installed`**(설치 여부 — + cleanup 반환이 선택이라 `_cleanup`으로는 판정 못 한다), + `_running`/`_pending`(재진입). + **옛 `_refDeps`/`_refCallbacks`/`_observers`/`_installing`은 `_deps` 하나와 + `_blocker`로 대체됐다.** ### ⭐ `Ref` 의존성의 해제 경로 (2026-08-24 확정, 6라운드 손 트레이싱 `H-7`) +> **⭐⭐ [2026-08-25 갱신, 7라운드 `H-58`/`H-59`] 해제 경로 대신 `Weak*` +> 등록으로 바뀌었다.** 아래가 진단한 누수(*"leaf가 죽어도 `ref.Callbacks`에 +> 클로저가 영원히 남는다"*)는 그대로 유효하지만, **해법이 바뀌었다** — +> `unbindLifetime`/`:Unsubscribe()`에서 `:Uncallback`하는 대신 +> `ref:WeakCallback(cb)`로 걸고 **강한 주인을 `Effect._deps`에 둔다**. +> 그러면 `Effect`가 죽을 때 콜백도 같이 죽어 항목이 자연히 사라지고, +> 바인드/언바인드마다 떼었다 붙이는 춤이 없어진다(그 춤이 `H-58`의 +> 중복 `Rerun`을 만들던 원인이다). 위 "확정 구조" 절이 소스. +> **아래 `canExecute` 게이팅은 그대로 유효하다** — *"해제는 누수를, +> 게이팅은 발화를 막는다"* 중 **뒤쪽 절반**이 여전히 이 절의 결론이다. + `Effect(fn, someRef)`의 leaf가 죽어도 **`ref.Callbacks`에 클로저가 영원히 남았다** — `Ref`엔 콜백 해제 API가 없었고(`:Uncallback` 류 없음) `canExecute` 게이팅도 안 걸린다(그건 Observer 쪽 배관이다). 그 클로저가 @@ -236,7 +428,17 @@ named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순 **보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째 세션, 재확인 후 명시화)**: -**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`] 이 문단 전체가 아직 "Observer 하나" +> **⛔⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] 이 문단 전체는 옛 모델이다.** +> 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절이 **정반대로** 확정했다 — +> **`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고** +> 내부 Observer로 cascade하지 않는다. dep 등록은 **생성자에서 한 번만** +> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`이 +> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는 +> **전부 `_deps` 하나와 `_blocker`로 대체됐다**(위 "필드 목록"). +> 아래는 히스토리로만 읽을 것 — **이 문단대로 짜면 `H-58`의 중복 `Rerun`이 +> 되살아난다.** + +**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`, 2026-08-25 폐기] 이 문단 전체가 아직 "Observer 하나" 전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.** 아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로 구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려** @@ -295,9 +497,20 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 `self` 반환(Observer와 동일한 fluent 대칭). - **`:Subscribe()`** — Observer가 쓰는 것과 같은 강참조 레지스트리에 - 자신(또는 `state` 있는 경우 내부 Observer)을 등록 — 새 메커니즘 아님, - 기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속 - 살아있음(Observer와 동일 관용구). + **핸들 자신**을 등록 — 새 메커니즘 아님, 기존 레지스트리 재사용. 이후 + 로컬 변수로 참조를 안 들고 있어도 계속 살아있음(Observer와 동일 관용구). + - **⭐ [2026-08-25 확정, 7라운드 `H-59`] "핸들 자신이냐 내부 Observer냐"는 + 더 이상 구현 세부가 아니다 — 핸들 자신이다.** 옛 서술은 + *"자신(또는 `state` 있는 경우 내부 Observer)"*, *"`handle._observers`만으로 + 충분한지는 구현 세부"*였는데, `H-7`/`H-11`이 **핸들 자신의 생존 판정**에 + 의존하는 배선을 추가하면서 그 선택이 계약이 됐다. 내부 Observer만 + 등록하면 (a) `handle.Subscribed`가 안 세워져 `canExecute(handle)`이 + 영원히 거짓이고, (b) deps 없는 `Effect(fn):Subscribe()`는 등록할 게 + 아예 없어 핸들이 GC되고 cleanup이 유실된다. + - **`:Subscribe()`가 하는 일은 그것 하나뿐이다** — 내부 Observer와 `Ref` + 콜백은 **생성자에서 이미 `Weak*`로 걸려 있다**(위 "확정 구조" 절). + `Subscribed = true`가 서는 순간 `canExecute(handle)`이 참이 되어 그 + 경로들이 살아난다. - **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance 생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`에 묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지 @@ -348,12 +561,14 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 *"relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup 이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면 dedup 되어야하는듯."* - - **⭐ 단, 내부 Observer cascade도 그 분기 *안*에 있어야 한다** - (5라운드 `EF-5`, 확인됨) — `EffectHandle`은 자기 자신뿐 아니라 - `handle._observers`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup - 분기 **밖**에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다 - (handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시 - 같은 `if` 안에 둘 것. + - **⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] `EF-5`의 "내부 Observer + cascade도 그 분기 안에" 요구는 사라졌다.** 원문은 *"`EffectHandle`은 + 자기 자신뿐 아니라 `handle._observers`까지 같이 bind/unbind해야 하는데, + 그 cascade가 dedup 분기 밖에 있으면 handle과 내부 Observer의 바인딩 + 상태가 갈린다"*였는데, **`bindLifetime`/`unbindLifetime`이 이제 핸들 + 하나에만 적용되고 cascade 자체가 없다**(위 "확정 구조" 절, + `_observers` 필드도 `_deps`로 통합돼 폐기). 갈릴 상태가 없으므로 + 이 요구는 성립하지 않는다 — **그대로 구현하면 `nil`을 순회한다.** - **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린 항목이 아니다. - **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥 @@ -363,8 +578,13 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도 동일하게 "지금 끝났다"는 신호로 취급해야 계약이 일관됨: - 1. `state`가 있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을 - 끊고, + 1. **⚠️ [2026-08-25 정정, 7라운드 `H-58`/`H-59`]** 여기 원래 *"`state`가 + 있으면 내부 Observer도 `:Unsubscribe()`해서 향후 재실행을 끊고"*라 + 적혀 있었는데, 내부 Observer는 생성자에서 `:WeakSubscribe()`로 걸리고 + **해제하지 않는다** — 발화는 `canExecute(handle)`이 막는다(위 + "확정 구조" 절). `:Unsubscribe()`가 `handle.Subscribed = false`로 + 만들면 그 게이트가 곧바로 거짓이 되므로 **향후 재실행은 그것만으로 + 끊긴다.** 단수 `state` 전제도 이미 `...deps`로 대체됐다. 2. **직전(또는 유일한) cleanup을 정확히 1회 호출** — leaf가 죽을 때 하던 것과 정확히 같은 이벤트를 수동으로 앞당기는 것. 3. **idempotent, 그리고 이후 leaf가 실제로 죽어도 cleanup이 중복 @@ -405,8 +625,28 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect - **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** — State/Source면 `Observer`, `Ref`면 `:Callback`. `:With`로 합치지 않는다. + **[2026-08-25 정정]** 각각 `:WeakSubscribe()` / `:WeakCallback()`으로 + 걸고, 강한 주인은 `Effect._deps`다(위 "확정 구조" 절). +- **⭐ [2026-08-25 확정, 7라운드 `H-70`] deps 검증** — 지금까지 + 비어 있던 세 자리를 정한다. + - **`nil` dep은 error.** `{...}` + `ipairs`로 순회하면 `nil` 구멍 뒤가 + 조용히 잘리므로 **`select("#", ...)`로 돌아야** 한다. + - **State/Source/`Ref`가 아닌 값은 error.** `H-40`이 `:List`의 요소 + 검증을 블랙리스트에서 화이트리스트로 뒤집은 것과 같은 성격이다 — + 이물 dep은 전파할 것이 없으므로 조용히 무시하면 "왜 안 발화하지"만 + 남는다. + - **중복 dep은 조용히 무시**(error 아님). **사용자 근거**: *":With 이나 + 시소한 연산으로 다른 State 가 된다던가 하면 deps 가 겹쳐도, 근원 + source 가 겹쳐도 에러를 안 냄. Ref 도 유사한 부분."* `Ref`가 `Epoch`로 + 승격돼(`base/ref-plan.md`) `EpochMap`이 키로 dedup하므로 **공짜로** + 처리된다 — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로 먼저 건 클로저가 + `Ref.Callbacks`에 남던 버그도 같이 사라진다. + - 검증은 전부 **생성자에서 한 번만** 도므로 hot path가 아니다. + error `level`은 **2**(사용자 입력 검증) — `base/architecture.md`의 + error 계약 절. - **⭐ [확정, 2026-08-24 6라운드 손 트레이싱 `H-14`] `fn`의 시그니처는 - `fn(self: EffectHandle) -> (() -> ())?` 이고, `...deps`는 의존성 선언일 뿐 + `fn(self: EffectHandle) -> ...(() -> ())` 이고(**[2026-08-25 `H-95`]** 가변 + 반환 팩으로 정정 — 옛 `-> (() -> ())?`는 정상 용례를 막았다), `...deps`는 의존성 선언일 뿐 `fn`에 넘어가지 않는다.** **사용자 확정**: *"`Effect( fn(self: Effect)->()->(), ...deps )` 가 맞는듯. Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게 맞아보이고, @@ -431,14 +671,20 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect - **`Ref` 의존성의 발화 시점은 `Set`될 때뿐**이다(Ref는 반복 재설정이 가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다. - **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록 - 즉시 1회 실행"이 N번 발화하므로, 설치 구간 동안 발화를 눌러뒀다가 마지막에 - 한 번만 실행한다. **[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 — - 게이트도 `Blocker`도 안 쓴다.** 한때 *"`Blocker`의 "`state:Block()` 없이 - 직접 쓰는" 용례를 그대로 재사용"*이라 적고 정확한 모양을 `Gate` 설계에 - 걸어뒀는데, `Gate`가 **빈 배치일 땐 통지를 안 하는 것**으로 확정되면서 - 성립하지 않는 게 확인됐다(`base/gate-plan.md`의 8번) — 설치 구간엔 어떤 - `Set`도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가 - 없다. 설치 중 발화를 누르는 플래그 하나면 되고 새 메커니즘이 필요 없다. + 즉시 1회 실행"이 N번 발화하므로, 등록 구간 동안 발화를 눌러뒀다가 마지막에 + 한 번만 실행한다. + **⭐⭐ [2026-08-25 정정, 7라운드 `H-58`] 그 억제는 `Effect` 내부 플래그 + (`_installing`)가 아니라 사적 `Blocker` 하나가 한다.** 여기 한때 + *"[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 — 게이트도 `Blocker`도 + 안 쓴다"*고 적혀 있었는데, 그 플래그는 **생성자 구간만 덮어 바인드 구간을 + 놓쳤다**(`Ref` 콜백을 바인드마다 재등록하던 옛 모델에서 `Rerun`이 dep 수만큼 + 돌았다). 지금은 dep 등록이 **생성자 한 곳**으로 모였고 억제는 + `self._blocker:On()` … `:OffWithoutEmit()` 구간이 맡는다 — + `materializeSlotTree`가 쓰는 관용구와 같은 모양이고, 위 "확정 구조" 절과 + 생성자 의사코드가 소스다. **`_installing`은 폐기된 필드다.** + (2026-08-21에 `Gate` 재사용을 접었던 근거 — *"설치 구간엔 어떤 `Set`도 안 + 일어나 게이트에 쌓이는 소스가 없다"*, `base/gate-plan.md`의 8번 — 는 그대로 + 유효하다. 게이트가 아니라 `Blocker`를 쓰는 이유이기도 하다.) - **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만 돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`, `A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를, @@ -453,21 +699,29 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect ```lua -- 각 dep의 내부 Observer가 공통으로 거는 클로저 function(self, from) - if handle._installing then return end -- 설치 구간 억제 (아래) + if not canExecute(handle) then return end -- 발화 게이트 + if handle._blocker:IsOn() then return end -- 등록 구간 억제 (위 정정) if handle._epochs:Update(from) then handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행 end end ``` - **⚠️ 억제 플래그가 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회 + **⚠️ 억제 확인이 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회 실행에는 `from`이 없어서(`nil`, `base/source-state-plan.md`의 "`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면 설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다 - (2026-08-21 커밋 전 `/code-review high` 발견). - - **`Ref` 의존성은 이 맵에 안 낀다** — `Ref`는 `Epoch`가 아니고 - `:Callback`으로 발화하므로 `from`이 없다. `Ref` 쪽 발화는 그대로 매번 - `fn`을 돌린다(`Ref`는 반복 재설정마다 도는 게 계약이고, 공통 상류 문제 - 자체가 없다). + (2026-08-21 커밋 전 `/code-review high` 발견). **[2026-08-25]** 플래그가 + `_blocker:IsOn()`으로 바뀌었을 뿐 순서 제약은 그대로다. + - **⭐ [2026-08-25 정정, 7라운드 `H-58`] `Ref` 의존성도 이 맵에 낀다** — + 여기 한때 *"`Ref`는 `Epoch`가 아니고 `:Callback`으로 발화하므로 `from`이 + 없다"*고 적혀 있었는데, **`Ref`가 `Epoch`로 승격**되며(`base/ref-plan.md`) + 공개 `.Revision`을 갖게 됐다. 그래서 `_epochs`가 State/Source/`Ref`를 + **같은 방식으로** 담고(**⚠️ [2026-08-25 정정]** 한때 여기 "State/Source/ + `Ref`를 균일하게"라 적었는데 **`State`는 `Epoch`가 아니다** — 등록이 + `isEpoch`로 갈려 `Source`/`Ref`는 `:Sync`, `State`는 `:TrackFrom`이다, + `base/state-epoch-plan.md` §4·§8), 같은 `Ref`를 두 번 dep으로 넣어도 키 dedup으로 접힌다 + (`H-70`). `Ref`가 반복 재설정마다 도는 계약 자체는 안 바뀐다 — 리비전이 + 매번 갱신되므로 `Update`가 매번 `true`다. - **검토했다 접은 대안**: deps를 하나의 파생 노드로 수렴시켜 다이아몬드 dedup에 태우기 — 노드가 늘고 "N deps → N observers" 구조를 바꿔야 해서 위 안보다 못하다. `useEffect`처럼 "N번 돌아도 무방"으로 계약을 느슨하게 @@ -475,14 +729,20 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect - 근거 기록은 `reference/epoch-brand-composition.md`(이 갭이 `EpochMap` 분리의 직접 발단이었다). - **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도 - N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를 - 같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피 - 모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."* + N개다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피 모든 옵져버들이 + 내부에 들어가 있을것이므로 가능하다."* + **⭐ [2026-08-25 정정, 7라운드 `H-58`/`H-59`] 다만 그 "전부"를 덮는 주체는 + bind/unbind cascade가 아니다** — `bindLifetime`/`unbindLifetime`은 이제 + **핸들 하나에만** 적용되고, N개 dep의 발화 여부는 `canExecute(handle)` + 하나가 전담한다(위 "확정 구조" 절). 등록도 생성자 한 곳에서 끝난다. **우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M2의 `Effect` 구현과 같이 간다. **[2026-08-21]** 여기 있던 "억제 장치 때문에 -`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Effect` 내부 플래그로 -확정돼 `Gate`에 안 걸린다. +`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Gate`에 안 걸린다. +**[2026-08-25 정정]** 억제 수단이 내부 플래그에서 **사적 `Blocker`**로 +바뀌었으므로 선행은 `Blocker`의 기본 메커니즘(`On`/`Off`/`IsOn`/ +`OffWithoutEmit`)이다 — 그건 `GateNode`/`:Policy`와 무관하게 독립 완결이라 +`Gate`보다 뒤일 필요는 여전히 없다(`ROADMAP.md` M2의 그 각주). ## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체) diff --git a/.claude/base/gate-plan.md b/.claude/base/gate-plan.md index b6d3561..2500c27 100644 --- a/.claude/base/gate-plan.md +++ b/.claude/base/gate-plan.md @@ -91,10 +91,43 @@ end) **확정 형태**: ```lua - -- setup: (emit: () -> ()) -> (onUpstreamEmit: () -> ()) + -- setup: (emit: (commit: boolean?) -> boolean) -> (onUpstreamEmit: () -> ()) local gated = state:Gate(setup) -- ComputeNode처럼 GateNode를 하나 만든다 ``` + **⭐⭐ [2026-08-25 확정, 7라운드 `H-55`/`H-86`] `emit`에 인자와 반환값이 + 붙는다.** 인자 목록이 늘지 않으므로 `H-49`의 *"`setup` 시그니처는 안 + 바뀐다"*를 최소로만 되짚는다. + + | 호출 | 뜻 | + |---|---| + | `emit()` / `emit(true)` | 평소대로 흡수 집합을 flush하고 전파 | + | `emit(false)` | **흡수 집합을 버리고** 전파하지 않는다 | + | 반환값 | "실제로 내보내거나 버릴 게 있었는가"(= 집합이 비어 있지 않았는가) | + + - **왜 필요한가 (1) — 버릴 수가 없었다(`H-55`).** 아래 4번은 *"emit 없이 + 푸는 경로는 집합을 **버려야** 한다"*고 확정하는데, `Blocker`가 + `blocker:Policy(emit)` **값**으로 떨어져 나온 뒤로 정책이 손에 쥔 건 + `emit` 하나뿐이라 **집합에 닿는 통로가 없다.** 그래서 + `Trailing = false`/`Cancel`/`OffWithoutEmit`이 "버린다"가 아니라 + "미룬다"가 되어, 버리기로 했던 옛 원천들이 **다음 버스트에 실려 + 나간다**(4번이 경고한 바로 그 모양이 정책 분리로 되살아난 것). + - **왜 필요한가 (2) — 읽을 수가 없었다(`H-86`).** 5번이 *"`pending` 같은 + 정책 상태는 `HasBlockedEmit`으로 흡수한다"*고 확정했는데, `blocker-plan.md`가 + `HasBlockedEmit`을 **게이트 노드의 `withheld`**로 흡수해버려 **양쪽 다 + 안 들고 있다.** 그 결과 `Throttle`의 창이 idle로 못 돌아가 + **leading이 첫 버스트 이후 영구 소실**되고 타이머 체인이 안 끝나 + `base/debounce-throttle-plan.md`의 "8. 라이프사이클 / GC 분석" 절이 확정한 "유계 GC"가 깨진다(실측 확인). + - **`Throttle`의 `onWindowEnd`가 이 한 줄로 닫힌다**: + ```lua + if not emit() then window = nil -- 보류분 없었음 → 완전 idle 복귀 + else rearm() end + ``` + - **기각된 안**: `setup(emit, discard, hasWithheld)`처럼 핸들을 늘리는 것 + (인자 목록 자체가 바뀌어 `H-49`를 더 크게 되짚는다), 정책이 자기 + `pending`을 다시 드는 것(`H-32`를 손으로 다시 막아야 하고 버리기는 + 여전히 안 닫힌다). + `Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Block(blocker)`가 내부에서 이 배선을 그대로 쓴다. 탑레벨 `Gate(...)` 생성자는 **안 만든다.** `:Gate`가 메소드라고 `:Apply`와 배타적인 것도 아니다 — 사용자 지적대로 @@ -120,8 +153,13 @@ end) - **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고, 타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State) -> U` 함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력). - `Apply`를 쓸 이유 자체가 없어졌으므로 확인할 필요도 없어졌지만, 혹시 - 되살아나면 `luau-test` 스파이크 한 개로 판정할 것. + **⭐ [2026-08-25 실측 확정, 7라운드 `H-94`] 안 들어간다.** + `luau-analyze`로 재현했고(제네릭·비제네릭 양쪽), 타입 레벨 `__call`은 + `self`도 못 받는다. 여기서 *"확인할 필요도 없어졌다"*고 접었지만 + **`Debounce`/`Throttle` 쪽엔 그 불확실성이 그대로 남아 있었다** — 바로 + 아래 항목이 *"`Debounce`/`Throttle`은 `:Apply` 그대로 둔다"*를 확정하기 + 때문. 그래서 애플리커티브 팩토리는 `__call`이 아니라 **지정된 필드**로 + 자기를 노출한다(`base/source-state-plan.md`의 "`state:Apply(factory)`" 절). - **2단 구조는 그대로 유효하다** — 사용자 관찰(*"Gate 의 callback 으로 얻어진 emit과, 리턴해낸 클로저가 호출되는걸로 배선은 가능"*) 대로, 바깥 함수가 **그 노드의 `emit`을 캡처**하고 반환 클로저가 상류 emit마다 정책을 태운다. @@ -173,11 +211,23 @@ end) 중첩 flush의 `clear`가 돌아 **바깥 전파의 남은 갈래가 빈 집합**을 받는다. **모델이 아니라 그 의사코드가 문제였다** — `emit()`이 flush인 이상 들어가는 순간 떼어내는 게 원래 모양이고, 그러면 그 경로 자체가 없다: + ```lua + -- ⭐ [2026-08-25 보강, 7라운드 `H-89`] 아래 8번과 `H-89`가 확정한 + -- **네 단계**를 그대로 적는다. 한때 이 스니펫이 가운데 둘만 갖고 + -- 있어서, 이걸 보고 짜면 "빈 배치도 통지" + "`emitEpochMap`을 언제 + -- `Sync`하는지 불명" 두 실수가 난다. + function GateNode:_flush(commit: boolean?) + if next(self._withheld) == nil then return false end -- (1) 빈 배치 얼리리턴 + local batch = self._withheld + self._withheld = newWeakK() -- (2) 새 테이블. clear가 아니다 + if commit == false then return true end -- 버리기(H-55) — 전파도 Sync도 안 함 + self.emitEpochMap:Sync(batch) -- (3) 전파 **앞**에서 한꺼번에 + emitDownstream(self, batch) -- (4) 떼어낸 batch를 페이로드로 + return true + end ``` - local batch = self._withheld - self._withheld = newWithheld() -- 새 테이블. clear가 아니다 - emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다 - ``` + 반환값이 `emit(commit) -> boolean`의 그 반환값이다 — **"실제로 + 내보내거나 버릴 게 있었는가"**(위 2번, `H-55`/`H-86`). **⚠️ [정정, 2026-08-24 6라운드 손 트레이싱 `H-9`] 그 새 테이블도 weak여야 한다.** 여기 원래 `self._withheld = {}`라고 적혀 있었는데, 그러면 위에서 **weak key로 확정한 집합이 첫 flush 스왑에서 평범한 테이블로 바뀐다** — @@ -212,7 +262,12 @@ end) 게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이 먼저 풀리든 정보가 안 샌다. - **게이트의 `emitEpochMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다 - (집합 전체에 대해 한꺼번에, `:Sync(batch)`). **이건 `state-epoch-plan.md` + (집합 전체에 대해 한꺼번에, `:Sync(batch)`). + **⭐ [2026-08-25 명시, 7라운드 `H-89`] `:Sync(batch)`는 전파 *앞*이다** — + flush 순서는 **빈 배치 얼리리턴 → 스왑 → `:Sync(batch)` → 전파**다. + 지금까지 *"실제로 전파할 때"*라고만 적혀 앞/뒤가 안 정해져 있었다. + 그리고 **수신 시점의 판정에는 `:Peek`을 쓴다** — `:Update`는 덮으므로 + 이 예외와 양립하지 않는다(`base/state-epoch-plan.md` §3). **이건 `state-epoch-plan.md` §4 의사코드의 유일한 예외이고, 그 문서에 예외로 기록돼 있다.** 그래야 "내가 하류로 던진 리비전"이라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중 같은 리비전이 다른 경로로 또 오면 규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에 @@ -224,14 +279,85 @@ end) 게이팅이 매 프레임 `On()` → … → `OffWithoutEmit()`을 도는 동안 집합이 **단조 증가**하고, 나중에 아무 `Epoch`나 한 번 통과하는 순간 **버리기로 했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다. + - **⭐⭐ [2026-08-25 정정, 7라운드 `H-67`] 여기 근거로 든 용례가 + 틀렸다.** `Dispatch.drive`의 배치 게이팅은 `base/blocker-plan.md`가 + *"이 용례는 `state:Block()`을 전혀 호출하지 않으므로 gated state도 … + 생기지 않는다"*고 명시한 경로라 **애초에 `withheld` 집합이 없다.** + 결론(비워야 한다)은 그대로 유효하고 **근거가 될 용례만 바꾼다** — + `state:Block(b)`로 만든 gated state에 `b:OffWithoutEmit()`을 반복해 + 거는 경우, 또는 `Throttle{Trailing = false}`가 창마다 버리는 경우가 + 실제로 집합이 단조 증가하는 자리다. + - **⭐⭐ [2026-08-25] 정책은 이걸 스스로 할 수 없다** — 위 2번의 + `emit(false)`가 그 통로다(`H-55`). - 그렇게 비우고 나면 하류의 `emitEpochMap`은 뒤에 남지만, 그 `Epoch`의 다음 진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요. - **⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라 - **노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화). + **⭐ 그래서 `setup`의 인자 목록은 안 바뀐다.** 집합을 **채우는** 건 정책이 + 아니라 **노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 + 단순화). **[2026-08-25 정정]** 다만 집합을 **버리고/읽는** 것은 정책이 + 해야 하는 일이었고 통로가 없었다 — 그래서 위 2번의 + `emit(commit: boolean?) -> boolean`이 생겼다. 인자 **목록**은 그대로다. 정책은 소스를 몰라도 되고, `Throttle`처럼 나중에 타이머에서 `emit()`을 부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다. +### ⭐⭐ `GateNode` 조립 — 필드 목록과 `:_receive`/`:_flush` (2026-08-25 신설) + +**새 결정이 아니라 조립이다.** 판정 규칙은 `base/state-epoch-plan.md` §4, +흡수 집합은 위 4번, `emit` 시그니처는 위 2번 — 지금까지 **세 문서에 나뉘어 +있어 한 곳에서 순서를 볼 수 없었다.** 구현자가 조립을 잘못할 여지를 +없애려고 여기 모은다. + +```lua +-- 필드 (ComputeNode와 같은 층위) +GateNode = { + _hold = { <상류 State/Source> }, -- 하류 → 상류 강참조(`source-state-plan.md`) + _subs = , -- 원소는 Observer 값 / 자식 State + valueEpochMap = EpochMap(), -- §4 규칙: 값이 낡았는가 + emitEpochMap = EpochMap(), -- ⚠️ 수신 때가 아니라 **flush 때** 갱신 + _withheld = newWeakK(), -- 흡수 집합 { [Epoch] = true } + onUpstreamEmit = <정책이 돌려준 클로저>, -- setup(emit)의 반환값 +} + +function GateNode:_receive(from) + -- (1) 판정은 §4 규칙 1~3을 **그대로** 돈다. 단 emit 쪽은 `Peek`이다 — + -- `Update`는 덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻이 깨진다. + local valueChanged = self.valueEpochMap:Update(from) + local emitChanged = self.emitEpochMap:Peek(from) + if valueChanged then self:_invalidate() end -- 캐시 카운터 갱신 + if not (valueChanged or emitChanged) then return end -- 규칙 3: 삼킨다 + -- (정책도 안 돌고 집합에도 안 넣는다) + + -- (2) 통과한 것만 흡수 집합에 합친다. `from`이 집합이면 unfold해서 합친다 + -- — 게이트-게이트 중첩에서 중복이 저절로 접힌다(집합이라서). + if isEpochSet(from) then + for epoch in from do self._withheld[epoch] = true end + else + self._withheld[from] = true + end + + -- (3) 정책에 넘긴다. 정책은 손에 `emit` 하나만 쥐고 있고, + -- 그걸로 flush(`emit()`)도 버리기(`emit(false)`)도 조회(반환값)도 한다. + self.onUpstreamEmit() +end + +function GateNode:_flush(commit: boolean?): boolean -- 이게 정책이 받는 `emit` + if next(self._withheld) == nil then return false end -- (1) 빈 배치면 아무것도 안 함 + local batch = self._withheld + self._withheld = newWeakK() -- (2) 새 테이블. clear가 아니다 + if commit == false then return true end -- 버리기 — 전파도 Sync도 안 함 + self.emitEpochMap:Sync(batch) -- (3) 전파 **앞**에서 한꺼번에 + self:_emitDown(batch) -- (4) 떼어낸 batch가 페이로드 + return true +end +``` + +- **반환값이 곧 위 2번의 `emit(commit) -> boolean`**이다 — "실제로 내보내거나 + 버릴 게 있었는가"(`H-55`/`H-86`). +- **`valueEpochMap`도 있다는 걸 여기서 명시한다** — 지금까지 *"규칙 1~3을 + 그대로 돈다"*는 문장에서 추론해야 했다. +- **`_emitDown`은 State와 같은 것**이다(`base/source-state-plan.md`의 + "전파 루프 — 확정 의사코드" 절) — 게이트가 전파 루프를 따로 갖지 않는다. + 5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 — 지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적, `debounce-throttle-plan.md` 5-4). diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index cc8832d..f3cdf05 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -335,15 +335,22 @@ function bindLifetime(inst, value) -- `slot._detachCleanup`(Detach 요소를 파괴하는 유일 경로)과 `OnDestroyed`가 -- 통째로 무동작이었다(`base/effect-plan.md`). -- **왜 이 자리인가**(사용자 판단 2026-08-24): `Destroying`은 엔진이 아는 - -- 요소라 엔진을 다루는 자리에 있어야 하고, 이 함수는 이미 `Effect`의 내부 - -- Observer 목록으로 cascade하며 값을 들여다본다. `Effect`가 바인드되는 + -- 요소라 엔진을 다루는 자리에 있어야 하고, `Effect`가 바인드되는 -- 경로가 둘(children 배열 leaf / `activateList`의 `_detachCleanup` 직접 -- 바인드)이라 **호출부 쪽에 두면 반드시 한쪽이 샌다.** + -- + -- ⭐⭐ [2026-08-25 정정, 7라운드 `H-58`/`H-59`] **내부 Observer로 cascade하지 + -- 않는다.** 여기 원래 `for _, observer in ipairs(value._observers) do + -- bindLifetime(inst, observer) end`가 있었는데, 그 필드 자체가 폐기됐고 + -- (`_deps` 하나로 통합) dep 등록은 **생성자에서 한 번만** 일어난다 + -- (`WeakSubscribe`/`WeakCallback`). 그대로 두면 `_observers`가 `nil`이라 + -- 순회에서 죽고, 피해 가도 **바인드마다 `Rerun`이 도는 `H-58`이 + -- 되살아난다.** 발화 게이팅은 전부 `canExecute(handle)` 하나가 맡는다 — + -- `base/effect-plan.md`의 "확정 구조" 절이 소스. if isEffect(value) then - for _, observer in ipairs(value._observers) do -- N-deps cascade - bindLifetime(inst, observer) - end - value:_bindDestroying(inst) -- Destroying 연결 + Ref dep 콜백 (재)등록 + value:_bindDestroying(inst) -- Destroying 연결 + **조건부 캐치업 1회** + -- (`if not self._installed or + -- self._epochs:Refresh() then self:Rerun() end`) -- 의사코드는 `base/effect-plan.md`가 소스. -- 그 안에서 주입 op `onDestroying(inst, fn)`을 -- 부른다(base는 Instance를 모른다). @@ -352,15 +359,16 @@ end function unbindLifetime(value) -- [2026-08-24 `H-11`] bind의 대칭 — **cleanup은 부르지 않는다.** - -- `Destroying` 연결을 끊고 `Ref` dep 콜백을 떼기만 한다. cleanup을 여기서 - -- 부르면 `destroySlotTree`가 `_detachCleanup`을 손으로 비운 뒤 unbind하는 - -- 경로에서 이중 호출이 된다(`base/effect-plan.md`의 그 항목이 소스). - -- 이 대칭 덕에 포탈이 자연히 성립한다 — 언마운트가 떼고 재마운트가 다시 건다. + -- cleanup을 여기서 부르면 `destroySlotTree`가 `_detachCleanup`을 손으로 + -- 비운 뒤 unbind하는 경로에서 이중 호출이 된다(`base/effect-plan.md`). + -- + -- ⭐⭐ [2026-08-25 정정, 7라운드 `H-58`] **`Ref` 콜백도 내부 Observer도 + -- 안 뗀다.** 둘 다 생성자에서 `Weak*`로 걸려 있고 발화는 `canExecute`가 + -- 막는다 — 떼었다 붙이는 그 춤이 `H-58`의 원인이었다. 포탈은 이제 + -- "떼고 다시 걸기"가 아니라 **재바인드 시 조건부 캐치업 1회**로 + -- 성립한다(`_bindDestroying`). if isEffect(value) then - value:_unbindDestroying() - for _, observer in ipairs(value._observers) do - unbindLifetime(observer) - end + value:_unbindDestroying() -- Destroying 연결만 끊는다 end local gchold = BindData:GetWeak(value, "gchold") @@ -512,12 +520,26 @@ Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라 `canExecute`가 "어디서 불리는가"는 지금까지 어느 문서에도 코드로 없었음(위 정정 배너 참고). 확정된 위치는 **State의 전파 루프**: -- State는 자기 구독자(Observer의 emit 클로저)를 **weak로** 담는다 — 살려두는 - 책임은 State가 아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 - 있고, 어디에도 안 묶인 Observer는 그냥 GC되어 구독 목록에서 자연히 빠짐. -- 발화 시 각 구독자에 대해 `canExecute(observer)`를 확인하고, 거짓이면 - **그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst`를 건드리는 시도가 - 일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의 실제 구현 지점. +- State는 자기 구독자를 **weak-키로** 담는다 — 살려두는 책임은 State가 + 아니라 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있고, 어디에도 + 안 묶인 Observer는 그냥 GC되어 구독 목록에서 자연히 빠짐. + **⭐ [2026-08-25 정정, 7라운드 `H-56`] 집합의 원소는 "Observer의 emit + 클로저"가 아니라 **Observer 값**이다.** `bindLifetime(inst, observer)`가 + Observer **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를 + 담으면 identity가 달라 `canExecute`가 **항상 거짓**이 된다. +- 발화 시 **Observer/Effect 구독자에 대해서만** `canExecute(observer)`를 + 확인하고, 거짓이면 **그 구독자만 조용히 건너뜀**(no-op) — 죽은 `inst`를 + 건드리는 시도가 일어나지 않게 막는 위 "해야 할 일은 딱 하나" 원칙의 + 실제 구현 지점. + **⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 자식 State 노드는 이 게이트를 + 안 탄다.** 여기 한때 *"각 구독자에 대해"*라고만 적혀 있었는데, 그대로 + 짜면 `:With`/`:Compute`/`:Gate`가 만든 자식 노드가 **전부 걸러진다** + (자식은 `bindLifetime`된 적도 `:Subscribe()`된 적도 없어 + `isBoundAlive`가 항상 거짓) — `A:Set()`이 파생 State에 한 번도 안 닿아 + 그 아래 모든 Observer가 침묵한다. **확정 의사코드는 + `base/source-state-plan.md`의 "전파 루프 — 확정 의사코드" 절이 소스**이고, + 자식 노드의 생존은 `canExecute`가 아니라 같은 문서의 `_hold` 불변식이 + 책임진다. **`state:Observer(fn)`의 "등록 즉시 1회 실행"은 이 게이팅과 무관**하다 — 그건 Observer 생성자 자체의 계약이라 `bindLifetime` 이전에 동기적으로 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 933e49f..2a76df2 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -425,7 +425,7 @@ Modifier에는 없음). **[정정, 2026-08-18 구현 전 QA]** 여기 짝으로 적혀 있던 두 서술이 이번 라운드에 바뀌었다 — (a) "동적인 건 **문자열 폴백**"의 그 폴백 (`store "key"` 문자열 커링)은 **기각**됐고 동적 키는 -`store:GetDynamic<>(name)`으로 감, (b) 정적 필드가 "**자주 쓰는 것만**"이 +`store:Of<>(name)`으로 감(**[2026-08-25]** 옛 이름 `GetDynamic`), (b) 정적 필드가 "**자주 쓰는 것만**"이 아니라 **생성기가 "GUI에 쓰이는 모든 인스턴스"를 전량 찍어냄** (`base/bind-system-plan.md`). Modifier 타입 생성(M7)이 재사용하는 건 그 **패턴**(제네릭 + 생성된 정적 필드)이지 옛 범위 서술이 아님. diff --git a/.claude/base/quad-types-plan.md b/.claude/base/quad-types-plan.md index 0b8065f..ecbfaba 100644 --- a/.claude/base/quad-types-plan.md +++ b/.claude/base/quad-types-plan.md @@ -188,6 +188,11 @@ export type function CheckVersion(actual: type, pattern: type): type **2개 이상**이면 단일 꺾쇠(`Foo`)가 비교 연산자로 오파싱되니 반드시 이중 꺾쇠(`Foo<>`)를 써야 한다(코퍼스에 이미 있던 `AttributeKey<>` 관례와 같은 이유). + **⭐ [2026-08-25 확장, 7라운드 `H-73`] 이 관례는 타입 자리 전용이 + 아니다** — Luau의 generic type instantiation은 **값 호출부**에서도, + **콜론 메소드**에서도 동작해 `T`를 실제로 묶는다 + (`store:Of<>("x")`). `luau-analyze` 음성 대조군까지 + 확인했다 — 상세는 `base/store-plan.md`의 "타입 추론 문제" 절. `Version` 필드는 Luau 내장 `index` type function으로 뽑는다 (수동 `t:readproperty(...)`보다 간결 — **사용자 제안**으로 채택, 실측 확인 diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index e6e4359..a4e40da 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -167,11 +167,41 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 `Ref(default):Callback(fn)`이 코퍼스 전반에 쓰인다) — 해제 핸들은 **`:Uncallback(fn)`**으로 뗀다. 셋이라 `Callbacks[fn] = nil` 한 줄이고, 중복 등록이 dedup되므로(위) "몇 번 떼야 하나"가 없다. - - `EffectHandle`은 자기가 건 콜백을 들고 있다가 `unbindLifetime`과 - `:Unsubscribe()`에서 `:Uncallback`한다 — State/Source dep의 - `_observers`와 대칭(`base/effect-plan.md`). + - **⛔ [2026-08-25 폐기, 7라운드 `H-58`] 여기 원래 *"`EffectHandle`은 + 자기가 건 콜백을 들고 있다가 `unbindLifetime`과 `:Unsubscribe()`에서 + `:Uncallback`한다"*고 적혀 있었다.** 아래 `:WeakCallback` 항목이 그걸 + **대체한다** — `Effect`는 생성자에서 `ref:WeakCallback(cb)`로 한 번만 + 걸고 강한 주인을 `_deps`에 두며, 바인드/언바인드는 `Ref`를 아예 안 + 건드린다(떼었다 붙이는 그 춤이 `H-58`의 "바인드마다 `Rerun`"을 만들던 + 원인이다). `:Uncallback`은 **사용자가 직접 건 콜백을 떼는** 공개 + 표면으로 남는다. `base/effect-plan.md`의 "확정 구조" 절이 소스. - **`:Wait()`의 대기자는 이 표면의 대상이 아니다** — 발화 시 소진되므로 뗄 일이 없다. + - **⭐⭐ [2026-08-25 신설, 7라운드 `H-58`/`H-59`] `:WeakCallback(fn)` — + 약하게 등록하는 짝.** `Weak` 쪽이 **프리미티브**이고 평범한 + `:Callback(fn)`은 그 위에 "GC 안 되도록 킵" 하나를 더 얹은 것이다. + **사용자 확정**: *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 + 동일하나 단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다. + 따라서 내부적으론 Weak 를 구현해 두고, Weak 아닌 곳에서 Weak 를 + 수행하고 gc 처리만 두면 돼요."* + - **자료구조**: 약한 등록은 **weak-키 테이블**(`__mode = "k"`)에 + 들어간다 — `.Callbacks`(강함)와 별도 테이블이다. Lua의 weak 모드는 + 테이블 단위라 항목별로 섞을 수 없다. 발화 순회는 두 테이블을 다 + 훑고(각각 스냅샷), `:Uncallback(fn)`은 양쪽을 다 본다. + - **왜 필요한가**: `Effect`가 자기 dep `Ref`에 콜백을 걸면 + `.Callbacks`가 강한 셋이라 **`Ref`가 그 `Effect`를 영원히 붙든다**. + 그렇다고 바인드/언바인드마다 떼었다 붙이면 *"등록 즉시 1회 호출"*에 + 걸려 **바인드마다 `Rerun`이 돈다**(`H-58`). `:WeakCallback`으로 걸고 + **강한 주인을 `Effect._deps`에 두면** 둘 다 사라진다 — + `Effect ↔ cb` 순환이 자기완결이라 Luau GC가 통째로 수거하고 + `Ref` 쪽 항목도 같이 없어진다(`base/effect-plan.md`의 + "확정 구조 — 강한 주인은 항상 `Effect`" 절). + - **`WeakRef`(1-슬롯 약참조 박스)는 만들지 않는다** — 그건 "`Ref`가 + `Effect`를 붙든다"만 풀고 "죽은 클로저가 `.Callbacks`에 쌓인다"는 + 못 푼다(사용자 지적). 아이디어만 기록: + `WeakRef:Set(v)`/`:Get()`만 주고(`.Value`가 아닌 이유는 내부 값이 + 항상 있다고 확정된 상태가 아니라서), 내부는 + `setmetatable({}, {__mode = "v"})`의 1-슬롯. - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 @@ -325,6 +355,41 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이 없다는 결론, 용어 정리 대상에서 제외됨. +### ⭐⭐ `Ref`는 `Epoch`를 만족한다 — `.Revision` 신설 (2026-08-25 확정, 7라운드 `H-58`/`H-64`/`H-70`) + +**사용자 제안**: *"혹은 Ref 까지도 Epoch 를 구현해줘도 좋다는 생각. +'바뀌였나?' 보는건 source 에 대한 계약이라, 똑같이 실제 값을 가지는 Ref 도 +이를 구현해주는데 문제가 없음."* + +`base/state-epoch-plan.md` §2가 **이미 예상해둔 확장**이다 — 그 절이 +`Epoch`를 `Source`가 아니라 최소 인터페이스로 일반화한 실익으로 든 게 +*"`Source`가 아닌 원천(외부 시계 등)이 특수 분기 없이 낀다 … 그건 +`EpochBrand:register(self)` 한 줄로 끝난다"*였다. + +- **`Ref`에 공개 필드 `Revision: number`가 생긴다.** `:Set()`이 + `Source`와 **같은 한 줄**로 갱신한다 — `self.Revision = bit32.bnot(-self.Revision)` + (그 문서 §2의 랩어라운드 감소). 공개여야 구조적 만족이 타입 레벨에서 + 성립한다. +- **`EpochBrand:register(self)`** — `Source`가 `SourceBrand`이면서 동시에 + `EpochBrand`인 것과 같은 다중 태깅(`base/brand-plan.md`). +- **`.Callbacks`(푸시 경로)는 그대로다** — `Epoch`는 **부기**일 뿐, + 전파 메커니즘을 대체하지 않는다. +- **무엇이 닫히나**: + - `Effect._epochs`(`EpochMap`)가 `Ref` dep을 **`Source`와 같은 방식으로** + 담는다(**⚠️ [2026-08-25 정정]** 한때 "State/Source/`Ref`를 균일하게"라 + 적었는데 **`State`는 `Epoch`가 아니다** — 등록이 `isEpoch`로 갈려 + `Source`/`Ref`는 `:Sync`, `State`는 `:TrackFrom`, `base/state-epoch-plan.md` + §4·§8. 균일해지는 건 **판정 쪽**이다). + 그래서 포탈 재마운트의 캐치업이 dep 종류에 따라 갈리던 것(`H-64`)이 + **대칭**이 되고, 판정이 `if self._epochs:Refresh() then self:Rerun() end` + 한 줄이 된다. + - 같은 `Ref`를 deps에 두 번 넣어도 `EpochMap`이 키로 dedup하므로 + **공짜로** 처리된다(`H-70`) — 옛 `_refCallbacks[ref] = cb` 덮어쓰기로 + 먼저 건 클로저가 `.Callbacks`에 남던 버그도 같이 사라진다. + - `Ref` dep 전용 스냅샷 배열 같은 별도 부기가 필요 없어진다. +- **`Ref`는 여전히 `State`가 아니다** — emit(전파)이 없고 `Get`/`Compute`도 + 없다. `Epoch`를 만족한다는 건 "직전과 구별되는 표식을 들고 있다"뿐이다. + ### `Ref`의 retract — `State` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용) > **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료.** @@ -380,13 +445,18 @@ function RefLeafHandler.process(inst, k, v, index) Dispatch.setOffsetSource(inst, k, None) Dispatch.setLength(inst, k, 0, inst) - local old = relate:GetStrong(inst, k) + local old = relate:GetWeak(inst, k) -- ⭐ [2026-08-25 `H-71`] 쓰기가 SetWeak이므로 읽기도 Weak + -- (안 맞추면 old가 항상 nil이라 dedup이 통째로 죽는다) if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip bindLifetime(inst, v) -- v가 이미 다른 자리에 살아있으면 여기서 즉시 error — -- 이중 배치 방지("이중 배치 방지" 절 참고), 별도 Relate 불필요 v:Set(inst) end - relate:SetStrong(inst, k, v) + -- ⭐ [2026-08-25, 7라운드 `H-71`] `SetStrong` 아님 — `v:Set(inst)`로 값이 + -- 자기 바깥 키를 되참조하므로 `SetStrong`이면 **100% 샌다**(실측 50/50). + -- dedup은 순수 성능 최적화라 weak로 낮춰도 최대 손해가 "한 번 놓침"이고, + -- `v`는 gchold가 이미 강하게 잡는다(`base/relate-plan.md`의 슬롯 표). + relate:SetWeak(inst, k, v) return function(nextValue) -- nextValue는 nil이거나 같은 핸들러가 곧 처리할 새 Ref(타입 보장됨) — v는 -- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요) @@ -399,7 +469,7 @@ function RefLeafHandler.process(inst, k, v, index) -- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고 -- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이 -- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음. - if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end + if relate:GetWeak(inst, k) == v then relate:SetWeak(inst, k, nil) end end end end @@ -411,7 +481,7 @@ end 불리게 함. - **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우 이전 클로저가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로) - 아예 없고 `relate:GetStrong(inst,k)`도 `nil`이라 `process`가 바로 + 아예 없고 `relate:GetWeak(inst,k)`도 `nil`이라 `process`가 바로 `v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의 구현으로 자연히 커버됨, 케이스 분기 불필요. - **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째 diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md index 62e5725..6d80f6e 100644 --- a/.claude/base/relate-plan.md +++ b/.claude/base/relate-plan.md @@ -90,7 +90,66 @@ Destroy 전까지 userdata 동일성을 고정해주므로, 모든 `inst`-키 `R `slot._mountedInst`는 그 값(`slot`)이 자기 키(`physicalTarget`)를 다시 참조하는 필드일 뿐이라 GC 안전 — `base/slot-plan.md` "재귀 메커니즘" 절)는 **단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가 -테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문. 하지만 +테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문. + +> **⛔⛔ [2026-08-25 전면 정정, 7라운드 `H-71`/`H-77`] 바로 위 "단일 `Relate` +> 안에서는 안전함"은 **거짓이다.** 커밋된 `quad-base/src/Relate.luau`로 +> 실측했다.** +> +> 위험은 **값 기준 하나**가 아니라 **슬롯 셋**으로 갈린다: +> +> | 슬롯 | `SetStrong` | `SetWeak` | +> |---|---|---| +> | 바깥 키(`inst`) | weak(설계) | weak(설계) | +> | **내부 키(`key`)** | **강함** | **강함** | +> | 값(`value`) | **강함** | weak | +> +> ```lua +> -- 커밋된 Relate.luau, 그대로 +> buckets[inst] = bucket -- buckets는 __mode = "k" +> bucket.StrongMap[key] = value -- 평범한 테이블 → 키·값 모두 강함 +> bucket.WeakMap = setmetatable({}, { __mode = "v" }) -- **값만** weak +> ``` +> +> Luau엔 ephemeron이 없으므로 **버킷 안의 무엇이든 `inst`를 되참조하면 +> `buckets`의 weak 키가 자기 버킷을 통해 살아남는다.** +> +> - **`SetStrong`의 값이 `inst`를 되참조하면 100% 샌다**(`H-71`, 50/50 +> 실측). `SetWeak`으로 낮추면 0/50으로 안전해진다. +> - **내부 키가 `inst`를 되참조하면 `SetStrong`/`SetWeak` 둘 다 샌다** +> (`H-77`, 50/50 · 50/50 실측) — `WeakMap`도 **키는 강하게** 잡기 때문. +> 이 슬롯은 위 규칙 문단에 **아예 등장하지 않았다.** +> +> **그래서 지금 규칙은 이렇다**: +> +> 1. **값이 바깥 키를 되참조하면 `SetWeak`을 쓴다.** dedup 기록처럼 +> "다른 곳에서 안전하게 유지되는 것"은 애초에 항상 `SetWeak`이라는 +> 아래 규칙과 같은 결이다. 실제로 물리던 자리는 +> `RefLeafHandler.process`와 `ObserverEffectLeafHandler.process`의 +> dedup 기록이고, 둘 다 `SetWeak`으로 정정됐다. +> 2. **내부 키로 쓰는 객체는 바깥 키를 되참조하면 안 된다** — `SetWeak`으로 +> 도망갈 수 없는 슬롯이라 규칙으로 막는 수밖에 없다. 지금 내부 키가 +> 객체인 자리는 둘이다: +> - **`runInitRelate`**(내부 키 = `initFn`) — **안전하다.** `RunInit`은 +> `initFn(self)`로 module을 **인자로** 받고, 멱등 가드가 성립하려면 +> 애초에 **하나의 고정 함수**여야 하므로(인라인 클로저면 매번 새 +> identity라 dedup 자체가 무의미) 바깥 변수를 캡처할 이유가 없다. +> - **⚠️ `groupClaimKeys`**(내부 키 = 그룹 `Attribute`의 값 객체 `v`, +> `base/attribute-plan.md`) — **자동으로 안전하지 않다.** 그 값은 +> 사용자가 만든 Store/Source이고, 거기 담긴 값이 `inst`로 되돌아갈 수 +> 있다(예: 같은 트리의 `Ref`가 그 `inst`로 채워진 뒤 Store에 담기는 +> 경우). **[2026-08-25 `/code-review high` 지적]** 여기 한때 *"둘 다 +> 안전하다"*고 단정했는데 `runInitRelate` 쪽 논거만 있었다. +> **그래서 이건 증명이 아니라 계약이다** — 그룹 `Attribute`의 값 +> 객체는 `inst`를 되참조하면 안 되고, 그 제약을 `attribute-plan.md`가 +> 같이 적는다. 되참조가 필요해지면 내부 키를 값 객체가 아니라 +> **이름/토큰**으로 바꿔야 한다(값 객체는 그때 값 슬롯으로 내려가고, +> 그 슬롯은 `SetWeak`으로 도망갈 수 있다). +> 3. **`luau-test/done/07`에 되참조 음성 대조군을 추가할 것** — 그 파일은 +> "GC-native 아키텍처의 핵심 전제를 검증했다"고 여러 문서에 인용되는데 +> 실제로는 **안전한 모양만** 봤다. + +하지만 **서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 제공하는 상호 순환**(예: `RelateA[inst]=value`(강)와 `RelateB[value]=inst`(강)가 동시에 존재)은 완전히 다른, 더 위험한 모양 — `inst`의 reachability diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 9cb6e19..d60d390 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -991,7 +991,22 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it 위 "용어 주의" 참고. - **`updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T | nil, UD?)` — 매 reconcile 사이클마다 - 모든 key에 대해 호출됨.** `:List`는 더 이상 item을 위해 `Source`를 + 모든 key에 대해 호출됨.** + - **⭐⭐ [2026-08-25 정정, 7라운드 `H-95`] 이 반환 타입 그대로면 strict에서 + 정상 용례가 전부 막힌다.** Luau는 콜백이 **선언된 것보다 적게 + 반환**하면 에러다(`Expected 'El?, number?', but got 'El?'`) — 이 문서가 + 드는 예시들이 `return frame` 한 값만 돌려주는데 그게 통과하지 않는다. + **확정 형태는 함수 타입의 유니온**이다: + ```lua + type UpdateFn = Fn2 | Fn1 | Fn0 + -- Fn2: (...) -> (T?, UD?) Fn1: (...) -> T? Fn0: (...) -> () + ``` + `luau-analyze` 실측에서 네 모양(2개/1개/`nil`/없음)이 전부 통과하고 + **엉뚱한 타입을 돌려주면 여전히 잡힌다**(음성 대조군 확인). + *"항상 명시적으로 반환하라"*를 계약으로 두는 안은 기각 — 인체공학이 + 나쁘고 `--!nocheck` 코드에선 조용히 지나간다. 단일 옵셔널 반환인 + `Effect`의 `fn`은 가변 반환 팩(`-> ...(() -> ())`)이 더 맞는다 + (`base/effect-plan.md`). `:List`는 더 이상 item을 위해 `Source`를 대신 만들어주지 않음(아래 "왜 `Source`를 `:List`가 안 만드는가" 참고, 2026-08-11 세션에 `index`도 같은 원칙으로 편입) — `item`/`index`는 매번 그 사이클의 raw 현재값 그대로 넘어감, 반응형으로 쓸지는 @@ -2577,7 +2592,7 @@ function rawUnmount(self, index) nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end - spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.N — 아래 참고 + spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고 recompute(self, bk) -- 자리가 없어지는 경로엔 setLength가 없으므로 여기서 명시 호출 end @@ -2597,7 +2612,8 @@ function rawDetach(self, index) nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end - spliceArraysDown(self, index) -- `_elemIndex`에서도 이 요소가 빠진다(트리 밖이 됨) + spliceArraysDown(self, index) -- `_elemIndex`/`bk.tokens`/`bk.indexOfToken`에서도 + -- 이 요소·자리가 빠진다(트리 밖이 됨) — 아래 splice 요구 목록 recompute(self, bk) end @@ -2810,7 +2826,7 @@ function rawRemove(self, index) nativeDispose(element) -- 트리 밖이라 offset이 필요 없다 end - spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.N + spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고 recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만 end ``` @@ -2836,9 +2852,34 @@ end 그쪽 error 가드엔 안 걸린다). **동기 재진입이라 "체인 도중 yield 금지" 불변식으로는 안 덮인다.** `sourceList`가 `None`으로 채워지는 것과 대칭을 맞춰 창 자체를 없앤다. -- **캐시를 앞으로 당긴다**(`H-3`) — `bk.invalidAfter = math.min(bk.invalidAfter, index)`. +- **캐시를 앞으로 당긴다**(`H-3`) — + `bk.invalidAfter = math.min(bk.invalidAfter, index)`. `base/dispatch-core-plan.md`의 무효화 표가 규정한 세 규칙 중 하나이고, 지금까지 산문으로만 있고 코드 경로가 없었다. + **[2026-08-25 정정]** 한때 여기 `index - 1`로 적었는데, `recompute`의 + 되감기 재개 지점이 `invalidAfter + 1`에서 **`invalidAfter` 자신**으로 + 고쳐지며 그 `-1`이 불필요해졌다(그 문서의 `recompute` 절). + - **⭐ 이게 `recompute` 되감기의 신호이기도 하다** — recompute 도중 + splice가 나면 그 값이 낮아지고, 진행 중인 루프가 그 지점 다음부터 + 되감는다(`base/dispatch-core-plan.md`의 `recompute` 절). 그래서 + `{a,a,a, b,b, c,c}`에서 앞의 `a,a`가 사라졌는데 커서가 이미 `c`에 + 있어도 복구된다. +- **⭐⭐ [2026-08-25 신설, 7라운드 `H-102`] `bk.tokens`와 `bk.indexOfToken`도 + 같이 밀어야 한다.** `setLength`가 만드는 `gatedRecompute` 클로저는 인덱스를 + 캡처하지 않고 **자리별 토큰**으로 `bk.indexOfToken[token]`을 조회한다 + (`slot._elemIndex`와 같은 개념을 Dispatch 층위로 격상 — + `base/dispatch-core-plan.md`). 그래서 splice가 다음 둘을 반드시 해야 한다: + 1. **`bk.tokens`를 다른 배열과 같이 당긴다**(`lengthList`/`sourceList`/ + `observers`와 완전히 같은 처리). + 2. **밀린 자리 전부에 대해 `bk.indexOfToken[token]`을 새 인덱스로 + 갱신하고, 사라진 자리의 토큰 항목은 지운다.** 강한 키 테이블이라 + 안 지우면 **누수**다. + + **안 하면 `H-102`가 그대로 남는다** — 토큰은 안 낡지만 그 토큰이 가리키는 + 인덱스가 낡아, 제거 뒤 `gatedRecompute`가 **엉뚱한 위치로** + `bk.invalidAfter`를 당긴다(앞선 자리가 영영 다시 offset을 못 받는다). + 이 목록에 *"옮겨진 클로저의 위치 추적"*이 셋 어디에도 없던 것이 원래 + 결함이었다. **[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는 배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가 @@ -3298,7 +3339,12 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 -- `dispose`가 막으려던 바로 그 UB가 일어난다. **가드를 분기 밖으로 올린다.** function dispose(value) -- (1) 소유권 가드 — 값 종류와 무관하다(`elementOwner` 조회는 타입을 안 봄) - if elementOwner:GetStrong(value) ~= nil then + -- ⚠️ [2026-08-25 정정, `/code-review high`] `GetStrong(value)`(1-인자)는 + -- **항상 `nil`이라 가드가 죽은 코드였다** — `elementOwner`는 위에서 + -- 3-인자 `SetWeak(element, OWNER, ...)`로만 쓰이고 강한 맵엔 아무것도 + -- 안 들어간다(`Relate`는 항상 3-인자 `SetWeak`/2-인자 `GetWeak`, 409행). + -- `H-71`로 dedup 기록까지 `SetWeak`이 되며 강/약 짝맞춤이 더 중요해졌다. + if elementOwner:GetWeak(value, OWNER) ~= nil then error("dispose: 이 값은 아직 트리가 살아있길 요구 중임 — 먼저 Remove/Extract 할 것") end -- (2) [`H-43`] Slot도 Instance도 아닌 값이 백엔드로 그냥 흘러가지 않게 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index cb9b74b..b388668 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -10,7 +10,7 @@ > | 갈라진 곳 | 담는 것 | > |---|---| > | **이 문서** | `Source`/`State` 온톨로지·서브타입, 전파 모델, `:With`/`:Compute`/`:Apply`, `previous`, `:Emit`, `Observer`, 구독/생명주기 게이트 | -> | `base/store-plan.md` | Store = 이름 붙은 Source 모음 — `defaults`, eager/lazy 생성, `store.key` 타이핑, `:Set()` 문법 | +> | `base/store-plan.md` | Store = 이름 붙은 Source 모음 — `defaults`, **명시적 초기화**(2026-08-25, 옛 eager/lazy 이중 모델 폐기), `store.key` 타이핑, `:Set()` 문법 | > | `base/dispatch-core-plan.md` | 디스패치 코어(핸들러 계약, `chains`, 하강 diff) | > | `base/bind-system-plan.md` | 인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인 | @@ -89,7 +89,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴. 생기지 않음. - **`RefSource` 같은 별도 타입은 불필요, `Store({defaults})`가 내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨** — Store - 쪽 상세(eager/lazy 생성, `defaults` 템플릿의 성격, 구현 스케치)는 + 쪽 상세(**[2026-08-25]** 명시적 초기화 — 옛 eager/lazy 이중 모델 폐기, + `defaults`의 성격, 구현 스케치)는 `base/store-plan.md`가 소스. 별도 `__values`류 그림자 실값 저장소도 불필요 — Source 객체 자체가 저장소 역할을 함. 이 모델은 이전에 검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/ @@ -112,7 +113,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴. 구현 델리게이션 포함)이라 그 금지와 충돌하지 않음. - **동적 키 경로도 `State`가 아니라 `Source`를 반환**하는 것으로 자연히 갱신됨 — **[정정, 2026-08-18]** 그 경로는 `store "key"` 문자열 커링이 - 아니라 `store:GetDynamic<>(name): Source`다(문자열 커링은 기각, + 아니라 `store:Of<>(name): Source`다(**[2026-08-25]** 옛 이름 + `GetDynamic` — 문자열 커링은 기각, `base/store-plan.md`의 "타입 추론 문제" 절). **[해소됨, 2026-08-13 첫 실측 라운드]** 핵심 질문(Source가 State를 구조적으로 @@ -205,8 +207,10 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa 전량은 **`base/state-epoch-plan.md`가 소스** — 여기선 이 절의 다른 서술과 어긋나지 않게 요지만 적는다. 여기 있던 "emit은 *항상* 전파된다"는 무조건 서술의 역전 원문은 `archive/always-propagate-no-dedup-superseded.md`. - - **`invalid`(구현 이름 `rawInvalid`) 플래그의 역할은 "내 캐시가 낡았다"는 - 표시 하나뿐** — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 + - **"내 캐시가 낡았다"는 표시의 역할은 그것 하나뿐**(**[2026-08-25 정정]** + 한때 *"구현 이름 `rawInvalid`"*라 적었는데 그 필드는 **폐기**됐다 — + 지금은 `cacheTargetCount`/`cacheCurrCount` 카운터 쌍이고, 재계산 도중 + 도착한 무효화와 `fn` 예외를 같이 덮는다. `base/state-epoch-plan.md`) — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 상류로 올라가 재계산하고, 그 결과를 캐시에 넣고, 플래그를 끈다. - **⚠️ `invalid`로 전파를 접는 것은 지금도 금지다.** 2026-08-14에 그 방식이 폐기된 이유는 `:Get()`을 호출하지 않는 `Observer`(아래 @@ -231,6 +235,52 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa ("emit 필요 여부" 열린 질문은 이걸로 해소). 전파가 잦아도 부담이 작은 이유이기도 함 — 신호 하나가 트리를 훑는 비용이지 재계산 비용이 아님. +### ⭐⭐ 전파 루프 — 확정 의사코드 (2026-08-25 신설, 7라운드 `H-56`) + +지금까지 이 루프는 **산문으로만** 있었고, 그 공백에서 실제 결함이 나왔다 — +`base/lifecycle-pattern.md`의 *"발화 시 각 구독자에 대해 `canExecute(observer)`를 +확인하고, 거짓이면 그 구독자만 조용히 건너뜀"*을 글자 그대로 짜면 +**자식 State 노드가 전부 걸러진다**(자식은 `bindLifetime`된 적도 +`:Subscribe()`된 적도 없어 `canExecute`가 항상 거짓이다). 그러면 +`A:Set()`이 `A:With(...)`/`A:Compute(...)` 노드에 **한 번도 안 닿고** +파생 State 아래의 모든 Observer가 침묵한다. + +```lua +function State:_emitDown(from) + -- H-23 확정: pairs 순회 중 새 키 추가는 미정의라 먼저 배열로 스냅샷 + local snap = {} + for sub in self._subs do snap[#snap + 1] = sub end + for _, sub in ipairs(snap) do + if isState(sub) then -- 자식 노드 + sub:_receive(from) -- state-epoch-plan.md §4 규칙 1~3 + elseif canExecute(sub) then -- Observer / Effect + sub.fn(sub, from) + end -- 거짓이면 조용히 건너뜀 + end +end +``` + +- **구독자 집합은 하나**(`self._subs`, weak-키)이고 **원소는 Observer + 값**이다 — emit 클로저가 아니다. `bindLifetime(inst, observer)`가 Observer + **값**을 키로 `BindData`에 gcconn을 복사하므로, 집합에 클로저를 담으면 + `canExecute(클로저)`가 identity가 달라 **항상 거짓**이 된다. + `base/lifecycle-pattern.md` (4)의 *"Observer의 emit 클로저"* 표현은 이 + 결정에 맞춰 고쳤다. +- **자식 State는 `canExecute`를 안 본다** — 그건 "이 값이 어떤 Instance에 + 묶여 살아 있는가"를 묻는 판정이고, 자식 노드의 생존은 위 + "해소됨 — 중간 State는 `_hold`로 살아남는다" 절의 `_hold` 불변식이 + 책임진다. +- **두 집합으로 나누는 안은 기각** — emit마다 스냅샷이 두 번이 되고 + (아래 비용 참고) 등록/해제 경로도 둘로 갈린다. +- **비용**(7라운드 `H-92`): 이 스냅샷은 **파동이 지나는 노드 수만큼** 배열을 + 할당한다. `base/state-epoch-plan.md` §2가 테이블 리비전을 기각한 근거가 + *"`Set` 한 번마다 테이블 하나를 할당"*이었으므로, 그 문서 §7의 비용 + 서술은 이 할당을 셈에 넣어 실제와 맞춰야 한다. **정확성 문제는 + 아니다** — 순회 중 등록이 미정의인 이상 스냅샷은 필요하다. +- **예외 안전성**: 구독자 콜백이 던지면 **그 파동의 나머지 구독자는 그 + 변경에 대해 영구 침묵**한다(값만 자가치유). `pcall`로 감싸지 않는다 — + `base/architecture.md`의 예외 계약 절이 소스. + ### 다이아몬드 의존성은 무엇이 푸는가 (2026-08-14 명확화) `a → b`, `a → c`, `(b, c) → d` 형태에서 `a`가 한 번 바뀌면 `d`는 두 경로로 @@ -300,10 +350,20 @@ State를 여러 갈래가 공유하는 다이아몬드 형태(`b`에서 `c1 = b: 수만큼 중복 실행됨 — `previous` 메커니즘이 막으려던 문제를 반대로 다시 만들어내는 셈이라 방향이 안 맞음. -**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** "관측해야 -실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건 -실제로 관측되는(`Get()`되는) State뿐 — 중간에 만들어놓고 아무도 안 보는 -State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 재계산을 막는 +**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** +**⭐⭐ [2026-08-25 정정, 7라운드 `H-62`] 여기 한때 *"실제로 관측되는(`Get()`되는) +State뿐 — 중간에 만들어놓고 아무도 안 보는 State는 구독 등록 자체가 안 +일어남"*이라 적혀 있었는데 **틀렸다. 구독 엣지는 생성 즉시(eager) 등록된다.** +**사용자 확정**: *"생성 즉시 밖에 없다. 옵져버가 실행 안 된다면 get 자체가 +안 되므로, lazy 하면 아에 등록 될 기회가 없다."* — lazy가 성립하려면 "먼저 +`Get()`이 일어난다"가 전제인데 `Get()`을 부르는 주체가 바로 그 등록되지 +못한 Observer라 순환이다. `Get()`을 안 하는 Observer가 *"매 변경마다 정확히 +한 번 운다"*는 확정과도 양립하지 않았다. 나머지 코퍼스는 이미 eager였다 — +`base/state-epoch-plan.md` §4의 생성 시점 시딩, `base/blocker-plan.md`의 +*"호출되는 즉시"* 등록, 아래 ":With도 새 State 노드로 확정" 절. +관리 부담이 작다는 **결론 자체는 유지된다** — 근거가 "엣지가 적다"에서 +**"엣지가 노드당 자기 상류 수만큼으로 유계이고 전부 weak-키"**로 바뀔 뿐이다. +다이아몬드에서 중복 재계산을 막는 것도 **노드별 캐시**(위 "다이아몬드 의존성은 무엇이 푸는가" 절)라 체인 전체가 링크드일 것을 요구하지 않고 각 노드가 자기 구독자 목록 + 자기 캐시만 가지면 되는 것이라, 이 결정과 무관하게 그대로 유지됨. 구현은 @@ -314,7 +374,7 @@ Observer와 동일한 패턴(외부 weak table, `{[child] = true}` 류)으로 유일한 중복 방지 수단이 되면서 "State는 캐싱하는 존재"라는 근거가 더 강해짐. -### ⚠️ 미해결 — 중간 State가 살아남는가(구독 엣지의 방향성) (2026-08-18 구현 전 QA에서 제기, **M2 착수 전 결론 필요**) +### ⭐ 해소됨 — 중간 State는 `_hold`로 살아남는다(구독 엣지의 방향성) (2026-08-18 제기, **2026-08-25 확정**) **사용자가 지목한 미검증 항목**: *"확인해봐야 하는게 State -> State -> State -> Observer Leaf Bind 에서 중간 State 는 참조되지 않아도 사라지지 @@ -340,11 +400,32 @@ With 등이 있는 경우 parent 와 연결된 상대를 자기 자신에 가지 없어서** 그런 우연한 캡처가 없다 — 그래서 "우연"에 기대면 안 되고 방향성을 불변식으로 못박아야 한다. -**해야 할 일**: (a) 이 방향성(상류 strong / 하류 weak)을 이 문서의 -불변식으로 명문화할지 결정, (b) `luau-test`에 실측 스파이크 추가 -(`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에). -**미검증 상태로 M2에 착수하면 안 되는 항목** — 아래 "결론"의 "관리 부담은 -작음"은 이 항목이 닫히기 전까지는 잠정이다. +**⭐⭐ [2026-08-25 확정] `_hold` 불변식으로 닫혔다.** **사용자 확정**: +*"단순히, 각 state 들이 상위 State|Source 를 홀드하는 `_hold` 를 놓는것으로 +바로 해결된다. 당연히 후행은 선행 요소들이 있어야하기 때문. 선행 state 가 +후행 state 를 얻으려 하는건 UB이므로 가능한 일이다.(이건 릴레이션도 아니라 +gc되긴 하지만.)"* + +| 방향 | 강도 | +|---|---| +| 하류 State → 상류 State/Source (`_hold`) | **강함** | +| 상류 → 하류 (구독자 집합) | weak-키 | + +- **모든 파생 노드**(`:With`/`:Compute`/`:Gate`/`:Block`)가 자기 상류를 + `_hold`에 강하게 담는다 — `:Compute`처럼 클로저가 **우연히** 캡처하는 + 것에 기대지 않는다(`:With`의 pass-through 노드엔 그 우연이 없다). +- 체인은 **말단(Observer/Effect/leaf)이 살아 있는 동안** 통째로 살아 있고, + 말단이 죽으면 통째로 수거된다. `Relate`가 아니므로 순환도 안 만든다. +- **상류가 하류를 얻으려는 것은 UB**라 반대 방향 강참조가 필요 없다. +- **따름정리 둘**(7라운드 `H-93`/`H-98`): 루트 `Epoch`(Source)가 하류보다 + 먼저 수거될 수 없으므로 *"`:Refresh()`가 `false`를 줘 낡은 값을 최신이라고 + 확신한다"*는 경로가 생기지 않고, `:Subscribe()`의 공개 계약 두 문장 + (*"참조를 아무 데도 안 담아도 정상"* / *"GC되지 않고 영원히 계속 실행됨"*)이 + 서로 모순 없이 성립한다. +- **남은 것은 실측 스파이크 하나** — `luau-test`에 "상류 strong / 하류 weak" + 불변식을 음성 대조군까지 확인하는 파일을 추가한다 + (`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에). + **M2 착수 게이트는 아니다**(`question.md` 최우선 절에서 내려갔다). **결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는 @@ -431,7 +512,18 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"( 전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로 대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는 지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음. -2. **공유 캐시를 못 타고 중복 계산이 생김. [2026-08-14 근거 재작성]** +2. **엣지 수와 에포크 부기가 늘어남. [2026-08-25 근거 재작성, 7라운드 `H-82`]** + **여기 한때 "공유 캐시를 못 타고 중복 계산이 생김"이라 적혀 있었는데 + `:With`엔 성립하지 않는다** — 같은 절이 `:With`를 *"계산 함수는 없고 값은 + `self`를 그대로 통과(pass-through)"*로 확정하므로 **공유될 계산 자체가 + 없다**. 2026-08-14 재작성이 "근거가 더 강해졌다"면서 실제로는 정확도를 + 낮춘 자리다. 유효한 근거는 이것이다 — 빌더로 누적하면 최종 소비자마다 + **자기 몫의 엣지 집합과 `EpochMap` 부기를 따로** 들게 되어, 실노드 + 하나가 그걸 공유하는 것보다 등록/판정 비용이 소비자 수만큼 늘어난다. + **결론(빌더 기각)은 안 바뀐다** — 근거 1·3이 그대로 유효하다. + 아래 옛 서술은 `:Compute`에 대해서는 여전히 맞는 이야기다: + + [옛 근거 — `:Compute`엔 유효, `:With`엔 무효] 원래 이 항목은 "invalid 플래그로 다이아몬드 중복 워크 방지" 장치를 근거로 들었으나 **그 장치는 폐기됨**(위 "전파 모델 확정" 절 정정) — 근거를 실제로 유효한 것으로 바꿔 적음. With가 진짜 노드면 @@ -766,7 +858,9 @@ someSource:Compute(computeFn) 가능해져 버린다"는 이전 우려는 이걸로 근본적으로 해소(그런 API 자체가 없음). - **[정정, 2026-08-06 후속 세션] 값을 쓰는 경로는 `store.key = value` - (`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — 이유와 + (`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — **[2026-08-25]** + 같은 날 오전에 대입 문법을 되살렸다가 **철회**했다 + (`archive/store-value-field-redesign-withdrawn.md`). 이유와 상세는 `base/store-plan.md`의 "Store 값 설정 문법" 절 참고(요지: Source가 State를 만족하는 구조로 바뀌며 레코드 타입 읽기/쓰기 대칭을 맞추려면 대입 문법을 포기해야 함 + `=`가 암시하는 "즉시 커밋"이 실제 @@ -783,8 +877,10 @@ someSource:Compute(computeFn) `Ref()`로 안 만들어질 특별한 이유는 없었고(이전 절에서 API 모양만 다루고 생성자를 명시 안 해서 생긴 공백), `architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`도 - 같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도 - 됨, 순수 편의용 초기값 템플릿). + 같은 스타일로 지원. **[2026-08-25 정정]** `defaults`는 더 이상 "선택 — + 순수 편의용 초기값 템플릿"이 아니다 — **명시적 초기화**가 확정되며 + `defaults`가 곧 **선언 키 집합**이 됐다(옛 lazy `__index` 폐기). 무인자 + `Store<<{}>>()`는 빈 타입일 때만 유효하다(`base/store-plan.md`). - **[보강, 2026-08-09 열한 번째 세션] `Source(default)`/`Ref(default)`의 `default` 인자가 "선택"이라는 서술은 정확히는 `T`가 `nil`을 포함할 때만 성립함 — 생략하면 실제로 `nil`이 그 자리를 채우기 때문.** `Source()` @@ -801,6 +897,20 @@ someSource:Compute(computeFn) 없이 생성하는 건 사용자 실수, 타입으로 막을 수 있으면 막고 안 되면 UB로 문서 경고). +### ⭐ `Source:Set(v)`는 동일값이어도 항상 갱신하고 emit한다 (2026-08-25 확정, 7라운드 `H-68`) + +지금까지 코퍼스 어디에도 이 경우가 안 적혀 있었다. **확정**: `v`가 현재 +값과 `==`여도 `Revision`을 갱신하고 정상적으로 emit한다. + +- 판정이 **값 동등성이 아니라 리비전**이라는 `Epoch` 모델 + (`base/state-epoch-plan.md` §2)과 일관된다. +- 더 중요한 건 **테이블 값**이다 — mutate한 뒤 같은 테이블을 다시 + `Set`하는 것이 `==`로 dedup되면 **변경이 조용히 증발**한다. 아래 + `:Emit()` 절이 다루는 것과 정확히 같은 상황인데, `Set`이 dedup하면 + 사용자가 그 구분을 항상 의식해야 한다. +- 불필요한 파동을 접는 일은 이미 **하류**가 한다 — `EpochMap` 판정과 + 게이트(`base/gate-plan.md`). + ## Source 값을 직접 mutate한 뒤 전파 — `:Emit()` (2026-08-06 후속 세션, 호출부 정정) **결정**: Source가 들고 있는 값을 새 값으로 교체하지 않고 제자리에서 @@ -892,11 +1002,34 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 방식이 완전히 통일됨. `:With`/`:Compute` 자체를 대신 호출해주는 자동화가 아니므로, 여전히 팩토리 본문 안에서 `:With`/`:Compute`를 직접 호출하는 건 팩토리 작성자 몫. +- **⭐⭐ [2026-08-25 확정, 7라운드 `H-94`] 팩토리는 함수이거나 "지정된 + 필드를 가진 객체"다 — `__call` 테이블은 안 받는다.** `Debounce{...}` / + `Throttle{...}`가 `__call` 테이블을 돌려주면 + `state:Apply(Debounce{...})`가 **타입에러**다(`luau-analyze` 실측 — + `__call` 테이블은 함수 타입 자리에 안 들어간다. 런타임은 멀쩡하고 + 타입만 막히므로 `--!nocheck` 스파이크에선 안 드러난다). 같은 실측이 + 타입 레벨 `__call`의 다른 한계도 보였다 — `self`를 못 받고, + `typeof(f<>)`로 타입 인자를 넘기는 것도 실패한다. + - **확정**: 애플리커티브 팩토리는 **`__call`이 아니라 지정된 필드**로 + 자기를 노출한다. **사용자 판단**: *"아에 어플리커티브 펑터로써, + `__call` 이 아닌 다른 필드로 들어가는게 맞아보여요. 외부에서 직접 + `()` 호출하는건 의미 없게 둬야해요."* + - **부수 효과**: `Blocker`를 슈가로 못 두던 이유도 같이 풀린다 — + `Debounce`/`Throttle`/`Blocker`가 전부 같은 계약을 만족하게 된다. + - **함수와 콜러블의 유니온으로 여는 안은 기각** — 필드로 받으면 + 유니온도 캐스트도 필요 없다. 필드 이름과 정확한 시그니처는 구현 시 + 정한다. - **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`로 필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만 존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌 자체가 없음 — 그냥 고정 메소드 하나 추가하는 것. -- **타입은 `factory: (State) -> U): U`로 완전히 열어둠** — Modifier의 +- **타입은 반환 쪽을 완전히 열어둠** — **⚠️ [2026-08-25 정정, `H-94`]** + 여기 한때 `factory: (State) -> U): U`라 적혀 있었는데, 그 시그니처는 + **함수만** 받으므로 위 `H-94` 항목이 확정한 "지정된 필드를 가진 객체" + 형태를 **거부한다** — `state:Apply(Debounce{...})`가 그대로 타입에러다. + 파라미터는 **함수 또는 그 필드를 가진 객체**를 받고 반환 `U`만 열어둔다 + (정확한 필드 이름과 시그니처는 구현 시 정한다). 아래 논거는 **반환 쪽**에 + 대한 것이라 그대로 유효하다 — Modifier의 `Apply`는 `factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이 이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종 요약된 plain 값)을 반환해 반응형 그래프를 벗어나는 탈출구로 쓰는 것도 @@ -1099,6 +1232,13 @@ override할 자리를 구조적으로 열어두는 것.) 멈춘다")을 만족시키는 가장 단순한 도구 — 별도 콜백 로직 없이 그냥 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 하면 별문제 없음(사용자 판단). + - **⭐ [2026-08-25 정정, 7라운드 `H-61`] 내부 콜백은 no-op가 아니라 + `function(self) self:Get() end`이다.** 전파가 push-invalidate / + pull-recompute라 `Get()`을 안 부르면 재계산이 아예 안 일어난다 — + 같은 절이 바로 위에서 *"값을 안 실어줌 — 반드시 `Get()`을 다시 해야 + 함"*이라 못박고 있으므로, no-op 콜백이었다면 이 유틸은 자기 용도 + (`previous` 패턴의 mutate 로직을 계속 돌게 하기)를 **하나도 못 + 한다**. 이름("항상 관측")과도 이쪽이 맞는다. ### Slot 생존 확인 — 별도 메커니즘 아님, `canExecute` 재사용으로 확정 @@ -1125,6 +1265,24 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인 `bindLifetime`(`base/lifecycle-pattern.md`)으로 명명됨 — 별도 메커니즘 두 개가 아니라 같은 것의 명명 전/후 표현. +**⭐⭐ [2026-08-25 신설, 7라운드 `H-58`/`H-59`] `:WeakSubscribe()` / +`:WeakUnsubscribe()` — 약하게 등록하는 짝.** `Weak` 쪽이 **프리미티브**이고 +평범한 `:Subscribe()`는 그 위에 "GC 안 되도록 킵" 하나를 더 얹은 것이다 +(**사용자 확정**: *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 동일하나 +단순히 gc 안 되도록 킵 해주는 부분만 제거된 함수가 됩니다"*). 즉 +`Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`이고 구현이 한 벌이다. + +- **자료구조**: 전역 레지스트리에 **약하게** 들어간다. 살려두는 책임은 + **잡고 있는 쪽**에 있다 — `Effect`가 자기 내부 Observer를 `_deps`에 + 강하게 들고 있는 게 그 예다(`base/effect-plan.md`의 + "확정 구조 — 강한 주인은 항상 `Effect`" 절). +- **왜 필요한가**: `Effect`가 dep마다 Observer를 만들 때 평범한 + `:Subscribe()`를 쓰면 전역 레지스트리가 그 Observer를(따라서 `Effect`를) + 영원히 붙든다. 그렇다고 바인드/언바인드마다 등록·해제하면 + *"등록 시점에 즉시 1회 실행"*에 걸려 **바인드마다 `Rerun`이 dep 수만큼 + 돈다**. `WeakSubscribe`면 둘 다 사라진다. +- `Ref` 쪽의 짝은 `ref:WeakCallback(fn)`이다(`base/ref-plan.md`). + **해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새 설계가 아니라 PA님 코드 교차검증(아래 라이프사이클 절)에서 이미 예고해둔 확장 지점을 실제로 채우는 것 — "나중에 GC만으로 정말 부족한 케이스가 @@ -1383,23 +1541,49 @@ function ObserverEffectLeafHandler.process(inst, k, v, index) Dispatch.setOffsetSource(inst, k, None) Dispatch.setLength(inst, k, 0, inst) - local old = relate:GetStrong(inst, k) + local old = relate:GetWeak(inst, k) -- ⭐ [2026-08-25 `H-71`] 쓰기가 SetWeak이므로 읽기도 Weak + -- (안 맞추면 old가 항상 nil이라 dedup이 통째로 죽는다) if old ~= v then -- 이미 같은 값이 이 자리를 차지 중이면 재바인딩 skip - bindLifetime(inst, v) -- Effect는 내부적으로 자기 Observer까지 cascade(`base/effect-plan.md`) + bindLifetime(inst, v) -- [2026-08-25 정정] Effect도 **핸들 하나만** 바인드된다 — + -- dep은 생성자에서 `Weak*`로 걸려 있고 발화는 + -- `canExecute(handle)`이 게이팅(`base/effect-plan.md`) end - relate:SetStrong(inst, k, v) + relate:SetWeak(inst, k, v) -- ⭐ [2026-08-25, 7라운드 `H-71`] Strong 아님 — 아래 참고 return function(nextValue) if nextValue ~= v then unbindLifetime(v) + -- ⭐ [2026-08-25, 7라운드 `H-57`] 값 교체는 파괴에 준한다 — 그 Effect는 + -- 다시 오지 않으므로 cleanup을 여기서 소진 호출한다. + if isEffect(v) then v:_consumeCleanup() end -- [`RefLeafHandler`와 같은 주의] relate 정리는 반드시 이 분기 *안*에서만 — -- 밖에 두면 spurious 재발행(nextValue == v)에서도 기록이 지워져 곧바로 -- 이어지는 process가 `old ~= v`를 항상 참으로 보고 dedup이 무력화됨. - if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end + if relate:GetWeak(inst, k) == v then relate:SetWeak(inst, k, nil) end end end end ``` +**⭐ [2026-08-25 신설, 7라운드 `H-71`] dedup 기록은 `SetWeak`이다.** +`SetStrong`으로 두면 **값이 `inst`를 되참조할 때 100% 샌다** — 커밋된 +`Relate.luau`로 50/50 누수가 실측됐다(`base/relate-plan.md`의 슬롯별 강약 +표). dedup은 이 절이 스스로 밝히듯 **순수 성능 최적화**라, weak로 낮춰 +엔트리가 조기 소실돼도 "dedup을 한 번 놓친다"까지가 최대 손해다. `v`는 +`gchold`가 이미 강하게 잡고 있고, `relate-plan.md`의 **"다른 곳에서 +안전하게 유지되는 것은 항상 `SetWeak`"** 규칙에도 그대로 맞는다. +`RefLeafHandler`도 같은 정정을 받는다(`base/ref-plan.md`). + +**⭐ [2026-08-25 신설, 7라운드 `H-57`] 값 교체 retract는 cleanup을 부른다.** +`base/effect-plan.md`가 확정한 *"`unbindLifetime`은 cleanup을 부르지 +않는다"*는 **포탈 언마운트**를 보고 정한 것인데, `unbindLifetime`의 호출부는 +셋이다 — 포탈 언마운트 / 파괴 직전 / **값 교체 retract**. 앞의 둘은 +cleanup을 안 불러도 되지만 셋째는 **파괴에 준한다**(그 `Effect`는 다시 안 +온다). 안 부르면 `Frame { effectState }`에서 `effectState:Set(E2)` 뒤에도 +`E1`의 타이머가 영원히 돈다 — React로 치면 `useEffect` 클로저가 바뀌었는데 +이전 cleanup을 안 부르는 것. `_consumeCleanup()`이 **읽고 → 지우고 → +실행**이라 파괴 경로와 이중 호출이 없고, `unbindLifetime`의 계약 자체는 +안 건드린다. + ## PA님 코드와의 교차검증(2026-08-04 4차 라운드) — 둘 다 기존 확정 유지 `.claude/initreq/artworks/EventDrivenProgramming/`(Connection/Event/ diff --git a/.claude/base/state-epoch-plan.md b/.claude/base/state-epoch-plan.md index 49bb2cc..65733a4 100644 --- a/.claude/base/state-epoch-plan.md +++ b/.claude/base/state-epoch-plan.md @@ -164,12 +164,33 @@ type EpochSet = { [Epoch]: true } -- 배열이 아니라 집합이다 (아래 EpochMap() -> EpochMap -EpochMap:Update(Epoch | EpochSet) -> boolean -- "뒤로 전파가 필요한가" +EpochMap:Update(Epoch | EpochSet) -> boolean -- "뒤로 전파가 필요한가" (읽고, 비교하고, 덮음) +EpochMap:Peek(Epoch | EpochSet) -> boolean -- 같은 비교를 하되 **덮지 않음** (2026-08-25 신설) EpochMap:Refresh() -> boolean -- 자기 키 전부를 라이브로 다시 읽음 EpochMap:Sync(Epoch | EpochSet) -- 읽지 않고 쓰기만 (반환값 없음) EpochMap:TrackFrom(other: EpochMap) -- other가 추적 중인 키를 넘겨받아 라이브 리비전으로 채움 ``` +**⭐⭐ [2026-08-25 신설, 7라운드 `H-72`] `:Peek` — 갱신하지 않고 비교만 한다.** +`GateNode`가 §4의 수신 규칙 1~3을 그대로 돌려면 `emitChanged`가 필요한데, +그걸 얻는 유일한 통로였던 `:Update`는 **정의상 읽고 나서 덮는다**. 그런데 +같은 §4의 게이트 예외는 *"`emitEpochMap:Update`를 수신 시점에 부르지 +않는다"*(유보 중엔 아직 안 던졌다는 맵의 뜻을 지키려고)라, **두 요구를 +동시에 만족할 연산이 표면에 없었다.** 나머지 표면도 전부 쓰기를 한다 — +`:Refresh`는 자기 키를 다시 읽어 **갱신**하고, `:Sync`는 **쓰기 전용**, +`:TrackFrom`은 키를 넘겨받아 **채운다**. + +```lua +-- GateNode:_receive +local valueChanged = self.valueEpochMap:Update(from) +local emitChanged = self.emitEpochMap:Peek(from) -- 갱신 안 함 +``` + +`:Update`가 이미 `{읽기, 비교, 쓰기}`라 `:Peek`은 그 앞 두 개만 쓰는 것이고 +내부 코드 공유가 쉽다. 플래그(`Update(from, write)`)로 두는 안은 기각 — +호출부에서 "안 쓴다"가 안 보이고 boolean 파라미터가 늘 그렇듯 읽기가 +나빠진다. + **⚠️ [2026-08-22 정정] 여러 개를 넘길 때는 `{Epoch}`(배열)가 아니라 `{[Epoch]: true}`(집합)다.** 여기 한때 `{Epoch}`로 적혀 있었는데, Luau에서 그건 `{[number]: Epoch}` 배열이라 **실제로 넘어오는 게이트 배치와 타입이 @@ -328,9 +349,68 @@ end 폐기된 옛 dedup의 "영구 침묵"과 같은 계열이다 (`archive/invalidate-dedup-propagation-reversed.md`). +### ⭐⭐ `rawInvalid` 불린은 **캐시 카운터 쌍**으로 교체됐다 (2026-08-25, 7라운드 `H-85`) + +**문제**: 아래 "재계산이 끝나면"이 확정한 `rawInvalid = false`가 **재계산 +*도중* 도착한 무효화를 지운다.** `fn` 실행 중에 상류가 `Set`되면 규칙 1이 +`rawInvalid = true`를 세우는데, `fn`이 반환한 직후 꼬리가 그걸 무조건 +`false`로 덮는다 — 캐시가 **다음 `Set`까지 영구 stale**이고, 한 세대를 +조용히 건너뛰는 형태라 관측이 어렵다. 재계산 중 상류 쓰기는 흔하다(다른 +Store 필드에 결과를 적어두는 관용구, 헬퍼의 lazy 초기화, `fn`이 yield하는 +사이 타이머가 `Set`하는 경우 — yield 금지 불변식은 `Dispatch.process`/ +`attachSlot` 체인 **안**에만 적용된다). + +**확정**: `rawInvalid: boolean`을 **`cacheTargetCount` / `cacheCurrCount`** +두 필드로 바꾼다. **사용자 확정**: *"state 는 epoch 를 구현해선 안 돼. +중간이지, 초기 값 컨테이너 계층은 아니거든. 따라서 cache count 를 넣을것을 +추천해. `cacheTargetCount`, `cacheCurrCount` 를 놓는게 맞지 않을까?"* + +```lua +-- 생성 시 +self.cacheTargetCount = 0 +self.cacheCurrCount = nil -- 아직 계산된 적 없음 → 항상 다름 + +-- 무효화 (아래 규칙 1의 `rawInvalid = true` 자리) +if valueChanged then + self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount) +end + +-- 재계산 +local gen = self.cacheTargetCount -- fn 직전 스냅샷 +self.cache = self.fn(self, self.cache, ...) +for _, d in self.deps do d:_track(self.valueEpochMap) end +self.cacheCurrCount = gen -- ⭐ 성공했을 때만 + +-- 재계산 판정 (아래 `rawInvalid == true` 자리) +if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end +``` + +- **재계산 도중 도착한 무효화**가 `cacheTargetCount`를 앞서게 만들어 + 다음 `Get`이 반드시 재계산한다. +- **`fn`이 던지면** `cacheCurrCount`가 안 갱신되므로 *계산된 적 없는 + 캐시를 유효하다고 확신*하는 일이 없다 — **사용자 지적**: *"특히 에러가 + 난다고 하면, 다시 계산 안하고 이전 결과를 다시 쓰겠네?"* (그래서 + "`rawInvalid = false`를 `fn` 앞으로 옮긴다"는 한 줄짜리 대안은 + **불충분하다**.) +- 증가는 §2가 `Source.Revision`에 확정한 **`bit32.bnot(-n)` 랩** 그대로다 — + 비교가 `~=`뿐이라 랩이 무해한 것도 같고, uint32 안에 머무르므로 평이한 + `+1`이 갖는 2^53 포화 지점 자체가 없다. +- **⚠️ 초기값은 `curr = nil`이어야 한다 — 두 숫자를 나란히 두면 안 된다.** + 이 갱신은 **감소**다(§2의 실측 표: `1 → 0 → 4294967295`). 그래서 한때 + 적어뒀던 `target = 1, curr = 0`은 **첫 무효화에서 `target`이 `0`이 되어 + `curr`와 같아진다** — 계산된 적 없는 캐시를 "유효"로 판정해 다음 `Set`이 + 올 때까지 그 값을 그대로 돌려준다(2026-08-25 `/code-review high` 발견). + `curr = nil`이면 어떤 숫자와도 다르고 **갱신 방향이 바뀌어도 안 깨진다.** + 필드 타입은 `number?`이고, 첫 재계산 이후로는 계속 숫자다. +- **State는 여전히 `Epoch`를 구현하지 않는다** — 이 카운터는 **자기 재계산 + 부기**이지 남이 키로 삼는 리비전이 아니다(§4가 State dep에 대해 + `TrackFrom(dep.valueEpochMap)`을 쓰는 것과 일관). +- 아래 두 절(`재계산 판정` / `재계산이 끝나면`)의 `rawInvalid`는 전부 이 + 카운터 비교로 읽을 것. + ### 재계산이 끝나면 -- **`rawInvalid = false`**, 그리고 **`valueEpochMap`은 자기가 읽은 상류 +- **`rawInvalid = false`**(위 카운터로는 `cacheCurrCount = gen`), 그리고 **`valueEpochMap`은 자기가 읽은 상류 전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이 `Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해 최신이다. 사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 전부 @@ -395,6 +475,20 @@ State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그 재계산한다. - 맵 하나당 객체 하나가 늘지만(State당 둘), 옛 모양도 테이블 둘이었으므로 컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다. +- **⭐ [2026-08-25 추가, 7라운드 `H-92`] 전파 루프의 구독자 스냅샷은 여기 + 셈에 안 들어가 있었다.** `base/source-state-plan.md`의 "전파 루프 — 확정 + 의사코드" 절이 확정한 대로, 순회 중 등록이 미정의라 매 발화마다 구독자 + 집합을 배열로 복사한다 — 즉 **파동이 지나는 노드 수만큼 배열 하나씩**이 + 할당된다. §2가 테이블 리비전 방식을 기각한 근거가 *"`Set` 한 번마다 + 테이블 하나를 할당"*이었으므로 이 비용은 정직하게 적어둔다. **정확성 + 문제는 아니고**(스냅샷 자체는 필수) 이 문서의 결정을 뒤집지도 않는다 — + 테이블 리비전은 **노드마다 상시** 새 테이블을 만드는 것이고 이건 **발화 + 경로에만** 생기는 임시 배열이다. +- **게이트 통과 모드의 할당**(7라운드 `H-69`): `base/gate-plan.md` 4번이 + 확정한 대로 게이트는 통과시킬 때도 `withheld`에 넣었다가 flush에서 + 새 weak 테이블로 스왑하므로 **emit마다 테이블 하나**를 쓴다. 역시 + 정확성 문제가 아니라 구현 시 최적화 여지(통과 모드면 스왑 없이 넘기기)로 + 적어둔다. ## 8. 구현 시 확인할 것 @@ -409,6 +503,13 @@ State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그 던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고 생각한다."* — 선언 안 한 Source를 클로저로 읽는 건 **옛 모델에서도 똑같이 stale**이었고 이 변경이 악화시키는 게 없다. + - **⚠️ [2026-08-25 범위 정정, 7라운드 `H-91`] 위 인용의 *"항상 state 는 + get 이 최신"*은 **선언한 의존성에 대해서**다.** 문장 그대로 읽으면 + 코퍼스에 **의도적 반례**가 있다 — `base/tween-plan.md`의 `Animate`가 + *"`info.Style`이 State여도 이 내부 `:Compute`의 trailing deps로 안 + 넘어가므로 구독 목록에 안 걸림"*이라며 **미선언 읽기를 설계로** 쓴다. + 바로 위 문장이 이미 "선언 안 한 것은 옛 모델에서도 stale"이라 말하고 + 있으므로 결론은 안 바뀌고, 범위만 좁혀 적는다. - **동적 의존성**은 `valueEpochMap`이 보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다. - **`Source:Emit()`**(값을 제자리에서 mutate하고 알리는 경로)은 `Revision`만 diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index 9d3062c..5589e54 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -9,8 +9,13 @@ > **`base/source-state-plan.md`**. **상태**: base — Store가 부작용을 허용한다는 핵심 결정, "이름 붙은 Source -모음"이라는 정의, eager+lazy 생성, `store.key` dot-access 타이핑, `:Set()` -문법 전환까지 전부 확정. 원본: `.claude/initreq/raw-userinput.md` +모음"이라는 정의, `store.key` dot-access 타이핑까지 전부 확정. +**⭐ [2026-08-25] 두 가지가 바뀌었다** — (1) 생성이 **명시적 초기화**로 +확정되고 옛 lazy `__index`가 폐기됐다, (2) 타입은 **타입 함수를 안 쓰고** +평범한 레코드로 짓는다(`WrapStore`/`ProcessStoreType` 폐기). 같은 날 +"`store.key`를 값으로" 재설계를 넣었다가 철회한 경위는 +`archive/store-value-field-redesign-withdrawn.md`. 원본: +`.claude/initreq/raw-userinput.md` "store는 부작용을 허용함" / "스토어는 스토어를 저장 가능한가" 절. ## Store는 부작용을 허용하는 게 기본 디자인 @@ -39,53 +44,74 @@ purity-and-effects-plan.md`와 연결됨). 어떻게 되는가"는 별도 메커니즘 없이 `canExecute` 재사용으로 해결됨 — `base/source-state-plan.md`의 "Slot 생존 확인" 절이 소스. -## Store = Source들의 이름 붙은 모음 (eager + lazy 생성) +## Store = Source들의 이름 붙은 모음 (명시적 초기화) -`store.a`처럼 키로 접근하면 **이미 만들어진 Source가 있으면 그대로 반환, -없으면 그 자리에서 만들어 저장한 뒤 반환** — 더 이상 별도 State wrapper를 -매번 만들거나 따로 캐싱하지 않음(Source 자체가 이미 State를 만족하므로 -wrapper 계층 자체가 불필요해짐, `base/source-state-plan.md`의 "Source가 -State를 만족함" 절). +> **⭐⭐ [2026-08-25] 같은 날 두 번 바뀌었다 — 최종은 이 절이다.** +> 오전에 "`store.key`가 값이고 `store:Of(k)`가 프리미티브"라는 재설계를 +> 넣었다가 **같은 날 철회**했다. 철회된 시도의 원문과 이유는 +> `archive/store-value-field-redesign-withdrawn.md`. 그 시도가 남긴 것은 +> **명시적 초기화**(eager/lazy 이중 모델 폐기) 하나이고, 나머지(값 필드, +> `__index`/`__newindex` 슈가, 팬텀 필드, `index<>`/`keyof<>`)는 전부 +> 되돌렸다. -- **`defaults`는 선택**(안 줘도 됨, 순수 편의용 초기값 템플릿) — - `Store({defaults})`가 내부적으로 `{[key] = Source(default), ...}`나 - 다름없게 됨. -- **[정정, 2026-08-07] "Store 생성 시 전부 eager하게만 만들어진다"는 이전 - 서술은 부정확 — eager와 lazy가 둘 다 필요하다.** Luau 타입은 런타임에 - 강제되지 않으므로 `Store<>()`처럼 `defaults` 없이 만든 뒤 - `.Key:Set(v)`를 부르는 경우, `__index`가 "없으면 그 자리에서 만들어 - 저장"까지 해주지 않으면 `.Key`가 `nil`이라 크래시남. 그래서 **Store - 생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과 - **`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어 - 저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함. -- **[2026-08-18 구현 전 QA, 2026-08-19 M0 실측으로 해소] lazy 생성이 - 오타/동적 키로 Source를 무한정 누적하는 트레이드오프는 그대로 수용하고, - 방어선은 런타임이 아니라 타입에 둔다.** 사용자 판정: *"Store<{ field: - type }> 상 없는 네임에는 타입 시간에 Source 가 없는것으로 나와 타입 - 에러만 나면 됩니다. 아마 지금 설계가 그럴것이예요"* — 즉 - `Store<{field: T}>`로 선언된 Store에 없는 이름을 쓰면 `type function`이 - 합성한 결과 타입에 그 프로퍼티가 없어 **타입 에러**가 나야 한다. - **[2026-08-19 실측 완료]** 맞았음 — - `luau-test/done/21-type-store-undeclared-key-rejected.luau`가 - `ProcessStoreType`(`16`과 동일 type function)의 결과 타입에 미선언 키로 - 접근하면 정확히 `TypeError` 2건(읽기·메소드 체이닝)이 나고, 선언된 키 - 3개는 클린임을 확인. 런타임에 굳이 이름을 받아야 하는 경우는 - `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구. -- **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹 - 스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값 - 템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던 - "defaults 테이블 직접 mutate는 UB"라는 옛 서술은 2026-08-07에 정정됨). - 별도 `__values`류 그림자 실값 저장소도 불필요 — Source 객체 자체가 - 저장소 역할을 함. -- **구현 스케치(2026-08-07, 성능 근거): eager 생성은 `table.clone(defaults)` - 후 그 결과를 순회하며 각 슬롯을 `Source(v)`로 교체하는 모양이어야 함** - (`local sources = table.clone(defaults); for k, v in sources do - sources[k] = Source(v) end` 류) — 빈 테이블을 새로 만들어 키를 하나씩 - 넣는 것보다, `table.clone`으로 원본의 해시/배열 슬롯 구조를 그대로 - 재사용하는 쪽이 Luau VM 입장에서 더 쌈(직접 해시 슬롯을 처음부터 - 구성하는 것보다 기존 슬롯을 복제하는 게 저렴). `Source()`(인자 없이 - 호출)는 `Source(nil)`과 동치 — `defaults`에 값이 없는 키를 `store.key` - 접근 시점에 lazy 생성할 때 이 무인자 형태를 씀. +**Store의 타입 인자에는 `Source`를 직접 쓰고, `defaults`에도 `Source(v)`를 +직접 넣는다.** + +```lua +local store = quad.Store<<{ + hp: Source, + name: Source, +}>>({ + hp = Source(100), + name = Source(""), +}) + +store.hp:Get() -- 평범한 레코드 필드 접근 → Source +store.hp:Set(5) +store.hp:Compute(function(s) ... end) +``` + +- **`store.key`는 평범한 레코드 필드다** — `Store`가 `T`(그 자체로 + `{hp: Source, ...}`)를 그대로 포함하므로 **타입 함수가 하나도 + 안 든다.** 마법이 없고, 읽기/쓰기 의미론이 `Source`의 기존 계약 + (`:Get()`/`:Set()`) 그대로다. +- **⭐ 명시적 초기화가 기본** — 선언한 키는 생성 시 `Source`를 준다. + `defaults`에 없는 키는 **필드 자체가 없어** 타입에서 걸리고, 런타임에도 + `nil` 역참조로 즉시 드러난다. 옛 lazy `__index`(없는 키를 그 자리에서 + 만들어 저장)는 **폐기**됐다 — **사용자 근거**: *"lazy 로 만들어낸다는 + 발상 자체가 약간 문제가 있어요. Set 을 안 해주면, 초기 값이 타입에 + 어긋날 수 있거든요. 애초에, `Source` 인데, nil을 조용히 가지고 + 있을수도 있고, 타입으로 못 막네요."* + - **부수 효과**: 선언 키 집합의 **런타임 소스가 `defaults` 하나**로 + 확정된다. 그래서 `store:Names()`가 `pairs`로 성립한다 — 옛 lazy + 모델에선 키 집합이 접근 이력에 좌우돼 0개/1개/2개로 갈렸다 + (7라운드 `H-79`). +- **부모가 값을 다 안 넘겨도 되게 하려면 컴포넌트가 자기 `DEFAULTS`로 + 채운다** — 타입에 `?`를 다는 게 아니라 **호출 규약**으로 표현한다 + (**사용자 아이디어**: *"기본 값인 소스는 한 곳에 `Defaults = {}` 해두고 + 쓰는거죠"*). 그래야 생성자가 항상 완전한 테이블을 받는다. +- **외부에서 만든 `Source`를 나중에 끼워 넣는 표면은 없다** — 생성 시점에 + 넣는 게 전부다(**사용자 판단**: *"다른곳에서 생성된 Source 를 다시 + 넣는다는게, 가능하게 해야할 표면적 이유가 없습니다"*). +- **값이 없는 상태가 필요하면 `None`이 이미 그 자리다** — attribute를 + 실제로 지우는 것도 `Source → None → nil`로 핸들러 계열을 타고 + 말단에서 set nil 된다. Store 키를 nilable로 만들 이유가 아니다. +- **구현 스케치**: 생성 시 `table.clone(defaults or {})`로 그림자 테이블을 + 만든다(`table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에 + 키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로). 값이 이미 + `Source`이므로 **슬롯 교체 순회가 없다**. **`or {}`가 필수다** — 무인자 + `Store<<{}>>()`도 유효한데 `table.clone(nil)`은 + `table expected, got nil`로 죽는다(**[2026-08-25]** 7라운드 `H-83` 실측). +- **`store:Names()`** — 그 시점의 키 집합을 준다(그림자 테이블의 키). 그룹 + `Attribute(...)`/`attr:NameMap()`이 이걸 요구한다 + (`base/attribute-plan.md`, 7라운드 `H-79`). + **⚠️ "선언된 키"와 정확히 같지는 않다** — `defaults`의 키에 **동적 키 + 창구 `store:Of(name)`이 만든 것**이 더해진다(아래 "타입 추론 문제" 절). + 둘의 차이는 `Of`를 쓴 Store에서만 생기고, `Of`는 "타입 보장을 포기했다"가 + 호출부에 드러나는 명시적 자리다. **그룹 `Attribute`에 미치는 영향**: + 이미 배치된 `Attribute(store)` 바인딩은 그 시점의 `NameMap()` 스냅샷으로 + 구성되므로, 나중에 `Of`로 늘어난 키는 **다음 재디스패치 때** 반영된다 + (`base/attribute-plan.md`의 그룹 절). v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게 쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 타입이 핵심 @@ -95,38 +121,36 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 `Source(default)`를 쓸 것(`base/source-state-plan.md`의 "Source는 독립 공개 프리미티브로 격상" 절). -## Store 값 설정 문법 — `myStore.key = value` 폐기, `source:Set(value)`로 전환 (2026-08-06 후속 세션, 정정) +## Store 값 설정 문법 — `myStore.key = value` 폐기, `store.key:Set(value)` (2026-08-06 후속 세션, 확정 유지) -**이전 버전("v1 인체공학 유지, `__newindex` 기반 `myStore.key = value` -그대로")은 폐기됨.** `base/source-state-plan.md`의 "Source가 State를 -만족함" 절 타입 설계와 맞물려 재검토된 결과: +> **[2026-08-25] 이 결정은 유지된다.** 같은 날 오전에 한 번 뒤집었다가 +> (`store.key`를 값으로 만들면서 대입이 되살아났었다) **같은 날 철회**했다 — +> `archive/store-value-field-redesign-withdrawn.md`. `store.key`가 다시 +> `Source`이므로 아래 근거 셋이 전부 그대로 성립한다. -1. **타입 대칭성**: `store.key`가 이제 `Source`를 직접 반환하는 - 평범한 레코드 필드(`{key: Source}`)로 타이핑되는데, 레코드 - 필드는 읽기/쓰기 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립함. +**`store.key = value`는 안 쓴다.** 값을 쓰는 경로는 `store.key:Set(value)`다. + +1. **타입 대칭성**: `store.key`가 `Source`를 직접 반환하는 평범한 레코드 + 필드(`{key: Source}`)로 타이핑되는데, 레코드 필드는 읽기/쓰기 + 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립한다. `store.key = value`(raw `T` 대입)를 유지하면 읽기(`Source`)/쓰기(`T`) - 타입이 갈려 mismatch가 남음 — `store.key:Set(value)`로 통일하면 필드 - 타입이 항상 `Source`로 대칭적이라 문제 자체가 안 생김(사용자 지적). + 타입이 갈려 mismatch가 남는다 — `:Set(value)`로 통일하면 필드 타입이 항상 + `Source`로 대칭적이라 문제 자체가 안 생긴다(사용자 지적). 2. **의미론적 정직성**: `=` 대입 문법은 관례상 "그 자리에서 즉시 확정되는 부작용 없는 값 쓰기"를 암시하는데, quad의 실제 동작은 **lazy** — `Set`은 무효화 신호만 쏘고, 실제 재계산은 나중에 누군가 관측(`Get()`)할 - 때만 일어남("Emit으로 필요한 사람 있어? 하고 물어보고, 있어야 진짜 - 계산 시작"). 이건 `=`가 암시하는 "즉시 커밋"과 정서가 안 맞고, 메소드 - 호출(`:Set()`)이 "이건 프로세스를 트리거하는 연산"이라는 걸 더 정직하게 - 신호함(사용자 확정 논거). -3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙(`base/ - architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게 - 아니라 기존 원칙의 정상적인 적용. + 때만 일어난다. 메소드 호출(`:Set()`)이 "이건 프로세스를 트리거하는 + 연산"이라는 걸 더 정직하게 신호한다(사용자 확정 논거). + **[2026-08-25 보강]** 철회된 재설계가 이 논거를 실제로 검증해줬다 — + `store.key = v`를 되살리자 *"`.Value = 1`이 정적 쓰기처럼 보이는데 + 거기서 error trace가 나오면 당황스럽고 약간 마법적"*(사용자)이라는 + 문제가 바로 드러났다. +3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙 + (`base/architecture.md`)에도 자연스럽게 들어맞는다. -**남는 것**: `:` 체이닝 원칙은 `:Set()` 자체가 그 사례라 유지. -**[정정, 2026-08-18 구현 전 QA] `myStore "key"`(문자열 커링)는 기각됐다** — -여기엔 "동적 키 전용 미타입 폴백으로 격하돼 그대로 유지"로 적혀 있었으나, -사용자 판정은 폐기다: *"store "a" 식으로 문자열 호출하는것 또한 기각된 -바임. 저러면 "a" 가 string 으로 들어가서, Source 의 타입을 모르기도 -하고, 우린 더이상 필요하지 않게 된 요소임."* 근거는 (a) `"a"`가 그냥 -`string`으로 들어가 `Source`의 `T`를 알 수 없고, (b) dot-access + -`type function` 타이핑이 자리잡아 더 이상 필요 없어졌다는 것. 동적 키는 -아래 `:GetDynamic` 항목으로 간다. +**`myStore "key"`(문자열 커링)는 기각**이다(2026-08-18 사용자 판정) — +*"저러면 "a" 가 string 으로 들어가서, Source 의 타입을 모르기도 하고, +우린 더이상 필요하지 않게 된 요소임."* 동적 키는 아래 `:Of`로 간다. `base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께 읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 @@ -137,93 +161,119 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 - `store "key"`(문자열 커링)로 `state`를 오버로드 함수 타입으로 정확히 추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**, - 위 절), **`store.key`(dot-access)를 1급 경로로 확정** - — Store 타입을 `{key: Source, other: Source}`류 평범한 - 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 - 리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State` - 필드로 적혀있었으나 Source가 State를 만족하는 구조로 바뀌며 `Source`로 - 갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가 - 더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이 - 해소됨, 위 "Store 값 설정 문법" 절 참고). - **[정정, 2026-08-18] 동적 키 경로는 문자열 커링이 아니라 명시적 메소드다** — - `store:GetDynamic<>(name): Source`. 런타임 동작 자체는 원래도 - dot-access와 같았고(lazy `__index`가 없는 이름을 만들면 그 자리에서 - Source를 만들어줌 — 아래 "없는 키" 항목) 문제는 **타입**뿐이었다: - 선언되지 않은 이름은 `type function`이 합성한 레코드 타입에 없어서 타입 - 에러가 난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이 - 정해지는" 정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를 - 둔다 — 사용자 판정: *"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐. - 타입 애러가 난다는 점인데, 이는 GetDynamic(name): Source 로 - 제공하는게 최선으로 보임."* 이름이 명시적이라 "여기서 타입 보장을 - 포기했다"가 호출부에 드러나는 것도 문자열 커링보다 나은 점. - - **⚠️ [구현 주의, 2026-08-18 감사에서 발견] 콜론 메소드는 Store의 - lazy `__index`와 정면으로 부딪힌다.** 위 "Store = Source들의 이름 붙은 - 모음" 절이 확정한 대로 **없는 키를 인덱싱하면 그 자리에서 `Source`를 - 만들어 저장**하므로, 아무 장치 없이 `store:GetDynamic("x")`를 부르면 - `store.GetDynamic`이 **`"GetDynamic"`이라는 이름의 새 `Source`를 - 만들어 반환**하고 그걸 함수로 호출해 런타임 에러가 난다. 따라서 - **`__index`가 고정 메소드 테이블을 먼저 확인하고, 없을 때만 lazy - `Source` 생성으로 폴백**해야 하며, 그 결과 **`GetDynamic`은 Store의 - 예약 키 이름이 된다**(그 이름의 Source는 dot-access로 못 만듦). - `Modifier`가 `Apply`/`Peek`/`Overridden`을 같은 이유로 예약하는 것과 - 정확히 같은 구조(`base/modifier-plan.md`의 "구현 시 주의") — 다만 - Store의 키 이름은 **사용자 도메인 데이터 이름**이라 Modifier(스타일 - 프로퍼티 이름)보다 충돌 확률이 높다는 게 차이. - - **대안(미결, 사용자 판단 필요)**: 예약 키를 하나도 만들고 싶지 않으면 - **탑레벨 함수**(`getDynamic(store, name)`)로 두면 된다 — `isState`/ - `bindLifetime`처럼 "특정 프리미티브에 안 묶인 범용 유틸은 소문자 - 탑레벨"이라는 기존 네이밍 규칙(`base/architecture.md`의 "코드 스타일 — - 네이밍 케이싱")에도 오히려 더 맞는다. 사용자가 지정한 표기는 - `GetDynamic(name)`이므로 **일단 콜론 메소드 + 예약 키로 적어두되, - M2/M4 구현 전에 어느 쪽인지 확인할 것**(`question.md` 최우선 절). + 위 절), **`store.key`(dot-access)를 1급 경로로 확정**. Store 타입을 + `{key: Source, other: Source}`류 **평범한 레코드 타입**으로 + 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 리터럴 narrowing + 문제 자체가 안 생긴다. +- **⭐ [2026-08-25] 동적 키는 `store:Of<>(name): Source` 하나다** — + 옛 이름 `GetDynamic`을 **흡수**했다(표면 둘을 유지할 이유가 없다). + 선언되지 않은 이름은 레코드 타입에 없어서 dot-access는 타입 에러가 + 난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이 정해지는" + 정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를 둔다 — + 사용자 판정: + *"이는 GetDynamic(name): Source 로 제공하는게 최선으로 보임."* + 이름이 명시적이라 "여기서 타입 보장을 포기했다"가 호출부에 드러나는 것도 + 문자열 커링보다 나은 점. + - **⭐⭐ [2026-08-25 신설] `Of`는 없는 이름이면 그 자리에서 만들어 저장한다 — + 여기가 lazy 생성이 남는 유일한 자리다.** 명시적 초기화로 dot-access 쪽 + lazy `__index`는 폐기됐지만(위 절), **동적 키 창구는 그 위에 서 있었다** + (`/code-review high` 발견) — `defaults`에 없는 이름을 `Of`가 그냥 + 조회하면 `nil`을 `Source` 타입으로 돌려주고 호출부가 `:Get()`에서 + 타입 에러 없이 nil 역참조한다. + ```lua + function Store:Of(name) -- 동적 키 전용 + local src = shadow[name] + if src == nil then + src = Source() -- == Source(nil) + shadow[name] = src + end + return src + end + ``` + - **`__index` lazy와 다른 점**: 그건 **암묵**이었고(오타가 조용히 새 + Source를 만들었다) 이건 **명시**다 — 이름을 문자열로 넘기고 타입을 + `<>`로 직접 주는 자리라 "여기서 타입 보장을 포기했다"가 호출부에 + 드러난다. 그게 애초에 이 창구를 둔 이유다. + - **`store:Names()`는 그대로 성립한다** — 선언 키(`defaults`)에 `Of`가 + 만든 동적 키가 더해진 것이 그 시점의 실제 키 집합이고, `Names()`가 + 돌려주는 것도 그것이다. + - **오타 방어는 여전히 타입이 한다** — dot-access는 레코드 타입에 없는 + 이름을 거부한다. `Of`는 그 방어를 **의도적으로** 우회하는 창구다. + - **⭐ [2026-08-25 실측] `<>`는 값 호출부에서 동작한다.** + 7라운드 `H-73`이 *"Luau엔 호출부 명시 타입 인자 문법이 없다"*고 + 단정했으나 **틀렸다** — Luau의 **generic type instantiation** + (`luau.org/types/generics/#generic-type-instantiation`)이 값 호출부에서도, + 콜론 메소드에서도 `T`를 실제로 묶는다. `luau-analyze` 실측: + ```lua + local ok: Source = store:Of<>("x") -- 진단 없음 + local bad: Source = store:Of<>("y") -- 정확히 걸림 + local none: Source = store:Of("z") -- Source + ``` + 원문은 **인스턴스화를 생략한 호출만** 돌려보고 단정했다. 따라서 + `base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다. +- **⭐ [2026-08-25] 예약 키는 `Of`/`Names` 둘뿐이고, 충돌은 조용히 죽는다.** + 실측: 사용자 키가 예약 이름과 겹치면 교집합이 뭉개져 **그 필드의 타입 + 검사가 통째로 꺼진다**(음성 대조군이 진단 0건으로 통과했다). 시끄럽게 + 막히는 게 아니라 그냥 지나간다. + - **그래서 `T`를 검증만 하고 그대로 통과시키는 작은 `type function`을 + 둔다.** 겹치면 사용 지점에 + `TypeError: quad.Store: "Of" is a reserved key`가 뜬다. + **`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼 + 버려진다. `print(...)` + `return types.never` 조합만 된다 + (`luau-test/rewrite-required/23-...`가 기록해둔 사실이고 + `type-version-check`가 이미 쓰는 패턴). + - **이건 아래 §0 원칙의 허용 범위 안이다** — 타입 함수를 **진단을 띄우는 + 데만** 쓰고 접근 타입을 합성하는 데는 안 쓴다 + (`base/typing-limits.md`). - 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절이 최신 확정 내용. -### `store.key` 레코드 필드 타이핑 — Luau 타입함수로 해결 확인 (2026-08-12 열일곱 번째 세션, `pre-implementation-audit.md` 1-10 해소) +### `store.key` 레코드 필드 타이핑 — 타입 함수가 필요 없다 (2026-08-25 재확정) -위 절이 "`store.key`를 평범한 레코드 필드 타이핑으로 자동 해결"이라 -서술했지만, `Store`가 입력 `T`(예: `{ty: string}`)를 받아 -`{ty: Source}`류 결과 타입을 실제로 어떻게 합성하는지는 미검증으로 -남아있었음. **Luau의 `type function`**(컴파일타임에 타입을 인자로 받아 새 -타입을 조립하는 기능, https://luau.org/types/type-functions/ , -https://luau.org/types-library/ — tbox에서도 이미 쓰이는 검증된 패턴)으로 -정확히 풀림: +> **⭐⭐ [2026-08-25] 타입 함수로 접근 타입을 짓는 접근은 **두 번** 시도됐고 +> **두 번 다 폐기**됐다.** (1) `WrapStore`/`ProcessStoreType`로 결과 타입을 +> **합성**하는 안(2026-08-12~2026-08-15) — 7라운드 `H-75`/`H-76`이 두 +> 한계를 실측해 폐기. (2) `index<>`/`keyof<>` + 팬텀 필드로 짓는 안 +> (2026-08-25 오전) — 같은 날 철회 +> (`archive/store-value-field-redesign-withdrawn.md`). -```luau -type function WrapStore(ty: type): type - -- Source 형태를 그대로 조립(:Get/:Set/:Compute/:With 등) - local result = types.newtable() - result:setproperty(types.singleton("Get"), types.newfunction(...)) - return result -end +**지금은 타입 함수를 안 쓴다.** `Store`의 `T`가 이미 +`{hp: Source, ...}`이므로 `store.hp`는 **평범한 레코드 필드 +접근**이고, Luau가 그냥 해준다. -type function ProcessStoreType(ty: type): type - local props = ty:properties() :: { [type]: { read: type?, write: type? } } - local result = types.newtable() - for i, v in props do - -- i는 프로퍼티 이름을 담은 singleton 타입, i:value()로 실제 문자열 - result:setproperty(i, WrapStore(v)) - end - return result -end +```lua +type Store = T & { + Of: (self: any, name: string) -> Source, -- 동적 키 전용 + Names: (self: any) -> { string }, +} ``` -`ProcessStoreType<{ty: string}>` → `{ty: Source}`가 나옴 — 결과는 -선언 시점에 이름 붙은 `Source` 그 자체가 아니라 구조를 그대로 풀어낸 -(flatten) 익명 타입이지만, **Luau는 이름이 아니라 "만족하는가"로 구조적 -일치를 검사**하므로 문제없이 `Source` 자리에 대입 가능 — 오히려 이 -방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이 -확인돼 M0/M2 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 — -`ROADMAP.md`의 M0/M2 배치를 강제로 바꿀 필요는 없어짐, 설계 레벨의 검증 -난이도 문제였던 것만 해소. **[2026-08-15] 이 `type function` 접근 자체의 -실측도 완료** — 스파이크(`luau-test/done/16-type-store-key- -typefunction.luau`)는 원래 `types.newfunction` 시그니처 불일치로 깨져 -있었으나 원인이 설계 문제가 아니라 API 버전 드리프트였음이 드러나 수정 -후 통과(음성 대조군 4건 포함), `base/typing-limits.md` §5로 승격. -상세는 `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절. +**`luau-analyze` 실측**(양성 + 음성 대조군): + +| 검사 | 결과 | +|---|---| +| `store.hp` | `Source` ✅ (`Source` 대조군 걸림) | +| `store.hp:Get()` / `:Set(5)` | ✅ (틀린 타입 대조군 둘 다 걸림) | +| `store.nope` | **거부** ✅ | +| `store:Of<>("dyn")` | `Source` ✅ (대조군 걸림) | +| 콜백 파라미터 추론(`store.hp:Compute(function(s) ... end)`) | ✅ `s`가 `StateData`로 잡힘(`s:NoSuchField()`가 정확히 걸림) | + +- **⚠️ `Compute`/`Apply`의 반환 타입은 여전히 명시 주석이 필요하다** — + 이건 Store와 무관한 `base/typing-limits.md` §1의 **문제 B**이고, + `store.hp`든 독립 `Source`든 **똑같이** 조용히 안전성을 잃는다(실측: + 틀린 주석 `local x: State = src:Compute(...)`이 양쪽 다 안 걸림). + `audit/type-recursion-issue/REPORT.md` 3-1절이 확정한 대로 **명시 주석 + 이후 다운스트림 전체는 정상 체크**되고 구멍은 그 한 줄뿐이다. + 살아나는 건 **콜백 파라미터** 쪽이다 — 그리고 철회된 재설계의 + `Self` 제네릭을 거치면 **그것마저 깨졌다**(`s`가 `unknown`으로 떨어짐). +- **`__call` 경로는 죽었다** — 타입 레벨 `__call`은 `self`를 못 받고, + `typeof(f<>)`로 타입 인자를 넘기는 것도 실패한다(실측). 같은 사실이 + `base/source-state-plan.md`의 `:Apply`에도 적용된다(7라운드 `H-94`). + +실측 전량은 `audit/type-store-index-keyof/`가 소스. ## Store가 Store를 저장 가능한가 diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index 981511d..5269087 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -45,6 +45,28 @@ 사례 — 코드 생성 없이 타입 선언 두 개로 끝나고, 나중에 Luau가 고쳐져도 손해가 없음). +### ⭐⭐ [2026-08-25 신설, 사용자] 타입 함수는 **진단을 띄우는 데까지만** 쓴다 + +**사용자 확정**: *"어쩌면 타입함수는 타입이 못 잡는 문제를 에러로 띄우기 +위한 정도 이상으로 가지 않는게 이로울 수도 있습니다."* + +- **허용**: 타입이 못 잡는 오용을 **컴파일 타임 에러로 만드는** 용도. + `type-version-check`의 `CheckVersion`(버전 불일치를 사람이 읽을 + 메시지로), Store의 `CheckReserved`(예약 키 충돌)가 그 사례다. 둘 다 + **`T`를 검증만 하고 그대로 통과**시키고 결과 타입을 만들지 않는다. + `error()`가 아니라 `print(...)` + `return types.never`를 쓴다. +- **안 함**: **접근 타입/결과 타입을 합성**하는 용도. +- **왜**: 그 선을 넘으면 §6이 실측한 함정(합성을 거친 값은 이후 제네릭 + self 체이닝이 조용히 깨짐)과 §5가 폐기한 한계(바깥 별칭 참조 불가, + 메소드 self 파라미터 불변)를 그대로 떠안는다. +- **⚠️ `index<>`/`keyof<>`도 타입 함수다** — Luau가 predefine해둔 것일 + 뿐 성질은 같다(**사용자 지적**: *"구현을 뜯어보면, 루아우에서 + 프리디파이닝한 타입함수임. 타입함수가 가지는 고질적 문제를 그대로 + 가져요."*). 실제로 2026-08-25에 `WrapStore`를 버리고 그쪽으로 갈아탔다가 + **같은 날 철회**했다 — 그 조합의 `Self` 제네릭을 거치면 **콜백 파라미터 + 추론이 깨진다**(평범한 레코드 필드에선 정상, 실측 대조). + 경위는 `archive/store-value-field-redesign-withdrawn.md`. + --- ## 1. ⭐ 재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이 조용히 사라짐 @@ -242,7 +264,7 @@ self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T | `state:With(...)` | 인라인 + 쪼개기 | 쪼개기로 해결(이형 dep 포함) | ❌ 명시 바인딩 필요 | | `state:Apply(factory)` | 인라인 | factory 파라미터 주석 필요 | ❌ 명시 바인딩 필요 | | `Effect(fn, ...deps)` | — | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) | -| `state:Observer(fn)` | — | 해당 없음(로컬 제네릭 없음) | 해당 없음(`EffectHandle` 반환) | +| `state:Observer(fn)` | — | 해당 없음(로컬 제네릭 없음) | 해당 없음(`Observer` 반환) | | `tween:Mapped(fn)` | 인라인 제네릭 메소드 | — | ❌ **조용히 통과**(아래 `H-24`) | | `tween:Mapped(fn)` | `typeof(named fn)`(③) | 콜백 파라미터 명시 주석 필요 | ✅ 안전 | @@ -350,14 +372,34 @@ RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이 --- -## 5. `store.key` 레코드 필드 타이핑(`type function`) — ✅ 검증 완료 +## 5. `store.key` 레코드 필드 타이핑 — ⛔ 이 접근은 폐기됨 (2026-08-25) -**[2026-08-15 확정, 근거: `luau-test/done/16-type-store-key-typefunction.luau`, -`audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절]** +> **⭐⭐ [2026-08-25 폐기] `WrapStore`/`ProcessStoreType`로 결과 타입을 +> **합성**하는 접근 자체가 사라졌습니다.** 지금 Store는 **타입 함수를 안 +> 쓰고** 타입 인자에 `Source`를 직접 써서 **평범한 레코드**로 +> 타이핑합니다(같은 날 `index<>`/`keyof<>` + 팬텀 필드로 가는 안을 +> 넣었다가 위 §0 원칙에 따라 철회했습니다 — +> `archive/store-value-field-redesign-withdrawn.md`) — +> `base/store-plan.md`의 "`store.key` 레코드 필드 타이핑" 절이 소스, +> 뒤집힌 원문은 `archive/store-value-field-redesign-withdrawn.md`. +> 아래는 그 접근이 살아 있던 동안의 실측 기록이고, **`type function` +> 자체의 성질**(API 시그니처, 재귀 호출 한계)은 여전히 유효합니다. +> +> **왜 폐기됐나**(7라운드 `H-75`/`H-76` 실측): (1) 합성 결과가 **평평하면** +> `store.key:Compute(무주석 콜백)`이 깨진다 — 아래 1번의 ②쪼개기를 +> `type function` **안에서도** 해야 한다는 뜻인데 그게 문서 어디에도 +> 없었다. (2) `type function`은 **바깥 타입 별칭을 참조하지 못해** +> `Source` 전 표면을 구조적으로 중복 작성해야 하고, **메소드 self +> 파라미터가 불변**이라 필드 하나만 어긋나도 `store.key`가 `State` +> 파라미터 자리에 안 들어간다. 스파이크 `16`은 그 대입을 한 번도 +> 해보지 않았다. + +**[2026-08-15 확정, 근거: `luau-test/rewrite-required/16-type-store-key-typefunction.luau`, +`audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 — 폐기 전 기록]** `Store` → `{[K]: Source}` 합성을 Luau `type function`으로 하는 -설계(`pre-implementation-audit.md` 1-10)는 **설계와 실측 둘 다 -확정**입니다. 원래 스파이크가 깨졌던 이유는 설계 문제가 아니라 +설계(`pre-implementation-audit.md` 1-10)는 그 시점 **설계와 실측 둘 다 +확정**이었습니다. 원래 스파이크가 깨졌던 이유는 설계 문제가 아니라 **`types.newfunction`의 API 버전 드리프트**였습니다 — 시그니처가 `(parameters: {head: {type}?, tail: type?}, returns: {head: {type}?, tail: type?}?, generics: {type}?): type`로 parameters/returns 둘 다 @@ -466,7 +508,38 @@ local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T `PostRef`도 같이 커버할 것. - **Modifier의 제네릭 `__index` + `table.clone` 체이닝** — `luau-test/done/17`. - **콜백 파라미터/본문의 타입 체크**(1번의 쪼개기 적용 시) — 진짜 - 살아있음. + 살아있음. **⚠️ [2026-08-25 경계 명시, 7라운드 `H-96`] 단 trailing deps가 + 붙으면 dep 파라미터엔 주석이 필요하다** — deps가 0개면 콜백 파라미터가 + 무주석으로 추론되지만, `:Compute(fn, a, b)`처럼 trailing deps가 붙는 + 순간 그 dep 파라미터들은 해소되지 않아 `Consider annotating the return`이 + 뜬다. ②쪼개기가 푸는 범위 **밖**이다. +- **명시적 제네릭 인스턴스화 `f<>(...)`가 값 호출부에서 동작**한다 + — 콜론 메소드에서도 된다(**[2026-08-25 실측]**, 7라운드 `H-73`이 + "문법이 없다"고 단정했던 것을 뒤집음). 자세한 건 + `base/store-plan.md`의 "타입 추론 문제" 절. +- **⭐ [2026-08-25 실측] 싱글톤 보존 — 타입 후보 중에 싱글톤이 있으면 + `string`으로 안 뭉개진다.** `index<>`/`keyof<>` 기반 키 타이핑 전체가 + 이 성질에 기대고 있어서 따로 확인했다: + + | 선언 | `f("bbb")`의 `T` | 진단 | + |---|---|---| + | `f(input: T): T` | `string` ❌ 뭉개짐 | 없음 | + | `f(input: T & string): T` | `unknown` ❌ | 없음 | + | `f(input: T & ""): T` | `"bbb"` ✅ | **에러**(교집합이 빔) | + | **`f(input: T \| "" \| string): T`** | `"bbb"` ✅ | **없음** ✅ | + | **`f(input: T & ("A" \| "B")): T`** | `"B"` ✅ | 범위 밖이면 정확히 걸림 ✅ | + + **[2026-08-25] 다만 quad는 이 성질에 기대는 설계를 채택하지 않았다** — + `K & keyof<...>` 기반 Store 타이핑은 같은 날 철회됐다(위 §0). 아래는 + Luau 자체의 성질로만 기록해둔다. 사용자 관찰: + *"K& 를 걸고 유니온 스트링을 걸면, K 가 싱글톤으로써, string 으로 + 뭉개지지 않고 전해진다."* 대조 전량은 + `audit/type-store-index-keyof/REPORT.md`. +- **⛔ 타입 레벨 `__call`은 죽은 경로다**(**[2026-08-25 실측]**) — `self`를 + 못 받고(`Argument count mismatch`), `typeof(f<>)`로 타입 인자를 + 실어 나르는 것도 실패한다. 그래서 콜러블 팩토리는 `__call`이 아니라 + **지정된 필드**로 자기를 노출한다(`base/source-state-plan.md`의 + "`state:Apply(factory)`" 절). --- diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 6474184..13ef703 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -88,12 +88,12 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `13-type-ref-preref-subtype.luau` (타입체크 전용) | **[2026-08-19 재작성]** `PreRef`/`PostRef`가 `Ref`를 구조적으로 만족하는지 — 원래 이 파일에 있던 런타임(B) 부분은 A의 더미 스텁이 실행을 막아 도달 불가였던 문제라 `22`로 분리, PostRef까지 확장 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정), `ref-plan.md`의 "`PostRef`" 절 | | `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | -| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | +| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 **⛔ [2026-08-25 이동, `rewrite-required/`] 검증 대상이 폐기됐다** — `WrapStore`/`ProcessStoreType`로 결과 타입을 **합성**하는 접근 자체가 Store 재설계로 사라졌다(`base/store-plan.md`, 역전 원문은 `archive/store-value-field-redesign-withdrawn.md`). 재작성 지침은 `STATUS.md`가 소스 — **타입 함수 없는 평범한 레코드** 모양을 검증하고 음성 대조군에 **예약 키 충돌**(`CheckReserved`)과 없는 키 접근을 넣을 것 | | `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 | | `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 실사례 | | `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | | `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `dispatch-core-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) | -| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 | +| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 **⚠️ [2026-08-25 이동, `rewrite-required/`]** `16`의 `ProcessStoreType`을 재사용하므로 같이 낡았다. **검증 대상(미선언 키는 타입 에러)은 그대로 유효**하고 새 모양에서도 성립함이 확인됐다(`store.nope` 거부) — 새 `Store` 선언으로 바꿔 쓰기만 하면 된다 | | `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지. **[2026-08-21] `rewrite-required/`로 이동** — 파일이 직접 구현해 쓰는 `Brand.set`/`Brand.get`이 인스턴스 브랜드 재작성으로 역전된 옛 API가 됐다(검증 대상 자체는 그대로 유효, 상태와 재작성 지침은 `STATUS.md`가 소스) | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 "⭐ 구현 — 인스턴스 브랜드" 절 | | `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 | diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 8b5c180..74f29c3 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -46,9 +46,17 @@ | 폴더 | 뜻 | 개수 | 누가 처리 | |---|---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | -| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 7 | 에이전트 | +| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 9 | 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | -| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — | +| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — | + +**⚠️ [2026-08-25 신설] 타입 스파이크는 `./scripts/test.sh`가 하는 리링크를 +먼저 거쳐야 한다.** `luau` CLI가 심볼릭 링크를 못 타는데(디렉토리·파일 둘 +다) pesde의 워크스페이스 링크가 전부 심볼릭이라, 실제 패키지를 `require`하는 +스파이크(`23` 등)는 리링크 없이는 **의도한 진단 대신 링크 실패 진단**이 +뜬다 — 게다가 `luau-analyze`는 그걸 **조용히 통과**시키기도 한다(모듈을 +`any`로 떨어뜨림). `scripts/relink.sh`를 돌린 뒤 확인할 것. 경위는 +7라운드 `H-78`. **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 @@ -95,6 +103,18 @@ 통과 상태로 `done/`에 두면 `01`은 구현이 안 하는 두 루프 순회를, `05`는 **이제 접히는 중복 통지가 안 접힌다는 것**을 "검증됨"으로 오독하게 된다. +**[2026-08-25] `16`과 `21`이 합류** — Store 재설계로 **검증 대상 자체가 +폐기**됐다. `WrapStore`/`ProcessStoreType`로 결과 타입을 **합성**하는 +접근이 사라졌다 — 지금은 **타입 함수를 안 쓰고** 타입 인자에 `Source`를 +직접 써서 평범한 레코드로 짓는다(`base/store-plan.md`). 같은 날 +`index<>`/`keyof<>` + 팬텀 필드 안을 넣었다가 `typing-limits.md` §0 원칙에 +따라 철회한 경위는 `archive/store-value-field-redesign-withdrawn.md`. 발단은 7라운드 +`H-75`/`H-76` 실측이다 — 평평한 합성이면 `store.key:Compute(무주석 +콜백)`이 깨지고, `type function`이 바깥 타입 별칭을 참조하지 못해 구조를 +통째로 중복 작성해야 하며 메소드 self 파라미터가 불변이라 필드 하나만 +어긋나도 대입이 실패한다. **`21`의 검증 대상(미선언 키는 타입 에러)은 +그대로 유효**하고 새 모양에서도 성립함이 확인됐다. + **[2026-08-21 후속] `22`도 같은 이유로 합류** — `Brand`가 **인스턴스 브랜드**로 전면 재작성되면서(`base/brand-plan.md`) 이 스파이크가 직접 구현해 쓰는 `Brand.set`/`Brand.get`/`XxxTag`가 **역전된 옛 API**가 됐다 @@ -112,6 +132,8 @@ | `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) | +| `16-type-store-key-typefunction.luau` | 옛 접근 기준으로는 ✅ 통과였음 | **[2026-08-25] 검증 대상이 폐기됨** — `WrapStore`/`ProcessStoreType` 합성 자체가 사라졌다. **재작성 지침**: 타입 함수 없는 평범한 레코드 모양(`base/store-plan.md`의 "`store.key` 레코드 필드 타이핑" 절)을 검증하고, 음성 대조군에 **예약 키 충돌**(`CheckReserved`가 `types.never`로 무너뜨리는지)과 **없는 키 접근**을 포함할 것 | +| `21-type-store-undeclared-key-rejected.luau` | 옛 접근 기준으로는 ✅ 통과였음 | `16`의 `ProcessStoreType`을 재사용하므로 같이 낡음. **검증 대상(미선언 키가 타입 에러)은 그대로 유효**하다 — 새 `Store` 선언으로 바꿔 쓰기만 하면 된다(`store:Of("nope")`이 거부되는 것도 같이 넣을 것) | | `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | ## ⚪ `not-run/` — 이 환경에서 못 돌림 @@ -160,8 +182,6 @@ | `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로). **[2026-08-24 `H-54`] 단 스파이크 자신의 주석이 실제 결과와 어긋난다** — *"이건 당연히 통과해야 함"*이라 적어둔 동질 대조군(line 41-43)이 실제로는 에러를 낸다(`AttributeKey(name)`이 문맥에서 `T`를 못 추론해 `unknown`으로 남음). 오히려 "왜 진짜 테스트 대상이 조용히 통과하는지(= narrowing이 아예 안 일어남)"를 설명해주는 정합적 결과라 **이 총론은 그대로 유효**하고, 근거 라인만 다르다 — 재작성 시 참고 | | `13-type-ref-preref-subtype` | **[2026-08-19 재작성]** ✅ 통과 — `PreRef`/`PostRef` 둘 다 `Ref`를 구조적으로 만족(음성 대조군도 정확히 에러). 런타임 B섹션은 `22`로 분리(A의 더미 스텁이 B 실행을 막던 문제 해결) | | `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 | -| `16-type-store-key-typefunction` | **[2026-08-15]** ✅ 통과 — 원인은 설계 문제가 아니라 `types.newfunction` API 버전 드리프트(배열이 아니라 `{head=...}` 레코드). `ProcessStoreType`이 정확히 `{ty: Source, count: Source}` 구조를 만족, 음성 대조군 4건(틀린 Get/Set 타입 2건, 존재하지 않는 메소드) 전부 정확히 에러. 근거: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | -| `21-type-store-undeclared-key-rejected` | **[2026-08-19 신규]** ✅ 통과 — `16`의 `ProcessStoreType`을 재사용해 미선언 키 접근 2건(읽기, 메소드 체이닝)이 정확히 `TypeError`로 거부됨을 확인, 양성 경로(선언된 키 3개) 클린. `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측 확정 | | `23-type-quadtypes-checkversion-addplugin` | **[2026-08-19 신규, 같은 날 후속으로 재작성]** ✅ 통과 — 실제 `quad-types`/`quad-base`/`type-version-check`로 `CheckedQuad`+`AddPlugin` 통합 검증. 재작성 과정에서 `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 조용히 깨진다는 새 Luau 함정 발견(`typing-limits.md` §6으로 승격), `export type function`/이중 꺾쇠 제네릭 인스턴스화 요구도 같이 실측 — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 | ### 특별히 중요한 통과 3건 @@ -207,5 +227,6 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치 | 검증할 것 | 왜 | 출처 | |---|---|---| | ~~`table.insert`가 배열 중간의 구멍을 재사용하는가~~ **[2026-08-24 폐기]** | **전제 자체가 없어졌다** — 6라운드 `H-7`로 `Ref.Callbacks`가 배열에서 `{[callback\|thread] = true}` **해시맵 셋**으로 바뀌었고, 해시맵엔 border 개념도 구멍도 없다(`base/ref-plan.md`). 이 스파이크는 만들지 말 것 | QA 4라운드 `R-11`(폐기), 6라운드 `H-7` | -| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M2 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 최우선 절 | +| 중간 State가 `_hold`(하류 → 상류 강함) 불변식으로 실제로 살아남는가 | **[2026-08-25] 설계는 확정됐다** — 각 파생 노드가 자기 상류를 `_hold`로 강하게 든다(사용자 확정). 남은 건 실측뿐이고 **M2 착수 게이트는 아니다**(`question.md` 최우선 절에서 내려감). 음성 대조군으로 "`_hold` 없이 짜면 중간 노드가 수거돼 전파가 끊긴다"까지 볼 것 | `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`로 살아남는다" 절 | +| `Relate` 값/내부 키가 바깥 키를 되참조하면 새는가 | **[2026-08-25 신설, 7라운드 `H-71`/`H-77`]** `done/07`은 **안전한 모양만** 봤는데 여러 문서가 그걸 "GC-native 아키텍처의 핵심 전제 검증"으로 인용한다. 실제로는 (a) `SetStrong`의 **값**이 되참조하면 100% 새고, (b) **내부 키**가 되참조하면 `SetStrong`/`SetWeak` **둘 다** 샌다. `07`에 음성 대조군으로 추가할 것 | `base/relate-plan.md`의 "위험한 패턴" 절 슬롯 표 | | `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` | diff --git a/.claude/luau-test/done/16-type-store-key-typefunction.luau b/.claude/luau-test/rewrite-required/16-type-store-key-typefunction.luau similarity index 100% rename from .claude/luau-test/done/16-type-store-key-typefunction.luau rename to .claude/luau-test/rewrite-required/16-type-store-key-typefunction.luau diff --git a/.claude/luau-test/done/21-type-store-undeclared-key-rejected.luau b/.claude/luau-test/rewrite-required/21-type-store-undeclared-key-rejected.luau similarity index 100% rename from .claude/luau-test/done/21-type-store-undeclared-key-rejected.luau rename to .claude/luau-test/rewrite-required/21-type-store-undeclared-key-rejected.luau diff --git a/.claude/project-context.md b/.claude/project-context.md index a1dee89..0db856b 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -17,14 +17,21 @@ M3의 번호·순서가 맞바뀌었다** — 열려 있던 마일스톤 순서 교체로 닫힌 결과다(경위는 `archive/question-resolved.md`의 "마일스톤 경계" 절, 새 구성은 `ROADMAP.md`의 M2 배너). **2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/`의 `M2`/`M3`는 옛 의미**(M2=디스패치, -M3=반응형)다. 그 교체의 부작용으로 **M2 착수 전에 답이 필요한 항목이 -`question.md` 최우선 절로 올라왔다**(무엇이 몇 개인지는 그 절이 소스 — -여기서 세지 않는다. 설계 게이트가 아니라 실측·표면 선택이라, 설계 게이트가 -없다는 아래와 `todos.md`의 서술과 모순되지 않음). 저장소 루트에 +M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선 절에 항목 +둘이 올라와 있었으나, **⭐ [2026-08-25] 둘 다 닫혀 그 절은 지금 비어 있다** +(중간 State GC는 `_hold` 불변식으로, `store:GetDynamic` 위치는 콜론 유지 + +`CheckReserved` 타입 함수로 — 7라운드 손 트레이싱 후속, 결정 전량의 소스는 +`qa-request/pre-implementation-handtrace-round7-followup.md`). **M2 착수를 +막는 항목은 설계든 실측이든 없다.** 저장소 루트에 `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ `quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`가 -소스. 핵심 아키텍처(Store 책임 분리, `process`/`retract` +소스. **⚠️ [2026-08-25] 테스트는 `./scripts/test.sh`로 돌릴 것** — +`luau` CLI가 **심볼릭 링크를 못 타는데**(디렉토리·파일 둘 다) pesde의 +워크스페이스 링크가 전부 심볼릭이라, 그냥 `luau`로 돌리면 스모크 2개가 +죽고 `luau-analyze`는 **조용히 통과**한다(모듈을 `any`로 떨어뜨림 — +"거짓 클린"). 그 스크립트가 `scripts/relink.sh`를 먼저 돌려 심볼릭을 실제 +복사로 바꾼다. 핵심 아키텍처(Store 책임 분리, `process`/`retract` 디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘, 컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/`에 문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가 diff --git a/.claude/qa-request/pre-implementation-handtrace-round6.md b/.claude/qa-request/pre-implementation-handtrace-round6.md index b5c9862..fd8730d 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round6.md +++ b/.claude/qa-request/pre-implementation-handtrace-round6.md @@ -1692,8 +1692,8 @@ nil value`, (b) 이름을 `self:Gate(...)`로 고쳐도 `setup`이 핸들러를 ## 🟢 `H-36` — `store-plan.md`가 미해결 항목을 확정처럼 근거로 인용한다 **어디**: `base/store-plan.md`(*"State/Source 그래프 구독이 전부 weak-keyed -GC-native"*) vs `base/source-state-plan.md`의 "⚠️ 미해결 — 중간 State가 -살아남는가(구독 엣지의 방향성)" 절. +GC-native"*) vs `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`로 +살아남는다" 절. **무엇이 어긋나나**: `store-plan.md`는 "그래프 구독이 **전부** weak-keyed"라는 명제를 기정사실로 써서 다른 결론(이중 해제 걱정 불필요)을 내리는데, 그 명제 diff --git a/.claude/qa-request/pre-implementation-handtrace-round7-followup.md b/.claude/qa-request/pre-implementation-handtrace-round7-followup.md new file mode 100644 index 0000000..7a787ca --- /dev/null +++ b/.claude/qa-request/pre-implementation-handtrace-round7-followup.md @@ -0,0 +1,899 @@ +# 7라운드 손 트레이싱 발견 — **사용자 결정과 반영 결과** + +**무엇인가**: `.claude/qa-request/pre-implementation-handtrace-round7.md`의 +발견 52건(`H-55`~`H-106`)과 그 검증 패스 +(`qa-request/pre-implementation-handtrace-round7-verification.md`)를 사용자와 대화형으로 처리한 결과. **결정의 소스는 +이 문서**이고, 발견 원문은 위 두 파일이 소스다(여기서 다시 서술하지 않음). + +**진행 방식**: 검증 패스가 재편성한 **결정 단위 12묶음(🅐~🅜)** 순서를 +따랐다. 같은 묶음 안의 항목은 결론이 서로를 규정하므로 같이 물었다. + +**[2026-08-25] 결정·반영 전부 완료.** 12묶음을 순서대로 처리했고 `base/` +반영까지 끝났다(`doc-check.py` ERROR 0). 처분 요약: + +| 처분 | 건수 | 번호 | +|---|---|---| +| 확정 — `base/` 반영 | 39 | `H-55`~`H-72`, `H-79`~`H-82`, `H-84`~`H-92`, `H-94`~`H-99`, `H-101`~`H-106` | +| **무효/소멸** | 4 | `H-73`(`<>`가 값 호출부에서 동작함) · `H-74`·`H-75`·`H-76`(Store 재설계로 `WrapStore` 폐기) | +| **기각** | 1 | `H-77`(`RunInit` 사례 — 성립 안 하는 사용법) | +| 범위 축소 | 2 | `H-87`(🔴→🟡) · `H-105`(수치 정정) | +| 다른 항목으로 흡수 | 6 | `H-64`/`H-70`(`Ref`=`Epoch`) · `H-102`(역참조 조회) · `H-93`/`H-98`(`_hold`) · `H-83` | + +**부수로 닫힌 것 둘** — `question.md` 최우선의 **중간 State GC**(`_hold` +불변식)와 **`GetDynamic` 위치**(콜론 + `CheckReserved`). **M2 착수를 막는 +항목이 하나도 남아 있지 않다.** + +**새 표면**: `Ref.Revision`(=`Epoch`) · `Ref:WeakCallback` · +`Observer:WeakSubscribe`/`:WeakUnsubscribe` · `EpochMap:Peek` · +`store:Of`/`:Names` + `CheckReserved` · `emit(commit) -> boolean` · +`bk.recomputeBlocker` · `EffectHandle:Rerun`(정의) · `_consumeCleanup` +**폐기**: `WrapStore`/`ProcessStoreType` · `_installing` · `rawInvalid` · +`_refDeps`/`_refCallbacks`/`_observers`(→ `_deps` 하나) · Store의 lazy 우선 모델 +**역전**: `store.key = value` 부활(`archive/store-value-field-redesign-withdrawn.md`) +**툴체인**: `scripts/relink.sh` + `scripts/test.sh` 신설 — `luau` CLI가 +심볼릭 링크를 못 탄다는 것이 최소 재현으로 밝혀졌다(`H-78`). + +--- + +## 🅐 게이트 정책의 상태 접근 통로 — `H-55` · `H-86` · `H-72` · `H-63` + +### `H-55` + `H-86` — `emit`에 인자와 반환값을 준다 **(확정)** + +`setup: (emit: (commit: boolean?) -> boolean) -> (onUpstreamEmit: () -> ())` + +- `emit()` / `emit(true)` — 평소대로 흡수 집합을 flush하고 전파. +- `emit(false)` — **흡수 집합을 버리고** 전파하지 않는다(`Trailing = false`, + `Cancel`, `OffWithoutEmit`이 요구하던 "버리기"). +- **반환값** — "실제로 내보내거나 버릴 게 있었는가"(= 흡수 집합이 + 비어 있지 않았는가). 정책이 `pending`을 따로 안 들고도 "지금 쌓인 게 + 있나"를 읽는 유일한 통로. + +`H-55`의 갈래 (b)와 `H-86`의 갈래 (a)를 그대로 합성한 것. `H-49`의 +*"`setup` 시그니처는 안 바뀐다"*는 **인자 목록은 유지한 채** 최소로만 +되짚는다(인자가 늘지 않고 기존 인자에 선택 파라미터와 반환값이 붙는다). + +`Throttle`의 `onWindowEnd`가 이걸로 닫힌다: + +```lua +if not emit() then -- 보류분 없었음 + window = nil -- 완전 idle 복귀 → 타이머 체인 종료 +else + rearm() +end +``` + +### `H-72` — `EpochMap:Peek(from) -> boolean` 추가 **(확정)** + +갈래 (a). 읽기 전용 비교(저장된 리비전과 비교만 하고 덮지 않음). +`Update`가 이미 `{읽기, 비교, 쓰기}`라 `Peek`은 그 앞 두 개만 쓰는 것이고 +내부 코드 공유가 쉽다. `GateNode:_receive`가 이걸 쓴다: + +```lua +local valueChanged = self.valueEpochMap:Update(from) +local emitChanged = self.emitEpochMap:Peek(from) -- 갱신 안 함 +``` + +### `H-63` — 세 자리 모두 선례대로 **(확정)** + +1. onunblock 핸들 보관은 **weak-키 해시맵 셋** `{[handle] = true}` + (`__mode = "k"`) — `H-7`이 `Ref.Callbacks`에 한 것과 동일. 배열의 + 구멍/`ipairs` 조기 종료 문제가 소멸한다. +2. **강한 주인은 정책이 반환하는 `onUpstreamEmit` 클로저다** — 그 클로저가 + onunblock 핸들을 upvalue로 잡는다. 체인은 + `GateNode → onUpstreamEmit → onunblock 핸들`이고, Blocker 쪽은 weak이라 + **게이트 노드가 죽을 때만** 수거된다. Blocker가 핸들을 강하게 드는 안은 + 기각 — 오래 사는 Blocker 하나가 거기 걸렸던 모든 gated state와 상류 + 체인을 영원히 살려두므로(`:List` 항목마다 게이트를 무는 패턴에서 직행 + 누수). +3. `Off()`/`OffWithoutEmit()`은 **스냅샷을 뜬 뒤 순회**한다(`H-23`과 동일). + +--- + +## 🅑 전파 루프를 코드로 확정 — `H-56` · `H-62` · `H-61` + +### `H-56` — 한 집합 + 구독자 종류별 분기 **(확정)** + +구독자 집합은 **하나**(`{[subscriber] = true}`, weak-키)이고, 원소는 +**Observer 값**이다 — emit 클로저가 아니다(`bindLifetime`이 그 identity를 +쓰므로 필수). `lifecycle-pattern.md` (4)의 *"Observer의 emit 클로저"* 표현을 +고친다. + +```lua +function State:_emitDown(from) + local snap = {} -- H-23 스냅샷 + for sub in self._subs do snap[#snap+1] = sub end + for _, sub in ipairs(snap) do + if isState(sub) then -- 자식 노드 + sub:_receive(from) -- §4 규칙 1~3, canExecute 안 봄 + elseif canExecute(sub) then -- Observer / Effect + sub.fn(sub, from) + end -- 거짓이면 조용히 건너뜀 + end +end +``` + +두 집합(`_childNodes`/`_observers`)으로 나누는 안은 기각 — emit마다 스냅샷이 +두 번이 되고(`H-92`의 할당 비용 두 배), 등록/해제 경로도 둘로 갈린다. + +### `H-62` — eager(생성 즉시 등록) **(확정)** + +**사용자 판단**: *"생성 즉시 밖에 없다. 옵져버가 실행 안 된다면 get 자체가 +안 되므로, lazy 하면 아예 등록 될 기회가 없다."* — lazy가 성립하려면 +"먼저 `Get()`이 일어난다"가 전제인데, `Get()`을 부르는 주체가 바로 그 +등록되지 못한 Observer라 순환이다. + +`:With`/`:Compute`/`:Gate`/`:Block`은 **만들어지는 그 자리에서** 상위의 +구독자 집합에 들어간다. `state-epoch-plan.md` §4의 생성 시점 시딩, +`blocker-plan.md`의 *"호출되는 즉시 … 등록"*, `source-state-plan.md`의 +":With도 새 State 노드로 확정" 절과 이미 일치한다. lazy를 논거로 쓰는 +"왜 State 체인을 Modifier처럼 플래튼하지 않는가" 절을 고친다. + +⚠️ eager이므로 `question.md` 최우선의 **"중간 State GC"** 미해결이 더 +절실해진다(엣지가 있어도 상위가 하위를 weak로만 들면 노드가 사라진다). + +### `H-61` — 무인자 `state:Observer()`의 내부 콜백은 `self:Get()` **(확정)** + +`state:Observer() == state:Observer(function(self) self:Get() end)`. +no-op 콜백은 `Get()`을 안 부르므로 문서가 적은 용도(`previous` 패턴의 +mutate 로직을 계속 돌게 하기)를 못 한다. 호출부 서술은 그대로 두고 내부 +콜백만 못박는다. + +--- + +## 🅔 `Effect` 배선 — `H-57`~`H-60` · `H-64` · `H-65` · `H-70` + +이 묶음은 대화 중에 **원문의 갈래 어느 것도 아닌 구조**로 수렴했다. 사용자가 +두 도구를 제안했고(`Ref`의 `Epoch` 승격, `Weak*` 등록 표면), 그 둘이 +`H-58`/`H-64`/`H-70`/`H-59`를 **한 번에** 닫았다. + +### 확정된 구조 — 강한 주인은 항상 `Effect`, 발화 게이트는 `canExecute` 하나 + +``` +Effect ──강──▶ { [Ref | State] = fn | Observer } ← 강한 주인은 언제나 Effect +Ref.Callbacks ──약──▶ fn (:WeakCallback) +Observer 전역 레지스트리 ──약──▶ Observer (:WeakSubscribe) +발화 게이트: 전부 canExecute(handle) 하나로 +``` + +**사용자 결론**: *"그냥 간단하게 저 강한 map 을 Effect 가 가지고, +WeakSub/WeakUnsub 를 WeakCallback 처럼 넣어줍시다. 의미론은 같습니다. +callback 을 잡고 있지 않거나, sub 대상인 observer 를 잡고 있지 않으면 gc 될 +수 있다. **그러면 bindLifetime 은 effect 하나 구현을 한 이후 canExecute 로 +모두 처리한다. 간단해집니다.**"* + +- dep마다 바인드/언바인드에서 등록·해제하던 춤이 **통째로 사라진다**. + `bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고, + 내부 Observer와 `Ref` 콜백의 발화 여부는 `canExecute(handle)`이 전담한다. +- 그래서 `H-7`의 *"`unbindLifetime`과 `:Unsubscribe()`에서 `:Uncallback`한다"*는 + **필요 없어진다**(`ref-plan.md`의 *"해제는 누수를, 게이팅은 발화를 막는다"* + 중 앞쪽 절반을 `Weak*`가 대신한다). + +### 새 표면 — `Ref:WeakCallback` / `Observer:WeakSubscribe`·`:WeakUnsubscribe` + +- **`Weak*`가 프리미티브이고 강한 쪽이 그 위에 얹힌다.** **사용자 확정**: + *"동작 자체는 Weak 아닌것과 동일하게 가고, 가드도 동일하나 단순히 gc 안 + 되도록 킵 해주는 부분만 제거된 함수가 됩니다. 따라서 내부적으론 Weak 를 + 구현해 두고, Weak 아닌 곳에서 Weak 를 수행하고 gc 처리만 두면 돼요."* + → `Callback(fn) = WeakCallback(fn) + 강한 셋에 킵`, + `Subscribe() = WeakSubscribe() + 강한 레지스트리에 킵`. 구현이 한 벌. +- 왜 `WeakRef`가 아니라 이것인가: **사용자 지적** — *"Ref 안에 항상 콜백이 + 쌓인다는것도 문제가 됨."* `WeakRef`는 "`Ref`가 `Effect`를 붙든다"만 풀고 + "`Ref.Callbacks`에 죽은 클로저가 쌓인다"는 못 푼다. `WeakCallback`은 둘 다 + 푼다 — `Effect ↔ cb` 순환이 자기완결이라 Luau GC가 통째로 수거하고, + `Ref` 쪽 항목도 같이 사라진다. +- **`WeakRef`는 만들지 않는다**(사용자: *"안 만들어야겠습니다. 아이디어만 + 기록."*). 아이디어 원문: `WeakRef:Set(v)`/`:Get()`만 주고 + (`.Value`가 아닌 이유는 내부 값이 항상 있다고 확정된 상태가 아니라서), + 내부는 `setmetatable({}, {__mode = "v"})`의 1-슬롯. +- 이건 🅐-3(`H-63`)에서 `Blocker`의 onunblock 핸들에 내린 결정과 **같은 + 패턴**이다 — 강한 주인은 소비자 쪽, 등록처는 weak. + +### `H-58` + `H-64` + `H-70`(중복) — `Ref`를 `Epoch`로 승격 **(확정)** + +**사용자 제안**: *"혹은 Ref 까지도 Epoch 를 구현해줘도 좋다는 생각. +'바뀌였나?' 보는건 source 에 대한 계약이라, 똑같이 실제 값을 가지는 Ref 도 +이를 구현해주는데 문제가 없음."* + +`state-epoch-plan.md` §2가 이미 예상해둔 확장이다 — *"`Source`가 아닌 +원천(외부 시계 등)이 특수 분기 없이 낀다 … `EpochBrand:register(self)` 한 +줄로 끝난다."* + +- `Ref`가 공개 필드 `.Revision`을 갖고 `EpochBrand`에 등록된다. + `:Set()`이 `Source`와 같은 `bit32.bnot(-rev)`로 갱신. +- `.Callbacks`(푸시 경로)는 그대로 — `Epoch`는 부기일 뿐이다. +- 그래서 `EffectHandle._epochs`가 State/Source/`Ref`를 **균일하게** 담고, + 포탈 캐치업이 dep 종류에 따라 갈리던 `H-64`가 **대칭**이 된다. +- `H-70`의 "같은 `Ref` 중복"도 `EpochMap` 키 dedup으로 **공짜로** 닫힌다 + (사용자: *"이미 dedup 해주는 도구는 Epoch 가 있고, 그걸 그대로 적용하는 + 사안으로 공짜로 해결됨"*). + +### `H-58` — 등록 구간 억제는 `Blocker` **(확정)** + +**사용자 지적**: *"해당 맥락의 도구인 Blocker 가 존재함 … 이미 Slot 에서 +사용중임. 모든 옵저버와 callback 등록에 있어서 이를 수행해야할 것임. +**옵져버도 처음에 호출하고, 여러 state 를 넣을 수 있음에 유의할것.**"* + +이 문제는 `Ref` dep 전용이 아니다 — `source-state-plan.md`가 확정한 +*"`fn`은 등록 시점에 즉시 1회 실행된다"* 때문에 **State dep이 여러 개인 +`Effect`도 똑같이 중복 실행**된다. + +- `Effect`가 사적 `Blocker` 하나를 든다. 등록 구간 동안 `On()`, 끝나면 + `OffWithoutEmit()`. 즉시-1회 호출 경로가 전부 `blocker:IsOn()`이면 조기 + 리턴. `materializeSlotTree`가 쓰는 관용구와 같은 모양이다. +- **별도 `_installing` 플래그는 폐기** — 그건 생성자 구간만 덮어 바인드 + 구간을 놓쳤다. + +### `H-64` — 캐치업은 조건부, 그리고 **미설치면 재설치** (확정) + +```lua +if self._cleanup == nil or self._epochs:Refresh() then self:Rerun() end +``` + +**사용자 판단**: *"cleanup 함수가 있냐 없냐를 보긴 해야겠네요. 기본적으로 +cleanup 의 실행은, cleanup 필드를 읽은 다음 그 필드를 제거해버리고 그 다음 +cleanup 을 실행해야할 것으로 보입니다. 그러면 cleanup 클로저가 있냐 없냐로 +클린 해야하는지 알 수 있어요. 그리고 cleanup 클로저를 지우는건, 이 역시 +gc 에 중요한 부분이겠네요."* + +- "바인드 때 항상 실행"이 아니라 **"설치돼 있지 않으면 설치"**다. 그래서 + 아래 `H-65`가 요구하는 재설치와 포탈 캐치업이 같은 한 줄로 닫힌다. + +### `H-57` — retract가 직접 cleanup을 부른다 (확정) + +갈래 (a). `Observer/Effect Leaf dedup`의 retract 클로저에서 +`nextValue ~= v` 분기가 `unbindLifetime(v)` 뒤에 `isEffect(v)`면 cleanup을 +**소진 호출**한다. 소진(필드 읽기 → 필드 제거 → 실행)이 있으므로 파괴 +경로와 이중 호출이 없고, `H-11`이 확정한 *"`unbindLifetime`은 cleanup을 +부르지 않는다"*는 안 건드린다. + +### `H-60` — `Rerun()` 정의: 재진입은 **지연 재실행** (확정) + +**사용자 판단**: *"Effect 의 실행 안에서 뭔가 수행되어 rerun 해야할 상황이 +발생하면, 지연해 두었다 나중에 재실행 하는건 어떤지(실행이 끝나고 나서). +실제로 Effect 안에서 state 등을 바꾸는 상황은 react 등지에서 흔함. 유일한 +문제는 error 발생 시 어떻게 되느냐인데, 그냥 UB로 두는게 맞아보임. 에러가 +난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 동작이라서."* + +```lua +function EffectHandle:Rerun() -- 공개 메소드, 무인자 + if self._running then + self._pending = true -- 실행 중 재진입 → 지연 + return + end + self._running = true + repeat + self._pending = false + local c = self._cleanup + self._cleanup = nil -- 읽고 → 지우고 → 실행 (소진) + if c then c() end + self._cleanup = self.fn(self) + until not self._pending -- 재요청이 또 오면 또 돈다 + self._running = false +end +``` + +- `canExecute` 확인은 **호출부**가 한다(`Ref` 콜백·전파 루프가 이미 그렇게 + 한다). 사용자가 `fn` 안에서 직접 부르는 경로는 게이트하지 않는다. +- **error 시 UB** — 전파되고 복구하지 않는다. 🅒(예외 안전성) 묶음의 원칙과 + 같다. +- 수렴 책임은 사용자 `fn`에 있다 — 무한 루프는 UB. + +### `H-59` — `:Subscribe()`는 셋을 다 한다 (확정) + +(a) `handle.Subscribed = true` + 전역 레지스트리에 **핸들 자신** 등록 +(`_observers`만 등록하는 해석은 폐기 — `Effect(fn)`이 GC돼 cleanup이 +유실된다), (b) 내부 Observer 각각 `:WeakSubscribe()`, (c) `Ref` +`:WeakCallback()`. (b)(c)는 위 구조상 **생성자에서 이미 끝나 있고**, +`:Subscribe()`가 새로 하는 일은 (a)뿐이다. + +### `H-70` — deps 검증 (확정) + +- `nil` dep → **error**. `select("#", ...)`로 순회해 구멍을 실제로 본다. +- 중복 dep → **무시**(error 아님). **사용자 근거**: *":With 이나 시소한 + 연산으로 다른 State 가 된다던가 하면 deps 가 겹쳐도, 근원 source 가 겹쳐도 + 에러를 안 냄. Ref 도 유사한 부분."* — `EpochMap` 키 dedup이 처리한다. +- State/Source/`Ref`가 아닌 값 → **error**. `H-40`이 요소 검증을 + 화이트리스트로 뒤집은 것과 같은 성격. + +### `H-65` — 재바인드는 재설치, 재사용은 **팩토리 패턴** (확정) + +- **재바인드**: 위 `H-64`의 한 줄이 그대로 답이다 — `_cleanup`이 없으면 + `Rerun`으로 재설치한다. 죽음을 표시하는 별도 부기를 만들지 않는다 + (**사용자 지적**: *"파괴 클린업은 결국 inst.Destroying 에 이벤트 바인딩인데 + 이 바인딩도 파괴 이후 자동 삭제된다 … gchold 나 gcconn 도 알아서 잘 풀린 + 상태라, 그냥 가만히 두면 삭제 이후 다시 사용에 있어 다시 실행해줘야한다는 + 것 이외엔 아무 문제가 없어요. 즉시 에러를 내려면 뭔가 다른 행동을 + 해줘야합니다만. 그걸로 얻는게 있느냐? 에 대해서는 의문입니다."*). +- **재사용/다중 인스턴스는 `Effect` 팩토리를 넘기는 패턴으로 안내한다.** + 검토 중에 `Effect:Clone()` / `Effect:Userdata()` / `Effect.Template` + 안이 차례로 나왔으나 **전부 기각**됐다 — **사용자 결론**: *"차라리 Effect + 를 만들어내는 팩토리를 넘기는 패턴을 권장해야할듯 해요 … Clone 도, + Userdata 도, 템플릿도 필요하지 않다."* + + ```lua + local function TimerEffectFactory(data: { timerSource: Source<...> }): Effect + return Effect(function(self) + ... + return function() ... end + end, data.timerSource) -- 주입받은 것을 그대로 deps로도 쓸 수 있다 + end + ``` + + - **왜 이게 더 나은가**(사용자): 초기 1회 실행 문제가 자연히 해결되고, + 템플릿이 실행되어 찌꺼기로 남는 것을 막으려 따로 뭘 할 필요가 없다. + 자식의 계약은 `({...}) -> Effect` 하나이고, 부모가 더 큰 타입을 넘겨도 + **부분 성립**으로 해결된다. 무엇보다 **주입받은 `Source`/`Ref`를 그대로 + `deps`로 넣을 수 있다** — userdata로는 절대 안 되던 것이다 + (*"이건 ud 가 deps 에 대해서는 아무 처리가 못 했던것과 비교해 더 + 간단하면서도, 기능적임"*). + - **이미 있는 관례다** — modifier에서 같은 패턴을 권해왔다. + - 부수로 *"이팩트를 여러곳에 바인딩하면?"*도 자연히 해결된다(매번 새 + 인스턴스). + +### 이 묶음에서 **안 만들기로 한 것** + +`WeakRef`, `Effect:Clone()`, `Effect:Userdata()` / `SetUserdata`· +`GetUserdata`, `Effect.Template`. 전부 검토 후 기각 — 위 각 절이 근거의 +소스다. + +--- + +## 🅓 "긴 연산의 꼬리가 도중 변경을 덮어쓴다" — `H-85` · `H-101` (+ `H-102`) + +### `H-85` — `rawInvalid` 불린을 **캐시 카운터 쌍**으로 교체 (확정) + +원문의 갈래 (a)(`rawInvalid = false`를 `fn` 앞으로)는 **불충분하다** — +**사용자 지적**: *"이러면 대신, 캐시가 언제 생성된 캐시인지 모르는 이슈가 +발생하지 않아? 특히 에러가 난다고 하면, 다시 계산 안하고 이전 결과를 다시 +쓰겠네?"* 그리고 *"state 는 epoch 를 구현해선 안 돼. 중간이지, 초기 값 +컨테이너 계층은 아니거든. 따라서 cache count 를 넣을것을 추천해."* + +```lua +-- 생성 시 +self.cacheTargetCount = 1 +self.cacheCurrCount = 0 -- 달라서 "재계산 필요" + +-- 무효화(§4 규칙 1) — 옛 `rawInvalid = true` 자리 +if valueChanged then + self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount) +end + +-- 재계산 +local gen = self.cacheTargetCount -- fn 직전 스냅샷 +self.cache = self.fn(self, self.cache, ...) +for _, d in self.deps do d:_track(self.valueEpochMap) end +self.cacheCurrCount = gen -- ← 성공했을 때만 + +-- 재계산 판정 — 옛 `rawInvalid == true` 자리 +if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end +``` + +- **재계산 도중 도착한 무효화**는 `cacheTargetCount`를 앞서게 만들어 + 다음 `Get`이 반드시 재계산한다(`H-85` 본체). +- **`fn`이 던지면** `cacheCurrCount`가 안 갱신되므로 계산된 적 없는 캐시를 + 유효하다고 확신하는 일이 없다(갈래 (a)가 못 막던 것). +- 증가는 **`bit32.bnot(-n)` 랩** — §2가 `Source.Revision`에 확정한 그 한 + 줄을 그대로 쓴다. 비교가 `~=`뿐이라 랩이 무해한 것도 똑같고, uint32 안에 + 머무르므로 `+1`이 갖는 2^53 포화 지점 자체가 없다. +- **State는 여전히 `Epoch`를 구현하지 않는다** — 이 카운터는 자기 재계산 + 부기이지 남이 키로 삼는 리비전이 아니다(§4가 State dep에 대해 + `TrackFrom(dep.valueEpochMap)`을 쓰는 것과 일관). + +### `H-101` + `H-102` — 재진입은 Blocker로 막고, 커서는 `invalidAfter`로 되감는다 (확정) + +**먼저 원문의 두 서술을 정정한다.** + +1. *"`:List`의 `updateFn`이 `offset`을 인자로 받는 사용자 코드라 거기서 + 형제 Slot을 조작"* — **틀렸다**. **사용자 정정**: *"updateFn 자체를 + 재실행 하는건 아니거든. offset 을 state 로 넘겨주어서 옵져빙 하게 + 만들잖아."* 남는 트리거는 그보다 좁은 것 — `slot.Offset` State를 + **관측하는** 사용자 코드. +2. *"바깥 루프의 남은 `sum`이 낡는다"* — **대체로 틀렸다**. **사용자 정정**: + *"offset:Set 동작 자체가 무언가를 트리거 한다면, 그게 다 끝난 다음 + 돌아옴 … sum 이 더해지는 시점, 그러니까 offset 설정 이후라면, 이미 + length 는 확정값임."* 실제로 재현된 오작동은 길이가 더럽혀진 게 아니라 + **`bk.N`이 자란 것**이었다(`for i = 1, bk.N`의 상한이 진입 시 한 번만 + 평가됨). + +**확정된 구조**: + +- **Blocker 두 개.** 기존 배치 게이팅용(`drive`/`materializeSlotTree`가 켜는 + 것)과 **재진입 차단용**을 따로 둔다(**사용자 확정**: *"그냥 blocker + 두개 쓰세요."*). 합치면 배치 `Off()`의 onunblock 순회 도중 같은 Blocker가 + 다시 꺼져 핸들이 재귀한다 — `blocker-plan.md`가 네스팅을 의도적으로 + 미지원한 것과 충돌. +- **길이 변경 경로는 스킵으로 끝난다.** 바깥 루프가 `offset:Set(i)` **직후**에 + `contribution(i)`를 읽으므로 그 Set이 유발한 자식 길이 변경은 이미 + 반영된 값이다 — 다시 도는 건 순수 낭비(사용자: *"안 돌아도 되는데"*). +- **⭐ 구조 변경은 되감는다.** **사용자 판단**: *"정말 앞에서 슬롯이 + 당겨졌다고 쳐요. 순차 순회로 처리가 안되는게 … `{a,a,a, b,b, c,c}` + 여기서 a,a 두개가 소멸했는데, 이미 c 에 왔다면, b,b 가 c,c 로 덮여지고 + a,a 는 달라지는게 없을 가능성이 생기죠. 따라서 recompute 도중 변경이 + 생긴다면, 변경이 생긴 곳으로 위로 올라가야할것 같습니다. 따라서, 지금 + 리컴퓨팅 중인 인덱스를 바꿀 방법을 제공하는게 나아보여요. 그러면 bk.N 에 + 대한 문제도 알아서 해결될것 같아보입니다."* +- **되감기 신호는 기존 `bk.invalidAfter` 하나로 통일한다** — 새 필드를 안 + 만든다. 두 뜻("캐시가 여기까지 유효"와 "여기 다음부터 다시 해야 함")이 + 실제로 같은 것이기 때문. **재개 지점은 `invalidAfter + 1`**이다 + (**사용자 지적**: *"length 가 바뀐 쪽에서, 본인의 offset 은 여전히 + 유효하다는거죠."* — `getOffsetAt`의 `for i = bk.invalidAfter, at - 1`과 + 같은 읽기). **splice는 `j`가 아니라 `j - 1`로 낮춘다** — 밀린 자리는 + 자기 offset도 다시 `Set` 해야 하므로. + +```lua +function recompute(bk, ownerKey) + bk.recomputeBlocker:On() + local prefix, i, sum = {}, 1, 0 + while i <= (bk.N or 0) do -- 상한 매 반복 재평가 → N 증가 흡수 + prefix[i] = sum + bk.offsetCache[i] = base + sum + bk.invalidAfter = i -- 여기까지 유효해짐 + bk.offsets[i]:Set(base + sum) -- ← 사용자 코드가 돌 수 있는 자리 + sum += contribution(bk, i) + if bk.invalidAfter < i then -- 누군가 낮췄다 → 되감기 + i = bk.invalidAfter + 1 + sum = prefix[i] + else + i += 1 + end + end + ownerKey.Length:Set(sum) + bk.invalidAfter = bk.N or 0 + bk.recomputeBlocker:OffWithoutEmit() +end +``` + +- **`H-102`가 이걸로 같이 닫힌다.** 원문은 "splice가 observer를 옮겨도 + 클로저에 박힌 position 인덱스는 안 고쳐진다"였는데, **사용자 지적**대로 + *"이미 `slot._elemIndex`: realElem → index 를 관리중"*이므로 + `gatedRecompute`는 인덱스를 **캡처하지 않고 조회**한다. 그 역참조를 + **Dispatch 층위로 격상**해 `bk`가 소유한다(사용자: *"그것을 dispatch 로 + 격상시키는게 더 나아보이는 지점"*) — splice가 배열을 당길 때 같이 + 갱신되므로 `slot-plan.md`의 splice 요구 목록에 항목이 늘지 않는다. +- `bk.invalidAfter = 0`으로 뭉개는 안은 **기각**(사용자: *"0 으로 두면, + 모든 부분에 있어 캐시가 무관해져요"*). +- **참고 — `gatedRecompute`가 왜 게이트 앞에서 `invalidAfter`를 낮추나**: + recompute가 스킵돼도 `getOffsetAt`의 lazy 접두합 캐시는 무효로 표시돼야 + 다음 조회가 다시 계산한다(원문 주석 *"나중 emit도 같은 무효화가 필요"*). + recompute와는 다른 축이다. + +--- + +## 🅕 `Relate` 되참조 — `H-71` · `H-77` + +### `H-71` — dedup 기록을 `SetWeak`으로 낮춘다 (확정) + +갈래 (b). dedup은 순수 성능 최적화라(그 절이 스스로 *"correctness 문제는 +아님"*이라고 못박음) 엔트리가 조기 소실돼도 "dedup을 한 번 놓친다"까지가 +최대 손해다. `v`는 gchold가 이미 강하게 잡고 있고, `relate-plan.md`의 +**"다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`"** 규칙에도 그대로 +맞는다. 대상은 `RefLeafHandler.process`와 +`ObserverEffectLeafHandler.process`. + +**같이 할 것**: `luau-test/done/07`에 되참조 케이스를 **음성 대조군**으로 +추가한다 — 지금 그 파일은 "GC-native 아키텍처의 핵심 전제를 검증했다"고 +여러 문서에 인용되는데 실제로는 안전한 모양만 봤다. + +### `H-77` — `RunInit` 사례는 **기각**, `Relate` 규칙만 명문화 (확정) + +**사용자 정정**: *"그런 경우 자체가 날 수 없음 … `initFn` 은 모듈을 +**인자로 받음**. 그건 클로저 캡쳐가 아님. 게다가 initFn 자체가 항상 하나야. +그래야지 재진입이 방어되거든. 그 의미는, initFn 자체는 프로토에 불과하다는거임. +게다가, 그 안에서 만들어낸 함수들이 module 을 레퍼런싱 해도, 리턴 값이 +quad자신에 flatten 되어 뮤테이션 되어 들어가는 구조 상, 그냥 module.fn +수행하는거랑 다른게 없음."* + +- 원문의 재현 코드(`q:RunInit(function() q.tagB = true end)`)는 **성립하는 + 사용법이 아니다** — 인라인 클로저는 매번 새 identity라 멱등 가드 자체가 + 무의미해진다. 발견이 자기 전제를 깨뜨린 경우. +- `quad-base/src/init.luau`와 `runInitRelate` 설계는 **그대로 둔다**(최상위 + `Relate`, quad 인스턴스가 바깥 키, `initFn`이 내부 키). `:23` 주석도 + 그대로. +- **다만 `Relate`의 슬롯별 강/약 규칙 자체는 문서에 없으므로**, `H-71`이 + 어차피 다시 쓰기로 한 "위험한 패턴" 절에 **세 슬롯 표**를 넣는다: + + | 슬롯 | `SetStrong` | `SetWeak` | + |---|---|---| + | 바깥 키(`inst`) | weak | weak | + | **내부 키(`key`)** | **강함** | **강함** | + | 값(`value`) | 강함 | weak | + + 지금 그 절은 위험을 **값** 기준으로만 서술해 내부 키 슬롯이 아예 + 등장하지 않는다. + +--- + +## 🅖🅗 Store 표면 · 확정 타입 vs 확정 관용구 — 진행 중 + +### ⛔ `H-73` — **무효**. `<>`는 값 호출부에서 동작한다 (실측으로 뒤집힘) + +**사용자 지적**으로 재실측했다 — Luau에는 **generic type instantiation** +문법이 있고(`luau.org/types/generics/#generic-type-instantiation`), 값 +호출부에서도, **콜론 메소드에서도** 동작한다. + +```lua +--!strict +type Source = { Value: T, Revision: number } +type Store = { GetDynamic: (self: Store, name: string) -> Source, [string]: any } +local store = (nil :: any) :: Store + +local ok: Source = store:GetDynamic<>("x") -- ✅ 진단 없음 +local bad: Source = store:GetDynamic<>("y") -- ✅ 정확히 걸림 (진짜 묶임) +local none: Source = store:GetDynamic("z") -- Source +``` + +원문과 검증 패스는 **인스턴스화를 생략한 호출만** 돌려보고 +*"Luau엔 호출부 명시 타입 인자 문법이 없다"*로 단정했다(`ident(1)`이 +비교 연산자로 오파싱되는 건 맞지만 `<<>>`는 다른 문법이다). 확정 표기 +`store:GetDynamic<>(name): Source`는 **그대로 성립한다**. + +**따라서**: (1) `question.md` 최우선 항목은 "타입을 묶을 수 있는가"가 아니라 +**원래의 예약 키 축**으로 되돌아온다. (2) `H-74`(eager `defaults` 경로가 +`__index`를 우회해 예약 키 방어를 무력화)는 **살아 있다**. (3) +`quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다 — 그 +서술도 같이 넓혀야 한다. + +### ⭐⭐ Store — `WrapStore` 폐기, **명시적 초기화**, 타입 함수 안 씀 (확정) + +> **⚠️ 이 항목은 같은 날 두 번 바뀌었다.** 오전에 +> "`store.key`가 값이고 `store:Of(k)`가 프리미티브"라는 재설계를 넣었다가 +> **같은 날 철회**했다(감사 4패스가 `store:Names()`의 런타임 구현 불가를 +> 잡은 게 발단). 철회된 시도의 원문과 이유 다섯은 +> `archive/store-value-field-redesign-withdrawn.md`. 아래는 **최종**이다. + +**발단**: 사용자가 `test.luau`로 직접 타입 실험을 돌려, `WrapStore`/ +`ProcessStoreType`로 결과 타입을 **합성**하는 접근이 `H-75`/`H-76`의 두 +한계에 걸린다는 걸 확인하고 대안을 탐색했다. + +**최종 확정 형태** — 타입 함수를 **안 쓴다**: + +```lua +type Store = T & { + Of: (self: any, name: string) -> Source, -- 동적 키 전용(옛 GetDynamic) + Names: (self: any) -> { string }, +} + +local store = quad.Store<<{ + hp: Source, + name: Source, +}>>({ + hp = Source(100), + name = Source(""), +}) + +store.hp:Get() -- 평범한 레코드 필드 접근 +store.hp:Set(5) +store.hp:Compute(function(s) ... end) -- 콜백 파라미터 추론 살아있음 +store:Of<>("dynamicName") -- 동적 키 +``` + +- **타입 인자에 `Source`를 직접 쓴다** — `store.key`가 평범한 레코드 + 필드라 타입 함수가 하나도 안 들고, 읽기/쓰기 의미론이 `Source`의 기존 + 계약 그대로다. **사용자 지적**: *"왜 우리가 `Source()` 를 직접 넣는걸 + 거부하고, 이렇게 까지 하려 했죠? 단순히, `Store<{ a: Source }>` 로 두고, + `store.a:Set,Get` 하지 말아야할 이유가 있을까요?"* +- **⭐ 명시적 초기화** — 옛 lazy `__index`(없는 키를 그 자리에서 만들어 + 저장) **폐기**. 그래서 `defaults`가 곧 선언 키 집합이 되고 + **`store:Names()`가 성립한다**(`H-79`). 부모가 값을 다 안 넘겨도 되게 + 하려면 컴포넌트가 자기 `DEFAULTS`로 채워 넘긴다. +- **`store.key = value`는 폐기 유지** — 2026-08-06 결정 그대로. + 오전 재설계에서 되살렸다가 같이 철회했다. **사용자 지적**이 그 논거를 + 실측해줬다: *"`.Value = 1` 이 정적 쓰기처럼 보일텐데, 여기서 error + 터지는 trace 가 나오면 당황스럽기도 하고요, 약간 마법적 동작이기도 + 해요."* +- **동적 키는 `store:Of<>(name)` 하나** — 옛 `GetDynamic`을 흡수했다 + (표면 둘을 유지할 이유가 없다). +- **예약 키는 `Of`/`Names` 둘뿐**이고, 충돌하면 타입 검사가 **조용히 + 꺼진다**(실측). `CheckReserved` 타입 함수가 **진단만** 띄운다 — + `error()`가 아니라 `print(...)` + `return types.never`. +- **⭐ 여기서 원칙 하나가 나왔다** — *"타입 함수는 타입이 못 잡는 문제를 + **에러로 띄우는** 정도 이상으로 가지 않는다"*(사용자). `index<>`/`keyof<>`도 + Luau가 predefine한 타입 함수라 같은 함정을 갖는다는 지적이 근거다. + `base/typing-limits.md` §0으로 승격. +- **`Compute`/`Apply`의 반환 타입은 여전히 명시 주석이 필요하다** — Store와 + 무관한 §1의 문제 B이고 어느 모양에서든 조용히 unsound다(실측: 틀린 + 주석이 `store.hp`든 독립 `Source`든 양쪽 다 안 걸림). 살아나는 건 + **콜백 파라미터** 쪽이고, 철회된 `Self` 제네릭을 거치면 그것마저 깨졌다. + +실측 전량은 `audit/type-store-index-keyof/`(측정값은 유효, 결론만 철회됨). + +### ⛔ `H-75` · `H-76` — **무효**. `WrapStore`/`ProcessStoreType`이 폐기된다 + +두 발견은 전부 *"정본 `Source` 선언을 `type function` 안에서 어떻게 +합성하는가"*에 대한 것인데, 위 재설계로 **합성 자체를 안 한다**. 남는 타입 +함수는 `CheckReserved` 하나뿐이고 그건 `T`를 **검증만 하고 그대로 통과** +시키므로 (a) 바깥 별칭을 반환할 일이 없고(`H-76`의 근거), (b) 메소드 self +파라미터 불변성 문제도 없고(`H-76`), (c) ②쪼개기를 `type function` 안에서 +할 일도 없다(`H-75`). `typing-limits.md` §5의 *"✅ 검증 완료"* 서술과 +`store-plan.md`의 `WrapStore` 스케치는 **삭제 대상**이다. + +### ⛔ `H-74` — **무효** (근거는 lazy `__index` 폐기) + +원문은 *"eager `defaults` 경로가 `__index`를 통째로 우회하므로 '고정 메소드 +테이블을 먼저 확인'이라는 예약 키 방어가 성립하지 않는다"*였다. **명시적 +초기화가 확정되며 lazy `__index` 폴백 자체가 없어졌다** — 우회할 방어가 +아예 없으므로 발견이 소멸한다. 지금 예약 키(`Of`/`Names`) 방어는 +`__index`가 아니라 **타입 레벨 `CheckReserved`**가 한다. +**[2026-08-25 문구 정정]** 한때 여기 근거로 "그림자 백킹 테이블"을 들었는데, +그건 같은 날 철회된 재설계의 산물이다 +(`archive/store-value-field-redesign-withdrawn.md`). 판정(무효)은 안 바뀐다. + +### `H-83` — `table.clone(defaults or {})` (확정) + +한 글자. **명시적 초기화와 모순되지 않는다** — 선언된 키가 하나도 없는 +`Store<<{}>>()`는 여전히 유효하고(줄 키가 없으니 넘길 것도 없다), 그때 +`table.clone(nil)`이 `table expected, got nil`로 죽는 걸 막는다. +"선언한 키는 값을 준다"와 "키가 0개면 인자도 없다"는 서로 다른 층위다. + +### `H-79` — `store:Names()` 신설 (확정) + +갈래 (c). `Tag:Names()`/`attr:NameMap()`과 같은 계열이고, 동적 키 표면 +(`store:Of<>(name)`)과 **같은 자리**에 둔다(사용자: *"이것도 GetDynamic +처럼 둡니다"* — 그 `GetDynamic`이 지금의 `Of`다). 그룹 `Attribute`가 +요구하던 열거가 이걸로 닫힌다. + +**시그니처는 `Names: (self: any) -> { string }`이다.** **[2026-08-25 문구 +정정]** 한때 여기 `{ keyof> }`라 적었는데 그건 같은 날 +철회된 재설계의 타입이다. **런타임 성립 근거도 바뀌었다** — 팬텀 필드가 +아니라 **명시적 초기화**다: `defaults`가 곧 선언 키 집합이므로 그림자 +테이블의 키를 그대로 준다. (감사 4패스가 잡은 것 — 철회 전 모양에선 +Luau가 타입 인자를 런타임에 지워 `Names()`가 **구현 불가능**했다.) + +### `H-95` — 두 콜백 시그니처를 실측 통과 형태로 고친다 (확정) + +갈래 (a). + +- **`Effect`의 `fn`** — 가변 반환 팩 `-> ...(() -> ())`. 단일 옵셔널 + 반환에 맞고, `function(self) end`와 cleanup 반환이 둘 다 통과한다. +- **`:List`의 `updateFn`** — 함수 타입의 유니온 `Fn2 | Fn1 | Fn0`. 네 모양 + (2개/1개/nil/없음)이 전부 통과하고 **엉뚱한 타입은 여전히 잡힌다**(음성 + 대조군 확인). + +"항상 명시적으로 반환하라"를 계약으로 두는 안은 기각 — `useEffect` 동형이라는 +확정 서술과 인체공학이 어긋나고, `--!nocheck` 코드에선 조용히 지나간다. + +### `H-94` — `__call`이 아니라 **지정된 필드**로 받는다 (확정, 필드 이름 미정) + +**사용자 판단**: *"그런데 이러면, Blocker 자체도 처음에 슈거로 두지 못했었던 +이유가 해결됩니다. 아에 어플리케이티브 펑터로써, `__call` 이 아닌 다른 +필드로 들어가는게 맞아보여요. 외부에서 직접 `()` 호출하는건 의미 없게 +둬야해요."* + +- 원문의 갈래 (a)(함수와 콜러블의 유니온)는 **기각** — 유니온도 캐스트도 + 필요 없어진다. +- `:Apply`는 "함수" 또는 "그 필드를 가진 객체"를 받고, `Debounce{...}` / + `Throttle{...}` / `Blocker`가 전부 같은 계약을 만족한다. +- **실측 뒷받침**: `test.luau`가 `__call` 경로가 죽었음을 확인했다 — 타입 + 레벨 `__call`은 `self`를 못 받고(`mock()`이 인자만 남는다), + `typeof(getter2<>)`로 `T`를 넘기는 것도 실패한다. +- **남은 것**: 그 필드의 이름과 정확한 시그니처(구현 시 정하면 됨). + +--- + +## 🅒 예외 안전성 — `H-88` · `H-89` · `H-87` · `H-103` + +### 네 자리 전부 **UB로 명문화**, `pcall`로 감싸지 않는다 (확정) + +2026-08-21에 `slot-plan.md`의 `materializeSlotTree`에 대해 내린 판단 +(*"마운트 도중 예외는 quad가 복구를 보장하지 않는 상태이고 … 아직 실제로 +밟은 적 없는 경로다 — `conventions.md`의 "드문 오용이나 가상의 미래 +요구까지" 절이 세운 원칙 그대로. 실제로 물리면 그때 넣는다"*)를 **전 +자리로 확장**한다. 🅔의 `Rerun`에 대해 같은 원칙을 이미 확정했다 +(*"에러가 난 이후 데이터의 무결이 깨져도 별 책임 안 진다는 quad의 일반 +동작"*). + +- 전파 루프(`H-88`, 구독자당 hot path), 게이트 flush + `Blocker:Off()` + 순회(`H-89`), 배치 게이팅(`H-87`), Dispatch 체인 슬롯(`H-103`, 자리당 + hot path) — 넷 다 감싸지 않는다. +- **문서에 없던 것을 적는 게 이 항목의 산출물**이다 — 지금 + `state-epoch-plan.md`/`gate-plan.md`/`blocker-plan.md` 셋엔 `error`라는 + 단어가 **0건**이다. yield 금지와 같은 톤으로 "예외가 나면 그 파동/그 + 자리의 부기는 복구되지 않는다"를 명시한다. +- **`H-87`의 심각도 정정**: 🔴 → 🟡. 원문의 *"코퍼스가 error 경로를 어느 + 쪽도 다루지 않는다"*는 거짓이었다(위 선례가 있다). 새 결함 보고가 아니라 + **기존 판단의 적용 범위** 문제였다. +- **`H-88`의 부정 주장 정정**: *"예외 안전성 계약이 코퍼스에 한 줄도 + 없다"*는 과했다 — 위 선례가 유일한 선례이고, 그 선례가 고른 방향이 바로 + 지금 채택한 쪽이다. + +### `H-89`의 나머지 — `:Sync(batch)`는 **전파 전** (확정, 물을 것 없음) + +`gate-plan.md` 4번이 *"실제로 전파할 때"*라고만 하고 앞/뒤를 안 정했는데, +참조 구현의 `GateNode:_flush`가 이미 **빈 배치 얼리리턴 → 스왑 → `Sync` → +전파** 순서이고 그게 4·8번과 일치한다. 그 순서를 문서에 명시한다. + +--- + +## 🅙 error 계약 — `H-104` · `H-105` + +**⚠️ 두 항목의 수치를 먼저 고칠 것** — 원문의 "42곳"은 산문 언급까지 센 +것이다. 실제 quad 자신의 error 코드 자리는 **약 29곳**, 한국어는 17이 +아니라 **약 23**, 영어는 6(동적 경로 가드 4형제 + 모듈 초기화 + attribute +이름 충돌)이 맞다. + +### `H-104` — 사용자 입력 검증은 `level 2`, 내부 불변식은 `level 1` (확정) + +```lua +error("Effect: dep #3 is not a State/Source/Ref", 2) -- 호출부를 가리킴 +error("Dispatch: bookkeeping is broken (bk.N=" .. n .. ")", 1) -- 그 자리를 가리킴 +``` + +*즉시 error*가 quad의 주 방어선인데 지금은 전부 quad 내부 줄을 가리켜 +사용자가 자기 코드 어디서 틀렸는지를 못 본다. 이분은 명확하다 — 사용자가 +잘못 넘긴 것(deps 타입, 이중 바인드, 예약 키…)은 호출부를, quad 자신의 +부기가 깨진 것은 그 자리를 가리킨다. **29곳을 쓰기 전에 정해야 한 번에 +맞는다.** + +### `H-105` — error 메시지는 **영어로 통일** (확정) + +**사용자 확정**. `conventions.md`의 *"사용자가 보게 될 것은 한국어"*는 +**이 프로젝트의 대화·문서** 규칙이고 quad 라이브러리 사용자에게까지 +적용된다고 정해진 적이 없다 — 이번에 정한다. 이미 영어인 6곳(동적 경로 +가드 4형제 등)이 핵심 경로라는 점도 같은 방향이다. 기존 한국어 메시지 +약 23곳은 구현 시 영어로 쓴다(문서의 예시 메시지도 같이). + +--- + +## 그 밖의 개별 확정 + +### `H-68` — `Source:Set(v)`는 **동일값이어도 항상 갱신하고 emit** (확정) + +판정이 값 동등성이 아니라 리비전이라는 `Epoch` 모델과 일관된다. 더 +중요한 건 **테이블 값**이다 — mutate한 뒤 같은 테이블을 다시 `Set`하는 +것이 `==`로 dedup되면 변경이 조용히 증발한다. dedup은 이미 하류 +(`EpochMap`, 게이트)에서 한다. + +--- + +## 🅚 ⭐ "중간 State GC" 미해결이 **닫혔다** — `H-93` · `H-98` + +**사용자 확정**: *"단순히, 각 state 들이 상위 State|Source 를 홀드하는 +`_hold` 를 놓는것으로 바로 해결된다. 당연히 후행은 선행 요소들이 +있어야하기 때문. 선행 state 가 후행 state 를 얻으려 하는건 UB이므로 가능한 +일이다.(이건 릴레이션도 아니라 gc되긴 하지만.)"* + +불변식이 이걸로 명문화된다: + +| 방향 | 강도 | +|---|---| +| 하류 State → 상류 State/Source (`_hold`) | **강함** | +| 상류 → 하류 (구독자 집합) | weak-키 (🅑-1에서 확정) | + +- 체인은 **말단(Observer/Effect/leaf)이 살아 있는 동안** 통째로 살아 있고, + 말단이 죽으면 통째로 수거된다. `Relate`가 아니므로 순환도 안 만든다. +- **`H-93` 소멸** — 루트 `Epoch`(Source)가 하류보다 먼저 수거될 수 없으므로 + *"`:Refresh()`가 `false`를 줘 낡은 값을 최신이라고 확신한다"*는 경로가 + 생기지 않는다. +- **`H-98` 소멸** — `source-state-plan.md`의 두 문장(*"참조를 아무 데도 안 + 담아도 정상"*, *"GC되지 않고 영원히 계속 실행됨"*)이 서로 모순 없이 + 성립한다. `:Subscribe()`가 전역 강 레지스트리에 핸들을 넣고, 핸들이 + `_hold`로 상류를 잡는다. +- **🅑-2(eager 등록)의 캐비엇도 해소** — 그 절에 적어둔 *"eager이므로 중간 + State GC 미해결이 더 절실해진다"*는 이제 해당 없음. +- **`question.md` 최우선 절의 첫 항목이 닫힌다**(실측 스파이크는 여전히 + `luau-test`에 하나 두는 게 좋다 — 그 파일은 "상류 strong / 하류 weak + 불변식"을 음성 대조군까지 확인하는 형태로). + +--- + +## 🅛 저장소·로드맵 — `H-78` · `H-97` · `H-80` · `H-81` · `H-99` + +### ⭐ `H-78` — 근본 원인은 **`luau` CLI가 심볼릭 링크를 못 탄다**는 것 (확정) + +원문은 *"안 돈다"*까지만 짚었는데, 이번에 최소 재현으로 원인을 특정했다: + +``` +require("./real") → OK (진짜 디렉토리 + init.luau) +require("./linked") → 실패 (같은 디렉토리를 가리키는 심볼릭 링크) + could not resolve child component "linked" +진짜 디렉토리 + init.luau 파일 심볼릭 → 실패 (no module present at resolved path) +파일 심볼릭 직접 require → 실패 +``` + +pesde의 워크스페이스 링크가 전부 디렉토리 심볼릭 +(`.pesde/…/quad_types/src -> quad-types/src`)이라, `quad-base/src/init.luau`의 +`require("./roblox_packages/quad_types")`가 그 링크에 닿는 순간 죽는다. +`luau` 0.735(현재 최신)에 관련 옵션도 없다. + +**거짓 클린도 재현했다** — 같은 경로를 `luau-analyze`에 걸면 진단이 lint +둘뿐이고 `local x: number = m.ok`(실제 `boolean`)를 **안 잡는다**. 모듈을 +`any`로 떨어뜨리고 조용히 통과한다. + +**해법(확정)**: **개발용 리링크 스크립트** — `.pesde` 아래 디렉토리 +심볼릭을 실제 복사로 교체하고, 테스트/분석 스크립트가 그걸 먼저 돌린다. +pesde 설정도 게시 경로도 안 건드린다. 워크스페이스 멤버를 고치면 다시 +돌려야 하므로 테스트 스크립트가 매번 선행 실행한다. + +**같이 고칠 것**: `ROADMAP.md:198`의 *"전부 PASS"* — 날짜도 전제조건도 없다. +지금 실제로는 `smoke.mock`만 PASS이고 `smoke.init`/`smoke.plugin`은 리링크 +없이는 안 돈다. 스파이크 `23`도 같은 이유로 의도한 음성 대조군이 안 뜬다 +(`luau-test/STATUS.md`에 반영). + +### `H-97` — mock에 생명주기 4종을 M2에서 최소 구현 (확정) + +`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`를 mock 백엔드용으로 +가장 단순하게 구현한다(커밋된 mock에 signal/Connection이 이미 있다). +M2의 핵심이 전파 루프인데 그걸 한 번도 안 돌려보고 M3로 넘어가는 것이 +이번 라운드가 찾은 종류의 결함을 그대로 낳는다. + +### `H-80` · `H-81` · `H-99` — 체크박스·소스 트리 갱신 (결정 불필요) + +- `H-80`: `ROADMAP.md` M2의 `Quad` 추가 목록이 `Source`/`State`/`Store`뿐 — + `Effect`/`is*` 전량/`bindLifetime` 4종/`Blocker`/`Relate`를 더한다. + `State`는 런타임 생성자가 없다는 것도 명시(파생으로만 생긴다). +- `H-81`: `isModifier` 런타임 가드 체크박스가 M7에만 있다 — 적용 지점이 + 전부 M2 파일(`Source:Set`/Store 생성/`:Compute` 캐싱)이므로 M2 + 체크리스트에 넣고, `modifier-plan.md` 7번과 `source-state-plan.md`의 + 적용 지점 목록을 일치시킨다(독립 `Source(someModifier)`가 한쪽에만 있다). +- `H-99`: `architecture.md` 소스 트리에 `Observer.luau` 자리와 + `:Subscribe()` 전역 레지스트리의 소유 모듈을 명시(`EpochMap.luau`가 + *"`State.luau`에 묻지 않고 별도 모듈"*로 명시된 것과 대비를 맞춘다). + +--- + +## 🅜 문서 정합 — 결정 불필요 (`H-66` · `H-67` · `H-82` · `H-91`) + +- `H-66`: `typing-limits.md` 영향 범위 표의 `state:Observer(fn)` 행을 + `EffectHandle 반환` → **`Observer` 반환**. +- `H-67`: `gate-plan.md` 4번이 `OffWithoutEmit` 비우기의 근거로 드는 용례가 + gated state를 안 쓴다 — gated state를 쓰는 용례로 교체. +- `H-82`: `:With`를 실노드로 확정한 근거 2번("`w`의 캐시를 c1/c2가 공유")이 + pass-through 노드엔 성립하지 않는다 — **"엣지 수와 에포크 부기"**로 교체. + 결론은 안 바뀐다(근거 1·3이 유효). +- `H-91`: `state-epoch-plan.md` §8의 *"항상 state 는 get 이 최신"*을 + **"선언한 의존성에 대해서는 항상 최신"**으로 좁히고, `Animate`가 미선언 + 읽기를 **설계로** 쓰는 의도적 예외임을 가리킨다. + +--- + +## 나머지 개별 — 구현 시 정하면 되는 것 + +- **`H-69`** 통과 모드 게이트가 emit마다 weak 테이블을 하나씩 할당 — + 정확성 문제 아님. 전파 루프를 코드로 쓸 때(🅑) 같은 자리에서 정한다. +- **`H-92`** 구독자 스냅샷이 emit마다·노드마다 배열 하나를 할당 — + `state-epoch-plan.md` §2가 테이블 리비전을 기각한 GC 근거, §7 비용 절과 + 어긋난다. 정확성 문제 아님, **서술만 실제와 맞춘다**. +- **`H-84`** `:With(...)`/`state:Block(b)`/`Source:Emit()`이 `ROADMAP.md` M2 + 체크리스트에 개별 항목으로 없다 — 추가. +- **`H-96`** trailing deps가 붙으면 콜백 파라미터 무주석 추론이 깨진다 — + 결정이 아니라 **`typing-limits.md` §7에 경계 한 줄**(*"단 trailing deps가 + 붙으면 dep 파라미터엔 주석이 필요하다"*). +- **`H-100`** `{[Source]: true}`가 `{[Epoch]: true}` 자리에 안 들어간다 + (인덱서 키 불변) — `EpochSet` 리터럴에 캐스트가 필요하다는 것만 적는다. +- **`H-106`** `getOffsetAt`의 `contribution(bk, i)`에 `nil` 가드가 없어 + `C-6`이 승격한 진단이 우회된다 — 한 줄 가드 추가. + +--- + +## 반영 후 검증 — `/code-review high` 1회 + 감사 다패스 (2026-08-25) + +`base/` 반영이 끝난 뒤 **각도를 바꿔가며** 검증을 돌렸다. 사용자 지침: +*"여러 각으로 넣으면 다양한 문제들이 해결되거든"*, *"수렴 되기 까지 여러 +각으로 한번씩 넣어봐줘. 그게 stale 부채를 막는 길이라서."* + +**각 패스가 낸 발견이 서로 거의 안 겹쳤다** — 이게 이 절을 남기는 이유다. +다음에 큰 반영을 할 때도 한 각도로 여러 번이 아니라 **각도를 바꿔가며** +돌릴 것. + +| 패스 | 각도 | 발견 | 대표적으로 잡은 것 | +|---|---|---|---| +| `/code-review high` | diff 자체의 결함(사용자만 호출 가능) | 12 | 캐시 카운터 시드 충돌(갱신이 **감소**라 첫 무효화에서 두 값이 같아짐), `_cleanup == nil`로 "설치됨"을 판정해 `H-58` 부활, 되감기 `+1`이 바뀐 길이를 `sum`에서 누락, `bk.indexOf(len)`가 길이 값을 키로 써서 두 자리가 접힘, `relink.sh` 재실행 no-op | +| 감사 1차 | diff 정합성 — 폐기된 이름·인덱스 레이어·색인 행 | 12 | **`ROADMAP.md` M2 전파 루프 체크리스트가 `H-56`이 뒤집은 것을 그대로 들고 있었다**(구현자가 보는 자리), `CLAUDE.md`/`project-context.md`가 닫힌 게이트를 열린 것으로 서술(매 세션 로드됨) | +| 감사 2차 | **새 의사코드를 손으로 실행** | 6 | `lifecycle-pattern.md`의 `bindLifetime`이 폐기된 `_observers` cascade를 **정본 의사코드로** 들고 있었다, `ref-plan.md`가 같은 파일 안에서 `:Uncallback`과 `:WeakCallback` 두 계약을 동시 주장, gate flush 스니펫이 4단계 중 2단계만 | +| 감사 3차 | **소비자 관점**(M2 표면을 쓰는 쪽) | 6 | **Store 재설계가 소비자 예시 12개 파일에 전혀 안 퍼짐** — 특히 `component-composition-plan.md` §3이 배너 없이 옛 모델을 "확정"으로 서술 | +| 감사 4차 | **구현자 관점**(M2를 위에서부터 짜 내려간다면) | 4 | **`store:Names()`가 런타임에 구현 불가능**(Luau가 타입 인자를 지움) — 이게 오전 Store 재설계를 **철회**하게 만든 발단이다. 그 외 `Effect`↔`Blocker` 체크박스 순서, `Observer.luau` 파일명 누락, `GateNode` 조립 의사코드 부재 | +| 감사 5차 | **철회가 완전한가** | 13 | **`architecture.md`를 철회에서 통째로 놓쳤다**(두 자리, 그중 하나는 같은 파일 안에서 모순), `source-state-plan.md`가 한 파일 안에서 대입 부활/폐기를 동시 주장, `GetDynamic`→`Of` 개명이 5곳 누락(**`ROADMAP` 체크박스 제목 포함**), `pre-implementation-audit.md`의 **"이미 고침" 목록**이 철회된 고침을 완료로 기록 | +| 감사 6차 | **최종형 수렴 + `H-74`/`H-79`/`H-83` 재판정** | 3 | `effect-plan.md`가 **같은 문서 안에서** 폐기된 `_installing`을 "확정"으로 서술, `README.md`의 `effect-plan` 색인 행이 하루 전 상태, followup의 `H-74`/`H-79` 판정 문구가 철회된 모델을 근거로 인용. **`base/`는 전부 최종형으로 일관** — 잔재가 인덱스·기록 레이어에만 남았다 | + +**교훈 둘** — (1) **각도를 바꾸면 계속 나온다.** 여섯 패스가 12/12/6/6/4/13/3건을 +냈고 겹치는 게 거의 없었다. 한 각도로 여러 번 돌리는 것보다 각도를 바꾸는 +쪽이 압도적으로 낫다. (2) **하루 안에 두 번 바뀐 것은 인덱스·기록 레이어에 +잔재가 남는다.** 6차에서 `base/`는 이미 깨끗했는데 `README.md` 색인 행과 +followup 판정 문구가 중간형을 들고 있었다 — "결정을 적는 곳"과 "결정을 +가리키는 곳"이 다르기 때문이다. + +**교훈 셋** — 같은 결정을 **여러 문서가 각기 다른 역할로** 들고 있으면 +한 곳만 고치기 쉽다. `H-56`(전파 루프)은 `lifecycle-pattern.md`(산문) / +`source-state-plan.md`(의사코드) / `ROADMAP.md`(구현 체크리스트) 셋에 +흩어져 있었고, 첫 반영은 앞의 둘만 고쳤다 — **구현자가 실제로 보는 건 +세 번째**다. diff --git a/.claude/qa-request/pre-implementation-handtrace-round7.md b/.claude/qa-request/pre-implementation-handtrace-round7.md index 020d74b..4637994 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round7.md +++ b/.claude/qa-request/pre-implementation-handtrace-round7.md @@ -258,7 +258,7 @@ Observer 값을 키로 `BindData`에 gcconn을 복사하므로, 집합의 원소 `H-23`이 스냅샷을 확정했지만 그 스냅샷 안에서 **무엇을 호출하는지**는 여전히 산문뿐이다. -**이건 `question.md`의 "중간 State GC" 미해결과 별개다** — 그쪽은 자식 +**이건 `question.md` 최우선 절의 중간 State GC 미해결과 별개다** — 그쪽은 자식 노드가 *살아남는가*, 이쪽은 살아있어도 *호출되는가*. **필요한 것**: 전파 루프의 실제 의사코드 — 구독자가 State 노드면 @@ -909,7 +909,7 @@ store 테이블이 아니라 **내부 백킹 테이블**에 넣고 `__index`가 ## 🔴 `H-75` — 평평한 `WrapStore`면 `store.key:Compute(무주석 콜백)`이 깨진다 (실측) **어디**: `base/store-plan.md`의 `WrapStore`/`ProcessStoreType` 스케치, -`luau-test/done/16-type-store-key-typefunction.luau`, +`luau-test/rewrite-required/16-type-store-key-typefunction.luau`, `base/typing-limits.md` §5(*"✅ 검증 완료"*)와 §1의 ②쪼개기. **무엇이 어긋나나**: 확정된 `WrapStore`는 `Get`/`Set`만 있는 **평평한** @@ -2094,8 +2094,8 @@ GC 근거를 그에 맞게 완화한다(그러면 테이블 리비전 기각 근 ## 🟢 `H-93` — 중간 State GC 미해결은 "전파 끊김"만이 아니라 "최신이라고 오판"으로도 나타난다 **어디**: `base/state-epoch-plan.md` §3의 *"키는 weak다. `epoch`가 죽으면 -항목이 사라진다"*, `base/source-state-plan.md`의 "미해결 — 중간 State가 -살아남는가" 절. +항목이 사라진다"*, `base/source-state-plan.md`의 "해소됨 — 중간 State는 `_hold`로 +살아남는다" 절. **미리 밝힘**: 이건 `question.md` 최우선 항목의 **파생**이라 이 라운드의 검사 대상이 아니다(1차 패스가 명시적으로 뺐다). 그래도 적는 이유는 그 @@ -2436,7 +2436,7 @@ Connection까지 이미 구현해뒀다. 즉 gcconn 트릭을 흉내낼 재료 **어디**: `base/source-state-plan.md`의 "Observer의 `:Subscribe()`/`:Unsubscribe()`" 절, 같은 문서의 -"미해결 — 중간 State가 살아남는가" 절. +"해소됨 — 중간 State는 `_hold`로 살아남는다" 절. **무엇이 어긋나나**: `:Subscribe()` 절은 두 가지를 **공개 계약으로** 못박았다 — *"`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 diff --git a/.claude/qa-request/pre-implementation-qa-round1.md b/.claude/qa-request/pre-implementation-qa-round1.md index d996b40..755e55f 100644 --- a/.claude/qa-request/pre-implementation-qa-round1.md +++ b/.claude/qa-request/pre-implementation-qa-round1.md @@ -238,7 +238,7 @@ D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2 오버로드 함수 타입으로 정확히 추론하려는 시도는 포기하고…" - `base/store-plan.md:128` — "`store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source`) 폴백으로 격하." - - `base/store-plan.md` "남는 것" 문단 — "`myStore "key"`(문자열 커링)는 … + - `base/store-plan.md`의 값 설정 문법 절 — "`myStore "key"`(문자열 커링)는 … 그대로 유지" - `base/bind-system-plan.md:157` — "`store "key"`(문자열 커링, 동적 키 폴백)는 이벤트와 달리 …" diff --git a/.claude/qa-request/pre-implementation-qa-round4-followup.md b/.claude/qa-request/pre-implementation-qa-round4-followup.md index 6100433..7557960 100644 --- a/.claude/qa-request/pre-implementation-qa-round4-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round4-followup.md @@ -658,7 +658,7 @@ function Dispatch.process(inst, k, v, index) -- ← 오케스트레이터 |---|---| | `ML-5` | 사용자가 회신에서 직접 짚음(*"새로운 커밋에서 이것이 달라짐"*). 파일마다 `Relate()`+`INITED` 센티널을 두는 설계가 **`RunInit` 하나로 통합**됐다 — **함수 자기 자신을 릴레이션 키로** 쓰므로 센티널이 불필요. `quad-base/src/init.luau`에 실제 구현 + `test/smoke.init.luau`로 검증까지 끝. **문항 `ML-5`는 폐기.** | | `ML-8` | 위 변경에 맞춰 `RunInit`(함수 identity) vs `_initializedBy`(문자열 마커) 구분으로 재서술됨. **결론은 그대로**(둘은 다른 층위, 재사용 안 함)이라 문항 자체는 여전히 유효. | -| `ST-3` | "M0에서 실측 확인할 것"이 **실측 완료**됨 — `luau-test/done/21-type-store-undeclared-key-rejected.luau`가 미선언 키 접근 2건이 정확히 `TypeError`로 거부됨을 확인. **⚠️ 열린 항목이 아니게 됨.** | +| `ST-3` | "M0에서 실측 확인할 것"이 **실측 완료**됨 — `luau-test/rewrite-required/21-type-store-undeclared-key-rejected.luau`가 미선언 키 접근 2건이 정확히 `TypeError`로 거부됨을 확인. **⚠️ 열린 항목이 아니게 됨.** | | `D-10` | 위 B-2 — 스파이크 `01`이 이미 통과. | | `A-2` | 패키징이 **wally → pesde**로 바뀌었고 `mise`/`selene`가 도입됨. 문항의 "wally 패키지" 표현이 stale(모놀리식 모노레포라는 **결론 자체는 유지**). | | 전반 | `M0`/`M1`이 **완료**됐다 — 문항지 여기저기의 "M0 착수 전/M0에서 확인" 표현은 이제 "M2 착수 전"으로 읽어야 맞는 것들이 섞여 있다. | diff --git a/.claude/question.md b/.claude/question.md index b300a88..1a95f6f 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -12,35 +12,30 @@ --- -## ⭐ 최우선 — **M2(반응형 코어) 착수 전에 답이 필요한 것 둘** (2026-08-24 갱신) +## ⭐ 최우선 — **비어 있음** (2026-08-25 갱신) -> **[2026-08-24] M2/M3 마일스톤 경계 문제는 닫혔습니다.** 그건 설계 결정이 -> 아니라 순서 문제라 이 절이 아니라 별도 항목으로 뒀었는데, 사용자가 -> **(a) 순서 교체**(반응형이 M2, 디스패치가 M3)를 선택해 같은 날 전량 -> 반영됐습니다 — 결정과 근거는 `archive/question-resolved.md`의 -> "마일스톤 경계" 절, 새 마일스톤 구성은 `ROADMAP.md`의 M2 배너. +> **[2026-08-25] M2(반응형 코어) 착수를 막는 항목이 하나도 없습니다.** +> 2026-08-24에 이 절로 올라왔던 둘이 7라운드 손 트레이싱 후속에서 같이 +> 닫혔습니다 — 결정과 근거는 +> `qa-request/pre-implementation-handtrace-round7-followup.md`가 소스. > -> **⚠️ 다만 그 교체의 부작용으로 아래 둘이 "한 마일스톤 뒤"에서 -> "바로 다음"으로 올라왔습니다.** 둘 다 반응형(옛 M3, 지금 M2)의 게이트라 -> 예전엔 급하지 않았는데, 반응형이 먼저 지어지게 되면서 **지금 답이 -> 필요합니다.** 순수한 *설계* 결정 대기는 여전히 0건입니다 — 하나는 실측 -> 미완이고 하나는 표면 위치 선택입니다. - -- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State → - State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 - 없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가 - 지목했고, **명문화 여부 결정 + `luau-test` 실측이 M2 착수 전에 필요** — - `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절. -- **[신설, 2026-08-18 커밋 전 `/code-review high`] `store:GetDynamic`을 - 콜론 메소드로 둘지, 탑레벨 함수로 둘지** — 콜론 메소드로 두면 Store의 - lazy `__index`(없는 키를 인덱싱하면 그 자리에서 `Source`를 만들어 저장)와 - 부딪혀서, `__index`가 고정 메소드 테이블을 먼저 확인해야 하고 그 결과 - **`GetDynamic`이 모든 Store의 예약 키 이름**이 된다(그 이름의 Source는 - dot-access로 못 만듦). Store 키는 사용자 도메인 데이터 이름이라 충돌 - 확률이 `Modifier`의 예약 이름들보다 높다. 대안은 탑레벨 - `getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자 - 탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M2/M4 착수 전 - 필요**, `base/store-plan.md`의 "타입 추론 문제" 절. +> - **중간 State GC 미검증** → **닫힘.** 사용자 확정: *"단순히, 각 state +> 들이 상위 State|Source 를 홀드하는 `_hold` 를 놓는것으로 바로 +> 해결된다. 당연히 후행은 선행 요소들이 있어야하기 때문."* 불변식은 +> **하류 → 상류 강함(`_hold`) / 상류 → 하류 weak(구독자 집합)**이고 +> `base/source-state-plan.md`에 명문화됐습니다. 실측 스파이크는 여전히 +> `luau-test`에 하나 두는 게 좋지만 **착수 게이트는 아닙니다**. +> - **동적 키 표면(옛 `GetDynamic`) 위치** → **닫힘.** `store:Of<>(name)` +> 하나로 합쳤고(콜론 메소드 유지), +> 예약 키 충돌은 `CheckReserved` 타입 함수가 잡습니다. 애초에 이 항목이 +> 섰던 근거(lazy `__index`와의 충돌)는 **lazy 생성 자체가 폐기**되며 +> 소멸했습니다(명시적 초기화). 이름도 `store:Of<>(name)` 하나로 +> 합쳐졌습니다 — `base/store-plan.md`의 "타입 추론 문제" 절. +> +> **[2026-08-24] M2/M3 마일스톤 경계 문제도 닫혀 있습니다** — 사용자가 +> **(a) 순서 교체**(반응형이 M2, 디스패치가 M3)를 선택해 전량 반영됐습니다. +> 결정과 근거는 `archive/question-resolved.md`의 "마일스톤 경계" 절, 새 +> 마일스톤 구성은 `ROADMAP.md`의 M2 배너. > **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의 > lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와 diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index f441985..eb6a32f 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -38,7 +38,7 @@ 1. **초기화** — `RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`) 2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 생성자 `New` + 클래스별 정적 필드(**[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 전량 코드 생성)(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`) 3. **속성 채우기** — `[Attribute "Name"]`, ~~`[Tag ""] = true`~~ **[2026-08-13 정정] 구모델(폐기, `archive/tag-hash-key-model-reversed.md`) — 실제로는 `Tag(...)` array-part 값 객체** 특수 바인드 키 (`architecture.md`) -4. **반응형 기초** — `Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`base/source-state-plan.md`, `base/store-plan.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 바뀜) +4. **반응형 기초** — `Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`base/source-state-plan.md`, `base/store-plan.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 확정. **[2026-08-25]** 같은 모델이 유지되고, **생성이 명시적 초기화로** 바뀌었다 — 타입 인자에 `Source`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접. **초심자 트랙이 짚을 것**: 부모가 값을 다 안 넘겨도 되게 하려면 컴포넌트가 자기 `DEFAULTS`로 채워 넘긴다) 5. **스타일링** — Modifier 기본 체이닝(`:FontSize(14)`), 배열/인라인 merge 우선순위 규칙 (`modifier-plan.md`) 6. **자식 전달** — Slot 기본 개념(children 배열, add/remove/clear), 마운트된 slot 재마운트 시 throw (`slot-plan.md`) 7. **컴포넌트 작성** — 컴포넌트 = 순수 함수, 리프 프로퍼티엔 State만 바인딩, 전역 store 직접 참조 금지(이식성) (`component-composition-plan.md`, `purity-and-effects-plan.md`) diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 6b168ea..db8550a 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -306,7 +306,7 @@ base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전. 이름이 아니라 구조로 일치를 검사하므로 문제없음. 기술적으로 막힐 위험이 없어졌으니 M0/M2 어느 시점에 검증해도 무방 — `ROADMAP.md` 배치를 억지로 안 옮겨도 됨. 상세는 `base/typing-limits.md` "`store.key` 레코드 필드 -타이핑" 절, 실제 문법 실측은 `luau-test/done/16-type-store-key- +타이핑" 절, 실제 문법 실측은 `luau-test/rewrite-required/16-type-store-key- typefunction.luau`(**[2026-08-15] 통과** — 원래 스파이크가 API 버전 드리프트로 깨져있던 걸 고침, `audit/type-recursive-issue-with-typeof/ REPORT.md` 6-1절). 아래는 원래 발견 당시 기록. @@ -674,7 +674,14 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 - `architecture.md`의 "Store/State/Source 온톨로지 — 확정됨 (요약)" 절이 `source-state-plan.md`의 최신 재구성(Source가 State를 만족, `store.key`가 - Source를 직접 반환, `store.key:Set()`)을 못 따라가고 있던 것 — 이 감사 + Source를 직접 반환, `store.key:Set()`)을 못 따라가고 있던 것 — + **⚠️ [2026-08-25 재오픈] 이 "고침"은 다시 낡았다가 다시 맞게 됐다.** + 같은 날 오전에 `architecture.md`가 "`store.key`는 값" 재설계로 고쳐졌고, + 오후에 그 재설계가 철회되며 원래 모델로 되돌아왔다 + (`archive/store-value-field-redesign-withdrawn.md`). **지금 맞는 서술은 + `base/store-plan.md`가 소스** — 이 목록의 "이미 고침"만 믿고 넘어가지 + 말 것(실제로 그 철회에서 `architecture.md` 두 자리가 누락됐고 감사가 + 잡았다). 이 감사 세션 도중 발견해 직접 정정(커밋 `4b839b0`에서 별도로 이미 반영됨을 뒤늦게 확인 — 같은 문제를 두 세션이 독립적으로 발견한 셈). - `architecture.md` 소스트리의 `Store.luau`/`Ref.luau` 주석이 각각 옛 diff --git a/.claude/research/v1-compat-plan.md b/.claude/research/v1-compat-plan.md index 16cd295..215dce2 100644 --- a/.claude/research/v1-compat-plan.md +++ b/.claude/research/v1-compat-plan.md @@ -52,9 +52,11 @@ v1(`.claude/initreq/quad/src`) 조사 결과, API는 성격이 다른 두 계층 CLAUDE.md에 그런 서술은 없었음. 실제 소스는 `base/component-composition-plan.md`의 "1. 컴포넌트 = 그냥 함수, "자기 store 자동 소유" 매직은 폐기" 절(자동 흡수/자동 store 생성 매직 폐기, - 사용자 확정)과 `base/store-plan.md`의 "Store 값 설정 문법 — - `myStore.key = value` 폐기, `source:Set(value)`로 전환" 절(`__newindex` - 자동 위임 폐기)). + 사용자 확정)과 `base/store-plan.md`의 "Store 값 설정 문법" 절(`__newindex` + 자동 위임 폐기)). **[2026-08-25]** 여기 한때 *"`store.key = v`는 + 되살아났다"*고 적었는데 그건 같은 날 **철회된** 재설계다 — 대입 문법 + 폐기(2026-08-06)는 **유지**된다 + (`archive/store-value-field-redesign-withdrawn.md`). ## 3. 계층별 실현 가능성 diff --git a/.claude/todos.md b/.claude/todos.md index 5fdcc13..ada0000 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -5,7 +5,38 @@ (`.claude/question.md`, `luau-test/STATUS.md` 등). -00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~5라운드 전부 `base/` +00. **⭐⭐⭐ [2026-08-25] 7라운드까지 전부 처리 완료 — `question.md` 최우선 + 절이 비었고 M2 착수 게이트가 0이다.** 7라운드(손 트레이싱, 6패스, + 발견 52건 `H-55`~`H-106`)와 그 검증 패스를 사용자와 대화형으로 처리해 + `base/`에 전량 반영했다. **결정의 소스는 + `qa-request/pre-implementation-handtrace-round7-followup.md`**(발견 원문은 + `qa-request/pre-implementation-handtrace-round7.md`, 판정은 + `qa-request/pre-implementation-handtrace-round7-verification.md` — 개수·개별 항목은 여기서 + 세지 않는다). + - **구조가 바뀐 것**: Store — **명시적 초기화**(옛 eager/lazy 이중 모델 + 폐기, `defaults`가 곧 선언 키 집합) + `WrapStore`/`ProcessStoreType` + 폐기(**타입 함수를 안 쓰고** 타입 인자에 `Source`를 직접) + + 동적 키를 `store:Of<>(name)` 하나로(옛 `GetDynamic` 흡수), + `Ref`가 `Epoch`로 승격, `Weak*` 등록 표면 신설(`Ref:WeakCallback` / + `Observer:WeakSubscribe`), `Effect`의 강한 dep 맵 + `Blocker` 억제, + 전파 루프 의사코드 확정, `rawInvalid` → 캐시 카운터 쌍, + `recompute` 재진입 차단 + 되감기, `emit(commit) -> boolean`, + `EpochMap:Peek`, `_hold` 불변식. + - **⚠️ 같은 날 한 번 크게 되돌렸다**: 오전에 "`store.key`가 값, + `store:Of(k)`가 프리미티브, `index<>`/`keyof<>` + 팬텀 필드, + `store.key = v` 부활"이라는 Store 재설계를 넣었다가 **같은 날 + 철회**했다 — 살아남은 것은 명시적 초기화 하나다. 철회 이유와 원문은 + `archive/store-value-field-redesign-withdrawn.md`, 거기서 나온 원칙 + (*"타입 함수는 진단까지만"*)이 `base/typing-limits.md` §0으로 승격됐다. + - **역전 없음** — `store.key = value` 폐기(2026-08-06)는 **유지**된다. + - **툴체인 하나**: `luau` CLI가 심볼릭 링크를 못 탄다는 게 밝혀져 + `scripts/relink.sh` + `scripts/test.sh` 신설 — 그 전엔 스모크 2개가 + 안 돌고 `luau-analyze`가 **거짓 클린**을 줬다. + - **새 error 계약**: `level` 이분(사용자 입력 2 / 내부 불변식 1), + 메시지는 **영어**, 예외는 `pcall`로 안 감싼다 + (`base/architecture.md`의 두 신설 절). + + **[2026-08-21] 1~5라운드 전부 `base/` 반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서 **M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`는 `state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의 @@ -166,7 +197,15 @@ (**[2026-08-24]** 그때 열어뒀던 마일스톤 재편 여부가 순서 교체로 닫혔다 — 위 00번 머리말). - **⚠️⚠️ [2026-08-24 승격] 아래 둘은 이제 *바로 다음* 마일스톤의 + **✅ [2026-08-25 해소] 아래 둘은 전부 닫혔다** — `question.md` 최우선 + 절은 지금 **비어 있다**. 중간 State GC는 **`_hold` 불변식**(하류 → 상류 + 강함, 상류 → 하류 weak)으로 사용자가 확정했고, 동적 키 표면(옛 `GetDynamic`) 위치는 + **콜론 유지 + `CheckReserved` 타입 함수**로 닫혔다(애초에 그 항목이 섰던 + 근거인 lazy `__index` 충돌 자체가 Store 재설계로 소멸). 남은 건 실측 + 스파이크 하나뿐이고 **착수 게이트가 아니다**(`luau-test/STATUS.md`의 + "만들어야 할 스파이크" 절). 아래는 해소 전 서술: + + **⚠️⚠️ [2026-08-24 승격, 2026-08-25 해소됨] 아래 둘은 이제 *바로 다음* 마일스톤의 게이트다.** 순서 교체 전엔 반응형이 M3라 "한 마일스톤 뒤"의 일이었는데, 반응형이 M2가 되면서 **지금 착수 직전에 결론이 필요한 항목**이 됐다 (위 00번이 "M2 착수를 막는 **설계** 항목은 없다"고 하는 것과 모순되지 diff --git a/.gitignore b/.gitignore index 990d99b..46c8ea6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ lune_packages/ # luau-lsp가 rojo 설치를 감지하면 백그라운드에서 자동 생성/watch함 # (에디터 타입 링킹용 산출물, 커밋 대상 아님) sourcemap.json +.relink-manifest diff --git a/CLAUDE.md b/CLAUDE.md index df62012..6c20487 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,9 +8,10 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의 **2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/` 문서의 `M2`/`M3`는 옛 의미로 읽을 것**(라이브 문서는 전부 새 번호로 맞춰뒀음). 경위는 `.claude/archive/question-resolved.md`의 "마일스톤 경계" 절, -새 구성은 `ROADMAP.md`의 M2 배너. **⚠️ M2 착수 전에 답이 필요한 항목이 -남아 있다** — 무엇이 몇 개인지는 여기서 세지 말고 `.claude/question.md`의 -최우선 절을 볼 것. 같은 상태를 `.claude/project-context.md`도 +새 구성은 `ROADMAP.md`의 M2 배너. **⭐ [2026-08-25] M2 착수를 막는 항목은 +없다** — `.claude/question.md`의 최우선 절이 **비었다**(7라운드 손 트레이싱 +후속에서 중간 State GC와 `store:GetDynamic` 위치가 같이 닫혔다, 결정 전량의 +소스는 `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`). 같은 상태를 `.claude/project-context.md`도 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 항상 루트 `ROADMAP.md`. diff --git a/ROADMAP.md b/ROADMAP.md index 96849c5..209eabe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,10 +13,12 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기 > 절이 소스). 그건 아직 열린 작업이므로 "M0 완료"로 읽고 넘어가면 안 됩니다. > **[2026-08-24] 착수를 막던 마일스톤 순서 문제는 해소됐습니다** — M2와 > M3의 번호를 맞바꿔 반응형을 먼저 짓기로 확정했습니다(아래 M2 배너가 -> 소스). **⚠️ 다만 그 교체로 M2 착수 전에 답이 필요한 항목 둘이 -> `.claude/question.md` 최우선 절로 올라왔습니다** — 중간 State GC 실측과 -> `store:GetDynamic` 위치(원래 반응형의 게이트였는데 반응형이 앞으로 -> 오면서 같이 당겨짐). M1까지의 산출물은 +> 소스). **⭐ [2026-08-25] 그 교체로 올라왔던 항목 둘도 닫혔습니다** — +> 중간 State GC는 `_hold` 불변식(하류 → 상류 강함)으로, 동적 키 표면 +> 위치는 `store:Of<>(name)` 하나로(옛 `GetDynamic` 흡수) + 예약 키 +> 충돌은 `CheckReserved` 타입 함수로. `.claude/question.md` +> 최우선 절은 지금 **비어 있습니다**. 결정 전량의 소스는 +> `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`. M1까지의 산출물은 > quad-base/quad-roblox 폴더+pesde.toml, 루트 > default.project.json/.luaurc, mock 테스트 하네스, `New()`/`RunInit`/ > `AddPlugin` 골격. **다만 M0의 검증 스파이크 여러 개가 설계 변경으로 @@ -195,7 +197,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 "미정"이라 적어둔 걸 `H-48`이 닫았다 - [x] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수 `luau` CLI, `architecture.md` "테스트 전략" 절 참고) — - `quad-base/test/mock.luau` + `smoke.*.luau`, 전부 PASS + `quad-base/test/mock.luau` + `smoke.*.luau` — **[2026-08-25 정정, + 7라운드 `H-78`] "전부 PASS"엔 전제조건이 있었다.** `luau` CLI가 + **심볼릭 링크를 못 타는데**(디렉토리·파일 둘 다) pesde의 워크스페이스 + 링크가 전부 심볼릭이라, 그대로는 `smoke.init`/`smoke.plugin`이 + `could not resolve child component "src"`로 죽고 `luau-analyze`는 + **조용히 통과**한다(모듈을 `any`로 떨어뜨림 — "거짓 클린"). + **`./scripts/test.sh`로 돌릴 것** — 그게 `scripts/relink.sh`를 먼저 + 돌려 심볼릭을 실제 복사로 바꾼다. 그 뒤 셋 전부 PASS 확인 - [x] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템 Init이 `module`을 파라미터로 받아 뮤테이션, `Relate` 기반 인스턴스별 멱등 가드(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 @@ -325,33 +334,68 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `:TrackFrom` — `EpochSet = {[Epoch]: true}`, **배열 아님**) 으로 떼어내며, State가 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 - **출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만 + **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만**(옛 `rawInvalid == false` 자리 — **[2026-08-25 `H-85`]** 불린이 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체됐다) 돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 (`luau-test/STATUS.md`). - [ ] `Source.luau`/`State.luau`/`Store.luau` -- [ ] **[2026-08-18 신설]** `store:GetDynamic<>(name): Source` — 런타임에 - 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은 - 기각). **⚠️ 콜론 메소드로 두면 `__index`가 고정 메소드 테이블을 먼저 - 확인해야 하고 `GetDynamic`이 예약 키가 됨** — 탑레벨 함수로 둘지 - 아직 미결(`base/store-plan.md`의 "타입 추론 문제" 절, `question.md` 최우선 절) +- [ ] **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<>(name): Source` — + 런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 + 커링은 기각). **콜론 메소드로 확정**했고, 예약 키 + (`Of`/`Names`) 충돌은 `CheckReserved` 타입 함수가 사용 지점에서 + 잡는다(`base/store-plan.md`의 "타입 추론 문제" 절). + `<>`가 값 호출부에서 실제로 `T`를 묶는 것도 실측 확인됨. + **[2026-08-25] 옛 이름은 `GetDynamic`이었다 — `Of`가 흡수했다** +- [ ] **[2026-08-25 신설]** `store:Names(): { string }` — 선언된 키 집합 + 열거(그림자 테이블의 키). 그룹 `Attribute(...)`/`attr:NameMap()`이 + 요구한다(`base/attribute-plan.md`) +- [ ] **[2026-08-25 신설]** `CheckReserved` 타입 함수 — 예약 키를 **검증만** + 하고 `T`를 그대로 통과시킨다. `error()`가 아니라 + `print(...)` + `return types.never`를 써야 한다. + **타입 함수는 이 용도(진단)까지만 쓴다** — `base/typing-limits.md` §0 +- [ ] **[2026-08-25 신설]** **명시적 초기화** — 타입 인자에 `Source`를 + 직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index` + (없는 키를 그 자리에서 만들어 저장)는 **폐기**. 그래서 `defaults`가 + 곧 선언 키 집합이고 `Names()`가 성립한다 - [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅** (2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) — - State는 구독자(Observer의 emit 클로저)를 **weak로만** 담고, 살려두는 + State는 구독자를 **weak-키로만** 담고, 살려두는 책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음 - (어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐). 발화 시 - 각 구독자에 대해 `canExecute(observer)`가 거짓이면 **그 구독자만 - 조용히 건너뜀**(no-op) — 이게 `canExecute`의 유일한 실제 호출부이고, - `inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에 - 걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은 - `bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관 -- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — Luau `type function` - (`WrapStore`/`ProcessStoreType`)으로 `Store`가 `T`의 각 필드를 - `Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱 - 번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]** - `luau-test/done/16-type-store-key-typefunction.luau` 통과(원인은 - 설계 문제가 아니라 `types.newfunction` API 버전 드리프트였음) + (어디에도 안 묶인 Observer는 GC되어 목록에서 자연히 빠짐). + **⭐⭐ [2026-08-25 정정, 7라운드 `H-56`] 확정 의사코드는 + `base/source-state-plan.md`의 "전파 루프 — 확정 의사코드" 절이 + 소스다.** 여기 한때 적혀 있던 두 가지가 틀렸었다 — + (a) 집합의 원소는 "Observer의 emit 클로저"가 아니라 **Observer + 값**이다(`bindLifetime`이 그 identity를 쓰므로 클로저를 담으면 + `canExecute`가 항상 거짓), (b) **`canExecute`는 Observer/Effect + 구독자에만 적용된다 — 자식 State 노드는 이 게이트를 안 탄다.** + "각 구독자에 대해"로 짜면 `:With`/`:Compute`/`:Gate`가 만든 파생 + 노드가 **전부 걸러져** 그 아래 모든 Observer가 침묵한다. 자식 + 노드의 생존은 `canExecute`가 아니라 같은 문서의 **`_hold` + 불변식**(하류 → 상류 강함)이 책임진다. + ```lua + if isState(sub) then sub:_receive(from) -- 자식 노드: 게이트 없음 + elseif canExecute(sub) then sub.fn(sub, from) -- Observer / Effect + end + ``` + `canExecute`가 `inst`를 인자로 받을 수 없는 이유는 그대로다(State는 + 자기가 어느 Instance에 걸렸는지 모름). `state:Observer(fn)`의 + "등록 즉시 1회 실행"은 `bindLifetime` 이전에 동기적으로 일어나므로 + 이 게이팅과 무관. + **순회 전 스냅샷 필수**(`H-23`) — `pairs` 순회 중 새 키 추가는 + 미정의다 +- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — + **[2026-08-25 재작성]** 옛 `WrapStore`/`ProcessStoreType` 합성 접근은 + **폐기**됐고(`archive/store-value-field-redesign-withdrawn.md`), 지금은 + **타입 함수를 안 쓰고** 타입 인자에 `Source`를 직접 써서 평범한 + 레코드로 짓는다(같은 날 `index<>`/`keyof<>` + 팬텀 필드 안을 넣었다가 + §0 원칙에 따라 철회 — + `archive/store-value-field-redesign-withdrawn.md`). `luau-analyze`로 양성 9건 + + 음성 대조군 전부 확인 — `base/store-plan.md`의 + "`store.key` 레코드 필드 타이핑" 절이 소스. 스파이크 `16`/`21`은 + 폐기된 접근을 검증한 것이라 재작성 대기 + (`luau-test/STATUS.md`) - [ ] `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar (2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`" 절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드 @@ -366,15 +410,28 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로 부르는 순수 설탕, `factory: (State) -> U): U`로 열린 타입. Source도 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함 +- [ ] **`Observer.luau`** — `Observer` 객체와 **`:Subscribe()`/`:WeakSubscribe()` + 전역 레지스트리의 소유 모듈**. `EpochMap.luau`와 같은 이유로 + `State.luau`에 묻지 않는다(`Effect`/`GateNode`/leaf 핸들러가 전부 이 + 레지스트리를 본다) — `base/architecture.md` 소스 트리, 7라운드 `H-99` - [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회 실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver` - 판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()`. **동적 + 판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` + + **`:WeakSubscribe()`/`:WeakUnsubscribe()`**(Weak 쪽이 프리미티브, + 7라운드 `H-58`/`H-59`). **무인자 `state:Observer()`의 내부 콜백은 + `function(self) self:Get() end`**(`H-61`). **동적 경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14 열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록 **⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것 자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목). -- [ ] `Effect(fn, ...deps)`(`base/effect-plan.md`, **[2026-08-21 5라운드 +- [ ] `Effect(fn, ...deps)` — **⚠️ 선행: `Blocker`의 기본 메커니즘** + (`On`/`Off`/`IsOn`/`OffWithoutEmit`). 생성자가 등록 구간 억제에 사적 + `Blocker` 하나를 쓴다(`base/effect-plan.md`). 아래 `Blocker.luau` + 체크박스가 이 항목보다 뒤에 있지만 **그 기본 넷은 `GateNode`/`:Policy`와 + 무관하게 독립 완결**이라(`base/blocker-plan.md`의 "메커니즘" 절) 그 + 부분만 먼저 만들면 된다 — `:Policy`/`state:Block` 배선은 `GateNode` + 뒤에. (`base/effect-plan.md`, **[2026-08-21 5라운드 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치 1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독** (State/Source는 `Observer`, `Ref`는 `:Callback`)을 걸어 @@ -389,17 +446,32 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 "동적 경로 가드" 절, 2026-08-14 열한 번째 세션) **⚠️ [2026-08-24] 단 그 가드를 `Dispatch.addHandler`로 등록하는 것 자체는 M3다** — 레지스트리가 거기서 생긴다(M3의 그 항목). -- [ ] **⭐ [2026-08-24 신설, 6라운드] `Effect` 구현 시 같이 만들 것** — - **`handle._observers`**(배열, 단수 `_observer`에서 바뀜 `H-8`) · +- [ ] **⭐ [2026-08-24 신설, 6라운드 / 2026-08-25 7라운드로 필드 재편] + `Effect` 구현 시 같이 만들 것** — + **`handle._deps`**(`{[Ref|State] = fn|Observer}`, **강참조** — 옛 + `_observers`/`_refDeps`/`_refCallbacks` 셋이 여기로 통합됐다) · + **`handle._epochs`**(`EpochMap` — `Ref`도 `Epoch`라 dep 종류가 균일) · + **`handle._blocker`**(등록 구간의 즉시-1회 호출 억제 — 옛 `_installing` + 플래그 폐기, 그건 생성자 구간만 덮어 바인드 구간을 놓쳤다) · **`handle._cleanup`**(직전 cleanup 보관, `Rerun`과 `Destroying` 클로저가 - 같은 자리를 읽는다) · **`handle._refDeps`/`_refCallbacks`**(`Ref` dep과 - 거기 건 클로저 — 해제 시 값으로 떼야 해서 보관 필요) · - **`handle._destroyConn`** · **`:_bindDestroying(inst)`/`:_unbindDestroying()`** - (`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`). - `fn` 시그니처는 **`fn(self: EffectHandle) -> (() -> ())?`**이고 - **`...deps`는 `fn`에 안 넘어간다**(`H-14`). `Ref` 콜백은 본문 맨 앞에서 - **`canExecute(handle)`를 확인**한다(`H-7`). 의사코드는 - `base/effect-plan.md`가 소스 + 같은 자리를 읽는다) · **`handle._installed`**(설치 여부 — `fn`의 cleanup + 반환이 **선택**이라 `_cleanup`의 유무로는 판정할 수 없다) · + **`handle._running`/`_pending`**(`Rerun` 재진입 + 지연) · **`handle._destroyConn`** · + **`:_bindDestroying(inst)`/`:_unbindDestroying()`** + (`bindLifetime`/`unbindLifetime`이 `isEffect`일 때 부르는 훅, `H-11`) · + **`:Rerun()`**(정의는 `H-60`이 채웠다 — 실행 중 재진입은 **지연 + 재실행**, error는 UB) · **`:_consumeCleanup()`**(읽고 → 지우고 → 실행). + `fn` 시그니처는 **`fn(self: EffectHandle) -> ...(() -> ())`**이고 + (**[2026-08-25 `H-95`]** 가변 반환 팩 — 옛 `-> (() -> ())?`는 콜백이 + "선언보다 적게 반환"할 때 strict에서 막혀 정상 용례가 전부 안 통과했다) + **`...deps`는 `fn`에 안 넘어간다**(`H-14`). + **⭐ dep 등록은 생성자에서 한 번만** — `:WeakSubscribe()`/ + `:WeakCallback()`으로 걸고, 바인드/언바인드는 dep을 아예 안 건드린다 + (`H-58`/`H-59`). 발화 게이트는 전부 **`canExecute(handle)`** 하나다 + (`H-7`). 캐치업은 바인드 직후 **조건부 최대 1회** + (`if not self._installed or self._epochs:Refresh() then self:Rerun() end` + — `H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스 - [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로 스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로 @@ -435,17 +507,40 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 한 번도 안 쓴다, `base/state-epoch-plan.md` §5). **빈 배치는 아무것도 안 함**. `emitEpochMap`을 쓰므로 위 `EpochMap.luau`가 선행 — `base/gate-plan.md`. **"게이팅 먼저" 결정의 산물** - (사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*) + (사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*). + **⭐⭐ [2026-08-25 갱신, 7라운드 `H-55`/`H-72`/`H-86`] `setup` 계약이 + 바뀌었다** — `setup: (emit: (commit: boolean?) -> boolean) -> + (onUpstreamEmit: () -> ())`. 인자 **목록**은 그대로지만 + `emit(false)`가 **흡수 집합을 버리고**(정책이 그걸 할 통로가 없었다), + 반환값이 **"실제로 내보내거나 버릴 게 있었는가"**를 준다(정책이 + `pending`을 읽을 통로가 없어 `Throttle`의 창이 idle로 못 돌아왔다). + 그리고 **수신 시점 판정은 `emitEpochMap:Peek`**을 쓴다 — `:Update`는 + 덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻과 양립하지 않는다. + flush 순서는 **빈 배치 얼리리턴 → 스왑 → `:Sync(batch)` → 전파** - [ ] **`Blocker.luau`** (**[2026-08-24]** 위 둘과 같이 되돌아옴) — 위 `GateNode` 위에 - 얹히는 **정책**(다시 노드를 만들지 말 것). 여러 Source를 한꺼번에 + 얹히는 **정책**(다시 노드를 만들지 말 것). + **⭐ [2026-08-25 추가, 7라운드 `H-63`] onunblock 핸들 보관 세 자리**: + (1) **weak-키 해시맵 셋**(`__mode = "k"`) — 값-weak 배열이면 구멍에서 + `ipairs`가 멈춰 뒤의 살아있는 게이트가 `Off()`를 못 받는다, + (2) **강한 주인은 정책이 반환하는 `onUpstreamEmit` 클로저**(upvalue로 + 잡는다) — Blocker가 강하게 들면 거기 걸렸던 모든 gated state와 상류 + 체인이 영원히 산다, (3) `Off()`/`OffWithoutEmit()`은 **스냅샷 뒤 순회**. 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive (`base/blocker-plan.md`). **M3의** `Dispatch.setLength`/`setOffsetSource`의 배치 등록이 `:On()`/`:IsOn()`/`:OffWithoutEmit()` **세 메서드**를 호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy 조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch 쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구** -- [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Source`/`State`/ - `Store` 필드 추가 — **규칙 요지**: `New(): Quad`가 닫힌 레코드이고 +- [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]** + `quad-types`의 `Quad`에 **이 마일스톤이 얹는 탑레벨 값 전부** 추가 — + `Source` / `Store` / `Effect` / `Blocker` / `Relate` / + `is*` 전량(`isState`/`isSource`/`isStore`/`isRef`/`isObserver`/ + `isEffect`/`isEpoch`/`isModifier` …) / `bindLifetime`·`unbindLifetime`· + `canBound`·`canExecute` 4종. **⚠️ `State`는 런타임 생성자가 없다** — + 파생(`:With`/`:Compute`/`:Gate`)으로만 생기므로 **타입만** 재수출한다. + 옛 목록(`Source`/`State`/`Store` 셋)은 M2가 실제로 얹는 것의 일부만 + 담고 있었고, 빠진 것 전부가 `H-25`가 만든 바로 그 벽에 부딪힌다. + **규칙 요지**: `New(): Quad`가 닫힌 레코드이고 `RunInit`은 반환값이 없어 타입을 못 넓히므로, 서브시스템을 붙이는 마일스톤마다 `quad-types`의 `Quad`에 그 필드와 타입 재수출을 같이 추가한다. 안 하면 그 마일스톤 완료 후 `quad.Store` 접근이 런타임엔 @@ -464,7 +559,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `luau-test`의 `15-type-compute-trailing-deps-typepack.luau`로 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 되면 동종 타입 dep 1개로 한정) -- [ ] mock 대상 테스트 +- [ ] **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Block(blocker)` / + `Source:Emit()` — `:Compute`/`:Apply`/`:Observer`는 각각 체크박스가 + 있는데 이 셋만 빠져 있었다 +- [ ] **[2026-08-25 신설, `H-81`]** `isModifier` 런타임 가드 — 적용 지점이 + **전부 이 마일스톤의 코드**다(`Source:Set` / Store 생성 시 eager + `Source(default)` / State의 `:Compute` 결과 캐싱, 그리고 + 독립 `Source(someModifier)`). 체크박스가 M7에만 있었다. + `base/modifier-plan.md` 7번과 `base/source-state-plan.md`의 적용 + 지점 목록을 서로 맞출 것(한쪽에만 있는 항목이 있다) +- [ ] **[2026-08-25 신설, `H-97`]** mock 백엔드용 생명주기 4종 최소 구현 — + `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`. 안 하면 + **아래 "mock 대상 테스트"가 전파 루프를 한 번도 못 돈다**(루프가 매 + 발화마다 `canExecute`를 부르는데 그건 M8 구현이고 미주입 슬롯은 + 에러 스텁이다). 커밋된 `quad-base/test/mock.luau`에 signal/Connection이 + 이미 있으므로 그 `Destroying`을 그대로 쓰면 된다 +- [ ] mock 대상 테스트 — **전파 루프를 실제로 돌릴 것**(위 항목이 선행). + M2의 핵심이 전파 루프인데 그걸 한 번도 안 돌려보고 M3로 넘어가면 + 7라운드가 찾은 종류의 결함을 그대로 낳는다 ## M3 — 디스패치 엔진 @@ -1115,7 +1227,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] **[2026-08-24 `H-25` 파생]** `quad-types`의 `Quad`에 `Ref` 필드 추가 (위 M3 항목의 "마일스톤마다" 규칙) - [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/ - `:Wait(thread?)`, 전부 self 반환) + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref + `:Wait(thread?)`, 전부 self 반환). + **⭐ [2026-08-25 추가, 7라운드 `H-58`/`H-64`/`H-70`] `Ref`가 `Epoch`를 + 만족하게 됐다** — 공개 필드 **`.Revision`**(`:Set()`이 `Source`와 같은 + `bit32.bnot(-rev)`로 갱신) + **`EpochBrand:register(self)`**, 그리고 + 약하게 등록하는 **`:WeakCallback(fn)`**(weak-키 별도 테이블, `Weak` 쪽이 + 프리미티브이고 `:Callback`이 그 위에 "GC 킵"을 얹은 것). + **M2가 이미 이 표면을 전제한다** — `Effect` 생성자가 `Ref` dep을 + `self._epochs:Sync(d)`로 `EpochMap`에 태운다(`base/effect-plan.md`). + `base/ref-plan.md`의 "`Ref`는 `Epoch`를 만족한다" 절이 소스 + `PreRef.luau`/`PostRef.luau`(별도 파일, Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 위치 무관 호이스팅 pre-pass — `base/ref-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절) @@ -1181,17 +1301,30 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (`pairs` 순회 중 새 키 추가가 Lua에서 미정의라 — `H-23`과 같은 처방) 키 타입으로 분기: `type(k) == "thread"`면 `Callbacks[k] = nil`로 소진 후 `coroutine.resume(k, self)`(값이 아니라 **Ref 자신**), 함수면 `k(value)` - 호출 + 유지. **중복 등록은 dedup이 계약**이고 해제는 신설된 + 호출 + 유지. **중복 등록은 dedup이 계약**이고 해제는 **`:Uncallback(fn)`**(`Callbacks[fn] = nil` 한 줄). + **⭐ [2026-08-25 추가, 7라운드 `H-58`/`H-59`] 약하게 등록하는 짝 + `:WeakCallback(fn)`이 신설됐고 그쪽이 프리미티브다** — + `:Callback(fn)`은 거기에 "GC 안 되도록 킵" 하나를 더 얹은 것이다. + 약한 등록은 **weak-키 테이블**(`__mode = "k"`)로 `.Callbacks`와 + **별도**이고(Lua의 weak 모드는 테이블 단위), 발화 순회는 둘 다 + 훑으며 `:Uncallback`은 양쪽을 본다. 의사코드는 `base/ref-plan.md`가 소스. **⚠️ 옛 배열 설계(빈 슬롯 선형 탐색 등록, `[i] = nil` 소진, `#t` border 실측)는 전부 폐기됐다** — 해시맵엔 구멍도 border도 없다. `:Wait(thread?)`는 그대로 — `thread`가 `nil`이면 `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` 반환(남의 thread를 여기서 대신 정지시킬 수 없어서) -- [ ] **[2026-08-24 신설, 6라운드 `H-7`]** `Ref:Uncallback(fn)` — 해제 경로. - `Effect`가 자기 `Ref` dep 콜백을 뗄 때 쓰고(`_refCallbacks`에 보관해둔 - 바로 그 클로저를 값으로 뗀다), `unbindLifetime`/`:Unsubscribe()`가 부른다 +- [ ] **[2026-08-24 신설, 6라운드 `H-7` / 2026-08-25 범위 축소, 7라운드 `H-58`]** + `Ref:Uncallback(fn)` — 해제 경로(강·약 두 테이블을 다 본다). + **⚠️ `Effect`는 더 이상 이걸 안 부른다** — 여기 한때 *"`Effect`가 자기 + `Ref` dep 콜백을 뗄 때 쓰고(`_refCallbacks`에 보관해둔 바로 그 클로저를 + 값으로 뗀다), `unbindLifetime`/`:Unsubscribe()`가 부른다"*고 적혀 있었는데 + 그 계약은 `base/effect-plan.md`가 명시적으로 retract했다. 지금은 + `Effect`가 **생성자에서 `:WeakCallback`으로 한 번만** 걸고 강한 주인은 + `_deps`이며(`_refCallbacks`는 폐기된 필드), 바인드/언바인드는 `Ref`를 + 아예 안 건드린다. `:Uncallback`은 **사용자가 직접 건 콜백을 떼는** + 공개 표면으로 남는다 - [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/ `unbindLifetime`/`canBound`/`canExecute` 본체(인터페이스 자체는 M2로 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 @@ -1202,12 +1335,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `gchold[value]=true`(강참조로 생존 보장)와 `BindData:SetWeak(value, "gchold"/"gcconn", ...)`(값이 자기 생존 판정 근거를 직접 들고 있게) 둘을 하고, `unbindLifetime(value)`은 그걸 되돌림. - **⭐ [2026-08-24 6라운드 `H-11`] 그리고 `isEffect(value)`면 분기가 하나 더 - 붙는다** — `handle._observers` 전부로 cascade하고 - `value:_bindDestroying(inst)` / `:_unbindDestroying()`를 부른다(전자는 - 주입 op `onDestroying`으로 `Destroying`을 연결하고 `Ref` dep 콜백을 - (재)등록, 후자는 그 대칭). 여기 원래 *"둘만 하고"*라고 적혀 있었는데 - 이 분기와 직접 모순이었다. **이게 `Effect`의 leaf 사망 cleanup을 실제로 + **⭐ [2026-08-24 6라운드 `H-11`, 2026-08-25 7라운드 `H-58`로 축소] + 그리고 `isEffect(value)`면 분기가 하나 더 붙는다** — + `value:_bindDestroying(inst)` / `:_unbindDestroying()`를 부른다. + 여기 원래 *"둘만 하고"*라고 적혀 있었는데 이 분기와 직접 모순이었다. + **⚠️ [2026-08-25 정정] `handle._observers` 전부로 cascade하지 + 않는다** — 그 필드 자체가 폐기됐다(`_deps` 하나로 통합). 그리고 + `_bindDestroying`은 **`Ref` dep 콜백을 (재)등록하지 않는다** — + dep 등록은 생성자에서 끝나고, 여기서 하는 건 `Destroying` 연결과 + **조건부 캐치업 한 줄**(`if not self._installed or + self._epochs:Refresh() then self:Rerun() end`)뿐이다. **이게 `Effect`의 leaf 사망 cleanup을 실제로 발화시키는 유일한 배선**이고, M6의 `_detached` 정리가 여기 의존한다 (그 항목의 `H-50` 각주 참고). 의사코드는 `base/lifecycle-pattern.md`와 `base/effect-plan.md`가 소스. **[2026-08-14 diff --git a/scripts/relink.sh b/scripts/relink.sh new file mode 100755 index 0000000..86bc34c --- /dev/null +++ b/scripts/relink.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# pesde 워크스페이스 링크를 **실제 복사**로 교체한다. 여러 번 돌려도 최신을 따라간다. +# +# 왜 필요한가: **`luau` CLI가 심볼릭 링크를 못 탄다.** 디렉토리 링크도 파일 +# 링크도 안 되고, `luau-analyze`는 더 나쁘게 **조용히 통과**한다(모듈을 `any`로 +# 떨어뜨리고 진단 0건 — "거짓 클린"). pesde의 워크스페이스 링크는 전부 디렉토리 +# 심볼릭이라, `quad-base/src/init.luau`의 `require("./roblox_packages/quad_types")`가 +# 그 링크에 닿는 순간 죽는다. 근거와 최소 재현은 +# `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`의 🅛 절. +# +# ⚠️ 이 스크립트가 지켜야 하는 것 셋(전부 `/code-review high`가 실제로 잡은 것): +# 1. **`cp -rL`** — 평범한 `cp -r`는 심볼릭을 **심볼릭 그대로** 복사한다. +# 중첩 패키지(quad_types 안의 type_version_check 링크)가 그대로 살아남아 +# 같은 "거짓 클린"이 재발한다. +# 2. **스킵은 치명적으로** — 원본이 없어 건너뛴 채 성공으로 끝나면 테스트가 +# 얼어붙은 스냅샷을 돈다. 이 스크립트가 막으려던 실패 모드 그 자체다. +# 3. **깊이 고정 글롭** — `find -path '*/.pesde/...'`의 `*`는 `/`를 넘어가 +# 중첩 `.pesde`와 `src`까지 잡는다. 셸 글롭은 `/`를 안 넘으므로 그걸 쓴다. +set -euo pipefail +shopt -s nullglob +cd "$(dirname "$0")/.." +MANIFEST=".relink-manifest" + +fail=0 +note_fail() { echo "relink: $*" >&2; fail=1; } + +# 0) 매니페스트가 없는데 이미 복사돼 있으면(도입 전에 한 번 돌린 트리 등) +# pesde 레이아웃에서 원본 매핑을 되살린다. +# 구조: /{luau,roblox}_packages/.pesde/+/// +if [ ! -f "$MANIFEST" ]; then + for d in */luau_packages/.pesde/*/*/* */roblox_packages/.pesde/*/*/*; do + [ -d "$d" ] || continue + pkg="$(basename "$d")" + member="${pkg//_/-}" + [ -d "$member" ] || continue + for e in "$d"/*; do + n="$(basename "$e")" + [ -e "$member/$n" ] || continue + printf '%s\t%s\n' "$e" "$(readlink -f "$member/$n")" >> "$MANIFEST" + done + done +fi + +replaced=0 +refreshed=0 + +# 1) 아직 심볼릭인 것 — 원본을 기록하고 복사로 교체. 중첩 링크가 남지 않도록 +# `cp -rL`로 역참조하고, 남은 게 없을 때까지 반복한다. +while :; do + found=0 + while IFS= read -r link; do + found=1 + target="$(readlink -f "$link")" + if [ ! -e "$target" ]; then note_fail "dangling symlink: $link"; continue; fi + printf '%s\t%s\n' "$link" "$target" >> "$MANIFEST" + rm "$link" + cp -rL "$target" "$link" + replaced=$((replaced + 1)) + done < <(find . -path ./.git -prune -o -type l -print | grep '/\.pesde/' || true) + [ "$found" -eq 0 ] && break +done + +# 2) 이미 복사된 것 — 매니페스트를 보고 원본에서 다시 복사(워크스페이스 멤버를 +# 고쳤을 때 테스트가 옛 스냅샷을 돌지 않게). +if [ -f "$MANIFEST" ]; then + sort -u "$MANIFEST" -o "$MANIFEST" + while IFS=$'\t' read -r link target; do + [ -n "$link" ] || continue + [ -L "$link" ] && continue # 방금 1)에서 처리됨 + if [ ! -e "$target" ]; then note_fail "missing source: $target (for $link)"; continue; fi + parent="$(dirname "$link")" + if [ ! -d "$parent" ]; then note_fail "missing link parent: $parent — 매니페스트가 낡았다, $MANIFEST 를 지우고 pesde install 후 다시 돌릴 것"; continue; fi + rm -rf "$link" + cp -rL "$target" "$link" + refreshed=$((refreshed + 1)) + done < "$MANIFEST" +fi + +echo "relink: $replaced symlink(s) replaced, $refreshed copy/copies refreshed" +if [ "$fail" -ne 0 ]; then + echo "relink: 위 스킵 때문에 트리가 최신이 아니다 — 테스트를 신뢰하지 말 것" >&2 + exit 1 +fi + +# 3) 최종 확인 — .pesde 아래에 심볼릭이 하나도 남으면 안 된다. +leftover="$(find . -path ./.git -prune -o -type l -print | grep '/\.pesde/' || true)" +if [ -n "$leftover" ]; then + echo "relink: 심볼릭이 남았다 (luau가 못 탄다):" >&2 + echo "$leftover" >&2 + exit 1 +fi diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..4986164 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# 스모크 테스트 — 리링크를 먼저 돌린다(scripts/relink.sh 주석 참고). +set -euo pipefail +shopt -s nullglob +cd "$(dirname "$0")/.." +./scripts/relink.sh +files=(quad-base/test/smoke.*.luau) +if [ "${#files[@]}" -eq 0 ]; then + echo "no smoke tests found (quad-base/test/smoke.*.luau)" >&2 + exit 1 +fi +fail=0 +for f in "${files[@]}"; do + echo "=== $f" + luau "$f" || fail=1 +done +exit "$fail"