From 17a2e4f05f438d5675d8c9104457b53c5e21f763 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 14 Aug 2026 05:10:49 +0900 Subject: [PATCH] =?UTF-8?q?docs(base):=20bind/store/state=203=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EB=B6=84=ED=95=A0,=20UI=20=EC=88=8F=ED=95=B8?= =?UTF-8?q?=EB=93=9C=20Tween=20=EC=A7=80=EC=9B=90,=20existing-instance-bin?= =?UTF-8?q?d=20=EA=B8=B0=EA=B0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 건을 한 커밋에 처리: 1. ui-shorthand-plan.md — Tween 지원 확정. 숏핸드가 자식 프로퍼티를 직접 대입하지 않고 Dispatch.process(child, prop, ..., 1)로 위임하면 Tween이 공짜로 따라옴(해석 코드는 PropertyHandler 하나에만 남음). "process 중 inst를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님"을 dispatch-core-plan.md에 일반 규칙으로 명문화. wrap을 Tween.Value에만 적용되도록 들어올리는 헬퍼가 새로 필요한 유일한 부품. ROADMAP M10에 통째로 빠져 있던 UI 숏핸드 항목도 보강. 2. existing-instance-bind — 기각, research/ → archive/. 사유: Length/Offset 등 quad가 만든 트리를 전제한 부기를 바깥에서 밀고 당기는 버그 표면이 치명적으로 넓어짐. "열려 있음"을 전제로 쓰인 본문 7곳도 같이 정정 (architecture.md의 "아직 미정" 절은 유일 항목이었어서 절 자체를 갱신). 3. bind-system-plan.md 3단계 분할 + store-semantics.md 흡수(순수 이동): - base/store-plan.md 신설 — Store = 이름 붙은 Source 모음 - base/source-state-plan.md 신설 — 반응형 코어(Source⊇State, 전파 모델, :With/:Compute/:Apply/previous, Observer, 구독·생명주기 게이트) - bind-system-plan.md 1238→203줄(인스턴스 생성·이벤트 네이밍 + 색인) - store-semantics.md 삭제 참조 40여 곳 스윕. doc-check.py ERROR 0, WARN 101→84. Co-Authored-By: Claude Opus 5 --- .claude/README.md | 9 +- .../archive/canexecute-inst-arg-reversed.md | 2 +- .../existing-instance-bind-rejected.md} | 30 +- .claude/archive/question-resolved.md | 17 +- .../archive/store-source-proxy-reversed.md | 4 +- .../audit/luau-test-first-run-2026-08-13.md | 2 +- .claude/base/architecture.md | 24 +- .claude/base/attribute-plan.md | 9 +- .claude/base/bind-system-plan.md | 1163 +---------------- .claude/base/blocker-plan.md | 7 +- .claude/base/component-composition-plan.md | 2 +- .claude/base/dispatch-core-plan.md | 34 +- .claude/base/effect-plan.md | 6 +- .claude/base/event-plan.md | 2 +- .claude/base/lifecycle-pattern.md | 16 +- .claude/base/modifier-plan.md | 15 +- .claude/base/module-lifecycle-plan.md | 4 +- .claude/base/purity-and-effects-plan.md | 2 +- .claude/base/ref-plan.md | 13 +- .claude/base/relate-plan.md | 9 +- .claude/base/source-state-plan.md | 1148 ++++++++++++++++ .claude/base/store-plan.md | 224 ++++ .claude/base/store-semantics.md | 346 ----- .claude/base/ui-shorthand-plan.md | 116 +- .claude/luau-test/README.md | 8 +- .../done/08-type-source-satisfies-state.luau | 2 +- .../done/11-modifier-illegal-value-error.luau | 2 +- ...5-type-compute-trailing-deps-typepack.luau | 2 +- .claude/question.md | 2 - .claude/reference/comparison-charm.md | 10 +- .claude/reference/comparison-fusion-vide.md | 4 +- .claude/research/documentation-content-map.md | 18 +- .claude/research/documentation-plan.md | 4 +- .../research/framework-comparison-findings.md | 2 +- .claude/research/operator-sugar-plan.md | 8 +- .claude/research/pre-implementation-audit.md | 41 +- ...4-07-store-source-split-shorthand-tween.md | 137 ++ CLAUDE.md | 44 +- ROADMAP.md | 25 +- 39 files changed, 1925 insertions(+), 1588 deletions(-) rename .claude/{research/existing-instance-bind-plan.md => archive/existing-instance-bind-rejected.md} (64%) create mode 100644 .claude/base/source-state-plan.md create mode 100644 .claude/base/store-plan.md delete mode 100644 .claude/base/store-semantics.md create mode 100644 .claude/session/2026-08-14-07-store-source-split-shorthand-tween.md diff --git a/.claude/README.md b/.claude/README.md index 6bb2896..773fb4e 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -32,9 +32,10 @@ | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 | | `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 44개로 확정). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드/`store.key` type function 한계도 여기 통합, 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` | | `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`은 읽지도 쓰지도 않음), 별도 `canBound`는 폐기되어 `canExecute` 하나로 통합, gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md` | -| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | +| `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급 경로이고 `store "key"` 문자열 커링은 동적 키용 미타입 폴백, 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "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()`, **이중 바인딩 금지 게이트**(`canExecute` 하나로 통합), PA님 코드 교차검증 | | `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`). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md` | -| `bind-system-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` | +| `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` | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error**(시그니처/범위는 `question.md` 0-B로 열림) **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유) | @@ -42,7 +43,7 @@ | `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를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 | | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | -| `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` 아님) | +| `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에 빠져 있던 체크리스트 항목도 이 세션에 보강 | | `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`와 동일) | | `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)`만 주입, 엔진 고유 타입 패밀리만 백엔드) | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | @@ -65,7 +66,6 @@ | 문서 | 내용 | 우선순위 | |---|---|---| -| `existing-instance-bind-plan.md` | 이미 생성된 인스턴스 재바인드 — 착수 안 하되 "미지원" 확정도 안 함, 열린 가능성 유지 | 하 — v2 초기 스코프 제외 | | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | | `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 | | `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 | @@ -96,6 +96,7 @@ | `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 | | `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 | | `slot-discard-no-portal-reversed.md` | **[역전됨, 2026-08-13 일곱 번째 세션 신설]** Slot의 **"retract = 폐기, 옮기지 않음"(2026-08-04 확정) + "portal은 오버엔지니어링이라 안 함"** — 여섯 번째 세션에 `State` 교체가 파괴에서 **언마운트**로 뒤집히며 portal이 별도 기능이 아니라 그 귀결이 됨(`state`와 동일한 시맨틱). `base/slot-plan.md`에 히스토리로 남아 있던 세 덩어리(확정 문단 + `State` 왕복 분석 + 포탈 검토와 숙제 셋)를 원문 그대로 이전, 숙제 셋이 각각 어떻게 결말났는지도 정리 | +| `existing-instance-bind-rejected.md` | **[기각됨, 2026-08-14 세션 — `research/`에서 이전]** 이미 생성된 Instance에 나중에 `{k=v}` 프롭 테이블을 바인드하는 기능 — 오래 "열린 가능성"으로 남겨뒀으나 사용자 확정으로 기각. 사유: 허용하면 `Dispatch.setOffsetSource`/`setLength` 같은 "quad가 만든 트리" 전제의 부기를 바깥에서 밀고 당기는 부가 작용이 전부 가능해져 **버그 표면이 치명적으로 넓어짐**. `pre-implementation-audit.md` 2-4(Slot 단일 마운트 소유권과의 충돌)도 이걸로 해소 | | `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(→2026-08-14 다섯 번째 세션에 폐기, `canExecute`로 통합)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 | | `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` | | `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 | diff --git a/.claude/archive/canexecute-inst-arg-reversed.md b/.claude/archive/canexecute-inst-arg-reversed.md index 907108d..5d99532 100644 --- a/.claude/archive/canexecute-inst-arg-reversed.md +++ b/.claude/archive/canexecute-inst-arg-reversed.md @@ -75,7 +75,7 @@ if self.Connection then return self.Connection.Connected end 이 오류가 여섯 세션 넘게 살아남은 이유는 **`canExecute`의 실제 호출부가 어느 문서에도 코드로 등장한 적이 없기 때문**. `bind-system-plan.md`/ -`store-semantics.md`/`slot-plan.md`는 전부 "발화 시 `canExecute`로 게이팅됨" +`source-state-plan.md`(당시 store-semantics.md)/`slot-plan.md`는 전부 "발화 시 `canExecute`로 게이팅됨" 같은 **서술만** 하고 넘어갔고, `dispatch-core-plan.md`는 아예 "핸들러가 직접 `canExecute`를 재구현할 필요 없음 — Observer가 이미 자기 `Subscribed` 상태로 게이팅됨"이라고 적어 호출부를 없는 것처럼 만들었음. diff --git a/.claude/research/existing-instance-bind-plan.md b/.claude/archive/existing-instance-bind-rejected.md similarity index 64% rename from .claude/research/existing-instance-bind-plan.md rename to .claude/archive/existing-instance-bind-rejected.md index c824584..2981ee7 100644 --- a/.claude/research/existing-instance-bind-plan.md +++ b/.claude/archive/existing-instance-bind-rejected.md @@ -1,6 +1,32 @@ -# 이미 생성된 인스턴스에 대한 바인드 (후순위, UB 또는 마일스톤) +# [기각됨] 이미 생성된 인스턴스에 대한 바인드 -**상태**: research — 명시적으로 후순위/UB 후보. 원본: +> **⛔ [2026-08-14 세션, 사용자 확정 — 기각]** `research/`에서 +> `archive/`로 이전. **더 이상 "열린 가능성"이 아니라 미지원으로 확정.** +> +> **기각 사유(사용자)**: 이게 가능하다고 하면 `Dispatch.setOffsetSource`/ +> `setLength`(`base/dispatch-core-plan.md`의 "Length/Offset" 절) 같은, +> quad가 자기가 만든 트리에 대해서만 성립한다고 전제하고 세운 부기를 +> 바깥에서 **밀고 당기는** 부가 작용이 전부 가능해짐 — 즉 기능 하나가 +> 열리는 게 아니라 **버그를 일으키기에 치명적인 표면이 대량으로 노출**됨. +> 그 표면을 다 막으려면 quad가 "내가 만들지 않은 Instance의 자식 +> 구성이 언제 어떻게 바뀌는지"까지 추적해야 하는데, 그건 +> `research/framework-comparison-findings.md`가 use-after-destroy +> 안전망을 기각할 때 쓴 것과 같은 이유(Instance 가상화/추적은 rbvm 같은 +> 전문 라이브러리의 영역, quad가 재발명하면 오버엔지니어링)로 스코프 +> 밖임. +> +> 아래 원문은 기각 전 서술 그대로 보존(당시엔 "미정 유지"가 결론이었음). +> 관련해서 이미 지적돼 있던 긴장은 두 곳 — `research/pre-implementation-audit.md` +> 2-4(Slot의 "엄격한 단일 마운트 소유권" 불변식과 근본적으로 충돌), +> `base/architecture.md`의 "복사 구현 지양, store 바인드 변경은 전체 +> 변경" 원칙. 둘 다 이번 기각으로 해소됨. +> +> **관련 기능이 필요해지면**: quad가 만들지 **않은** Instance를 다루는 +> 정상 경로는 `Ref`(외부 관리 instance를 점진적으로 다루기 위한 직접 참조 +> 획득, `base/ref-plan.md`)와 `Effect`(`base/effect-plan.md`)뿐 — 그 +> 둘로 안 되는 걸 이 문서로 되살리려 하지 말 것. + +**상태**: 기각됨(원래 `research/` — 명시적으로 후순위/UB 후보였음). 원본: `.claude/initreq/raw-userinput.md` "이미 생성된 객체에 대한 바인드?" 절. ## 문제 diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 3a096d5..23255c5 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -311,7 +311,7 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 계산 끝난 값"으로 오해하기 쉬움 — Vue/Svelte 생태계에서는 lazy와 `computed`라는 이름이 공존해도 문제없지만, quad 안에서는 다름. 같은 이유로 `:Compute`(동사 원형) 메소드 이름도 `Computed`가 아니라 - `Compute`인 게 맞다고 재확인(`base/bind-system-plan.md` "네이밍 — + `Compute`인 게 맞다고 재확인(`base/source-state-plan.md` "네이밍 — `Compute`가 `-ed`가 아닌 이유" 절). - `Pipe` — 검토했으나 기각. (1) "캐시한다"는 동작이 파이프라는 비유와 안 맞음(파이프는 통과시키는 채널 이미지라 값을 들고 있다/캐시한다는 @@ -351,7 +351,7 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** `base/slot-plan.md` "`Slot:List(...)`" 절. - **[해소됨, 2026-08-09 세션]** `Bound` — **`canBound(handle): boolean` 탑레벨 함수로 확정**, `canExecute`와 같은 결(raw 필드를 직접 노출하는 - 대신 predicate 함수로 감쌈). `base/bind-system-plan.md` "이중 바인딩 + 대신 predicate 함수로 감쌈). `base/source-state-plan.md` "이중 바인딩 금지" 절 참고. - **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를 @@ -422,7 +422,7 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 해소). - **[해소됨, 2026-08-09 세션]** `State`와 Ref/Slot이 Modifier 필드에 들어가는 것 — 이제 둘 다 `isX` predicate 기반 명시적 `error`로 - 통일(`base/modifier-plan.md` 4번/7번 절, `base/store-semantics.md` + 통일(`base/modifier-plan.md` 4번/7번 절, `base/source-state-plan.md` "따름정리" 절). Luau 타입 차단은 "되면 좋은 보너스"로 격하되어 더 이상 필수 검증 항목 아님 — 문서모순 절 + 우선순위2-2도 갱신 완료. - ~~`props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴 @@ -448,7 +448,7 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 이름이었음, 2026-08-13 다섯 번째 세션에 인덱스 기반으로 재설계되며 개명)로 이미 해소(`pre-implementation-audit.md` 1-2, `bind-system-plan.md` "Dispatch 체인" 절). **[해소됨, 2026-08-09 세션]** `:Compute`의 `previous` 인자 - 오버엔지니어링 의심도 기각(`bind-system-plan.md` "previous" 절, + 오버엔지니어링 의심도 기각(`source-state-plan.md` "previous" 절, `pre-implementation-audit.md` 3-1). **[해소됨]** UI shorthand의 기존 UICorner 매칭 기준도 `base/ui-shorthand-plan.md`에 이미 확정 반영돼 있던 것을 이번에 `pre-implementation-audit.md` 2-11에도 해소 표시로 @@ -495,8 +495,11 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정 규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`. 구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함. -- `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 - 착수를 막지 않음. +- **[해소됨(기각), 2026-08-14 세션]** 이미 생성된 인스턴스 재바인드 — + "스코프 논의만 필요"로 오래 열려 있었으나 사용자 확정으로 **기각**, + `archive/existing-instance-bind-rejected.md`로 이전(사유: Length/Offset + 등 quad가 만든 트리를 전제한 부기를 바깥에서 밀고 당기는 버그 표면이 + 치명적으로 넓어짐). - **[해소됨, 2026-08-13 세 번째 세션]** v1 `objectListClass.__newIndex` 오타 기능(재현 테스트 필요했던 항목) — 사용자가 당시 실수였음을 확인. v2는 이제 오브젝트에 id를 주입하고 id로 조회하는 개념(`GetObjects`류) 자체가 없어져 @@ -565,7 +568,7 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** |---|---| | 전체 아키텍처 결정(디스패치 모델, DOMless, 태그/Ref, Signal 미채택 등) | `base/architecture.md` | | Store/State/Source 온톨로지, 인스턴스 생성/이벤트 인체공학, Ref, 남은 API 이름 | `base/bind-system-plan.md` | -| Store 부작용 허용, `:With`+`:Compute`, dot-access 문법 | `base/store-semantics.md` | +| Store 부작용 허용, `:With`+`:Compute`, dot-access 문법 | `base/store-plan.md` | | 프로바이더 패턴, bind/store 구현 책임 분리 | `base/module-lifecycle-plan.md` | | Slot 재조정, 재마운트 시 throw, **[2026-08-13 6번째 세션 역전] retract=언마운트**(파괴 아님, portal이 그 귀결 — 옛 "retract=폐기"는 뒤집힘) | `base/slot-plan.md` | | `Connected`+GC 라이프사이클 패턴 | `base/lifecycle-pattern.md` | diff --git a/.claude/archive/store-source-proxy-reversed.md b/.claude/archive/store-source-proxy-reversed.md index 41ca8d4..26980dd 100644 --- a/.claude/archive/store-source-proxy-reversed.md +++ b/.claude/archive/store-source-proxy-reversed.md @@ -2,7 +2,7 @@ **역전 일시**: 2026-08-06 (세 번째 세션). **원 확정 일시**: 2026-08-04 (`component-composition-plan.md` 컴포넌트화 논의 3~4번 항목). -**현재 유효한 설계**: `base/store-semantics.md`의 "Source가 State를 +**현재 유효한 설계**: `base/source-state-plan.md`의 "Source가 State를 만족함 — 구조적 서브타입" 절이 최종 소스. 이 파일은 더 이상 능동적으로 참고할 필요 없음(구현에 안 씀) — 왜 이 경로를 갔다가 되돌아왔는지가 `quadnomicon`(프레임워크 설계자용 심화 콘텐츠, `research/documentation-plan.md` @@ -69,5 +69,5 @@ State를 만족**(Svelte `Writable extends Readable`와 같은 모양) "별도 프록시 타입을 만든다"에서 "Source 자체를 서브타입으로 승격한다"로 바뀐 것. 이 반전 자체가 "타입이 없던 v1 습관을 재검토 없이 typed 재작성에 그대로 가져오면 안 된다"는 더 큰 교훈의 구체적 사례이기도 함(사용자 회고, -`base/store-semantics.md` 참고) — `quadnomicon`에서 "설계가 왜 이렇게 +`base/source-state-plan.md` 참고) — `quadnomicon`에서 "설계가 왜 이렇게 반전됐는가" 사례로 쓰기 좋음. 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 5a1c167..89dc4a6 100644 --- a/.claude/audit/luau-test-first-run-2026-08-13.md +++ b/.claude/audit/luau-test-first-run-2026-08-13.md @@ -95,7 +95,7 @@ relate4의 살아있는 엔트리 총 개수: 5 (기대 5) relate4의 살아있는 엔트리 총 개수: 0 (기대 0) ``` -**`base/bind-system-plan.md` "왜 GC-안전한가"와 `base/relate-plan.md` +**`base/lifecycle-pattern.md`(GC-native 관용구)와 `base/relate-plan.md` 전체가 기대고 있는 전제가 실측 확인됨** — quad의 GC-native 아키텍처 (명시적 Destroy 강제 없음, `bindLifetime`으로 매달아둔 자원이 inst와 함께 자동 소멸)가 실제로 성립함. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 16c803f..58e7457 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -197,8 +197,10 @@ quad/ **남은 것**: Slot 코어 로직의 정확한 API(`research`→`base` 승격된 `slot-plan.md` 참고)와 각 파일의 정확한 함수/타입 이름은 구현 단계에서. -existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을 -막지 않음(`purity-and-effects-plan.md`/`tween-plan.md`는 이미 `base/`로 승격 완료). +existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로 +이전**됐고(`archive/existing-instance-bind-rejected.md`), 애초에 이 구조 +확정을 막던 항목도 아니었음(`purity-and-effects-plan.md`/`tween-plan.md`는 +이미 `base/`로 승격 완료). ## 코드 스타일 — 네이밍 케이싱 (2026-08-08 두 번째 세션 신설) @@ -238,7 +240,7 @@ existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확 타입의 정적 결합 함수), 아니면(범용 유틸이거나 프리미티브가 아닌 엔진 소속) 소문자. `Dispatch`/`Brand`가 프리미티브가 아닌 이유는 `base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절/ - `base/store-semantics.md`의 "세 번째 카테고리 — Handler" 절 참고. + `base/source-state-plan.md`의 "일반 원칙 — 독립 존재 가능한 프리미티브 vs 원천에 종속된 파생 데이터" 절(세 번째 카테고리 문단) 참고. ## 코드 스타일 — Luau 문법 관례: `if-then-else`/`const` (2026-08-12 세션 신설) @@ -309,7 +311,7 @@ Store 생성 시 미리 만들어둔 경우), 아직 없으면 그 자리에서 "Store 생성 시 전부 eager하게만 만들어진다"로 한 차례 더 정리됐다가, Luau 타입이 런타임에 강제되지 않아 defaults 없이 만든 키를 나중에 `:Set()`하면 크래시난다는 점이 지적돼 lazy `__index`+저장 생성도 같이 필요함이 확인됨 — -상세는 `base/store-semantics.md` "Source가 State를 만족함" 절). 전파는 +상세는 `base/source-state-plan.md` "Source가 State를 만족함" 절). 전파는 push-invalidate(신호만)/ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림. State는 쓰기 대상이 아니고, 값을 쓰는 @@ -319,14 +321,18 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬 다룰 땐 Store와 별개인 가벼운 `Source` 프리미티브를 독립적으로도 씀. `store.key` dot-access를 타입 추론 1급 경로로 삼는 것도 3차 라운드에서 정식 확정됨 — **더 이상 열린 질문 아님**, 남은 건 정확한 API 이름뿐. -상세는 `base/store-semantics.md`의 "Source가 State를 만족함" 절과 -`base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. +상세는 `base/source-state-plan.md`의 "Source가 State를 만족함"/"핵심 +온톨로지" 절 참고. ## 아직 미정 (research/로 분리됨) -이미 생성된 인스턴스에 대한 바인드 — `.claude/research/existing-instance-bind-plan.md` -참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈 +**[2026-08-14 세션] 이 절에 유일하게 남아있던 항목(이미 생성된 인스턴스에 +대한 바인드)이 기각되어 `archive/existing-instance-bind-rejected.md`로 +이전됨** — 지금 `research/`에 남은 것은 전부 "착수 시점 미정"이지 +아키텍처를 미정으로 남기는 항목이 아님. 전체 색인은 `.claude/README.md`. +바인드 디스패치/Slot/모듈 라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는 위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨 -(`bind-system-plan.md`/`dispatch-core-plan.md`/`module-lifecycle-plan.md`/ +(`bind-system-plan.md`/`dispatch-core-plan.md`/`source-state-plan.md`/ +`store-plan.md`/`module-lifecycle-plan.md`/ `slot-plan.md`/`modifier-plan.md`/`component-composition-plan.md`). diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 8ed5627..6ab230c 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -34,8 +34,8 @@ Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attri 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸 2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute에도 적용해야 한다는 사용자 지적) — -`bind-system-plan.md`의 "Attribute 특수 키 — 타입 파라미터화" 절(2026-08-06 -신설) 내용을 그대로 옮기고, 논의한 `None`/`process`/`retract` 동작을 추가. +`bind-system-plan.md`의 Attribute 특수 키/타입 파라미터화 절(2026-08-06 +신설, 지금은 이 문서로 옮겨져 그쪽엔 색인만 남음) 내용을 그대로 옮기고, 논의한 `None`/`process`/`retract` 동작을 추가. ## 단일 키 — `AttributeKey<>` (구 `Attribute<>`) @@ -262,7 +262,7 @@ Store 필드 여러 개를 각각 `[AttributeKey<> "name"] = store.name`으 - **`Attribute`를 Store의 서브타입/확장으로**: Attribute가 Store를 상속(IS-A)하면 `Store`의 `T`가 다시 Attribute(=Store)일 수 있게 되어, 이미 확정된 제약("핸들러 계층 값은 Source에 못 들어감" — - `store-semantics.md`의 `Store`의 `T`는 Modifier 불가 규칙과 같은 + `base/source-state-plan.md`의 `Store`의 `T`는 Modifier 불가 규칙과 같은 이유)과 부딪히는 "Store 안에 Store"를 실제로 만들어냄. Attribute는 Store를 **참조(HAS-A)**만 해야지 **상속(IS-A)**하면 안 됨 — 기각. @@ -362,7 +362,8 @@ end 이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없음. - **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State 계약("값은 항상 선언된 Compute 재실행 결과, 캐시 비교 금지", - `store-semantics.md` "하드 경계" 절)과 어긋나고, `source`가 + `base/source-state-plan.md`의 "Source 값을 직접 mutate한 뒤 전파 — `:Emit()`" + 절 "하드 경계" 문단)과 어긋나고, `source`가 `State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다 해줌(그룹 Handler가 따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음. - **[확정, 2026-08-12 세션 후속, 사용자 결정] 클로저는 `setAttribute`를 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 0ecf0d0..08049c2 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -1,9 +1,9 @@ -# Bind 시스템 — 반응형 값 조합과 Store/State/Source 온톨로지 (base로 승격됨) +# Bind 시스템 — 인스턴스 생성·이벤트 네이밍 인체공학 + 분할 색인 -> **📄 [2026-08-13 열네 번째 세션] 분할 완료 — 이 문서는 이제 "반응형 -> 코어"만 담습니다.** 2989줄까지 불어나 사람이 검토할 수 없다는 사용자 -> 지적으로 시작된 분할이 2단계로 끝났음(내용/결정은 이동 자체로는 안 -> 바뀜): +> **📄 [2026-08-14 세션] 분할 3단계 완료 — 이 문서는 이제 "prop 테이블이 +> 인스턴스가 되는 인체공학"과 **분할 색인**만 담습니다.** 2989줄까지 불어나 +> 사람이 검토할 수 없다는 사용자 지적으로 시작된 분할이 3단계로 끝났음 +> (내용/결정은 이동 자체로는 안 바뀜): > > | 나간 것 | 어디로 | 단계 | > |---|---|---| @@ -11,10 +11,16 @@ > | 이벤트 바인딩(self 미전달, `false`로 disconnect) | `base/event-plan.md` | 1단계 | > | `Brand`(런타임 nominal 판별) | `base/brand-plan.md` | 1단계 | > | **디스패치 코어**(핸들러 계약 / 디스패치 모델 / `chains`·`retractFrom` / 체크리스트 / Length·Offset) | **`base/dispatch-core-plan.md`** | **2단계(14차 세션)** | +> | **반응형 코어**(Source/State 온톨로지·서브타입, 전파 모델, `:With`/`:Compute`/`:Apply`/`previous`, `Observer`, 구독·생명주기 게이트) | **`base/source-state-plan.md`** | **3단계(2026-08-14)** | +> | **Store**(이름 붙은 Source 모음, `defaults`, dot-access 타이핑, `:Set()` 문법, Store가 Store를 담는가) | **`base/store-plan.md`** | **3단계(2026-08-14)** | > > 2단계를 9차 세션이 미뤄뒀던 이유는 "0-A/0-Z 확정 시 그 텍스트가 어차피 > 전면 재작성 대상이라 같은 패스에서 갈라야 총 변경량·위험이 작다"였고, -> 실제로 14차 세션에 재작성과 분할을 같이 처리함. +> 실제로 14차 세션에 재작성과 분할을 같이 처리함. 3단계는 이 문서와 +> store-semantics.md가 같은 내용을 반씩 나눠 갖고 서로를 "상세는 저쪽 +> 참고"로 가리키던 걸 정리한 것 — **store-semantics.md는 이 분할로 +> 완전히 흡수되어 없어졌음**(내용은 `store-plan.md`/`source-state-plan.md` +> 둘로 갈라짐). **상태**: base — 핵심 디스패치 모델(`process` + 그가 반환하는 retract 클로저, 핸들러 3종 계약 — 2026-08-13 다섯 번째 세션에 별도 `retract` @@ -22,1080 +28,67 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 엔진은 `quad-base`가 인터페이스로 소유, `quad-roblox`는 실제 구현만)까지 전부 2026-08-04 세션에서 확정되어 `research/`에서 승격됨(`base/architecture.md`의 -"구현 착수: 소스 트리 구조 확정" 절 참고). 남은 건 세부 시그니처(dependency -array API) 뿐 — 구현 단계에서 자연히 정리됨. 원본: +"구현 착수: 소스 트리 구조 확정" 절 참고). 원본: `.claude/initreq/raw-userinput.md` "key와 value에 대한 바인드 연산은 pluggable 하도록 구성하기" / "스토어는 스토어를 저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 `reference/quad-v1-architecture.md` ("ProcessQuadProperty" 하드코딩 디스패처), 참고 패턴은 `.claude/initreq/tbox` (레지스트리)와 Fusion/Vide 비교는 `reference/comparison-fusion-vide.md` 참고. -## 디스패치 코어 — 전용 문서로 분리됨 (2026-08-13 열네 번째 세션) - -핸들러 계약(`isHandlable`/`priority`/`process`), 확정된 디스패치 모델, -`None` 센티널, `Dispatch`가 탑레벨 싱글톤인 이유, `chains` 인덱스 체인과 -`Dispatch.retractFrom`, Handler 작성 체크리스트, Length/Offset(형제 순서 -보장), "store 바인드는 래핑" 결론은 **`base/dispatch-core-plan.md`로 -분리**됐음 — 이 문서가 2989줄까지 불어나 사람이 검토할 수 없다는 지적으로 -시작된 분할의 2단계(1단계는 `ref-plan.md`/`event-plan.md`/`brand-plan.md`). -**같은 세션에 0-A/0-Z 확정으로 그 텍스트를 어차피 전면 재작성했기 때문에, -재작성과 분할을 한 패스에서 같이 처리함**(9차 세션이 "같은 텍스트를 두 번 -만지지 않기 위해" 의도적으로 미뤄둔 계획 그대로). - -## Store가 Store를 저장 가능한가 - -사용자 원 메모: "슬롯을 스토어처럼 생각 가능하다면 이건 가능하다고 봐야하는가? -아니면 아예 다른 값으로 둬야 하는가? table/number 같은 프리미티브 타입이나 -ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플러그를 만드는 걸로." - -**2026-08-04 6차 확정: 그런 경우는 없다고 본다.** 위에 적힌 "재실행 래핑으로 -기계적으로는 커버 가능하다"는 제안은 메커니즘상 틀리지 않지만, 실제 설계 -의도와 안 맞음 — Store는 Source에 준하는 존재로 모든 반응형 값의 "시작점" -역할만 함. 시작점은 다른 변화하는 무언가에 연결되는 것을 제공하고자 하지 -않음(= Store가 다른 Store/State를 값으로 담아 자동으로 따라가게 하는 용도로 -쓰지 않음). Store에서 값을 꺼내 State를 옵저빙하다가 콜백으로 다른 Store 값을 -바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의 -보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음. - -**[2026-08-13 세션, 스코프 명확화, 같은 날 다섯 번째 세션에 결론 갱신]** -이 절은 "Store *필드*가 Store/State를 담는가"(예: `store.a = otherStore`) -얘기이고, "State가 *emit하는 값*이 State/Source인가"(`State>`, -예: `store.key`에 대입된 값 자체가 State)는 다른 축. 이 절의 "별도로 -신경 쓰지 않음"(Store 필드 얘기)은 그대로 유지 — 후자(`State>`)는 -한때 실제 체인 파손 버그로 확인돼 `Dispatch.process`가 명시적으로 error -하도록 막았었으나, 같은 날 다섯 번째 세션에 `chains`의 인덱스 기반 -재설계로 그 버그의 근본 원인이 없어져 **지금은 정상 지원 대상** -(`base/dispatch-core-plan.md`의 "Dispatch 체인" 절 참고 — 열네 번째 -세션의 하강 diff로 깜빡임 방지 힌트까지 깊은 체인에서 유지됨) — "신경 안 씀"의 -의미가 "조용히 UB"도 "즉시 실패"도 아니라 "그냥 정상적으로 동작함"으로 -다시 한번 바뀜. - -## Ref / PreRef — 전용 문서로 분리됨 (2026-08-13 아홉 번째 세션) - -`Ref`/`PreRef`(용도 재정의, `.Value`/`:Set`/`:Callback`/`:Wait` API, -`Ref`의 retract, 이중 바인딩 금지, PreRef 호이스팅/1회용 가드)는 -**`base/ref-plan.md`로 분리**됨 — 이 문서가 3000줄에 육박해 분할한 -1단계. 내용/결정은 안 바뀜. - -## 이벤트 바인딩 — 전용 문서로 분리됨 (2026-08-13 아홉 번째 세션) - -"이벤트 핸들러는 self(Instance)를 받지 않는다"와 "이벤트도 store-bind -가능 — `false`로 disconnect" 두 절은 **`base/event-plan.md`로 분리**됨 -(사용자가 직접 지목한 분할 대상). 내용/결정은 안 바뀜. - -단 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 이 문서의 -"인스턴스 생성 / 이벤트 네이밍 인체공학" 절에 그대로 있음. - -## 여러 Store 값을 묶어 파생값 만들기 — `:With` + `:Compute`, 포지셔널 인자 지양 - -**사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각: - -- **암묵적 자동 추적(Vide식 ambient stack)** 기각 — "함수 실행 중과 끝 사이를 - 확인하고 부작용이 필요"한 방식이라 Lua에서 깔끔한 방법이 아니라고 판단. -- **명시적 디펜던시 배열 + 포지셔널 인자**(`Store.Combine({a,b}, function(av,bv) - ...)`)도 기각 — 두 가지 이유: (1) 팩토리 함수로 store-bind 처리기를 쉽게 못 - 만들어줌, (2) 여러 팩토리를 체이닝하면 인자 순서가 꼬일 수 있고, 타입 표기도 - 어려워짐. - -**채택 방향**: `:With(...)`로 필요한 의존성을 모으고, 그 뒤 `:Compute(function() -... end)`에서 **`with`한 값을 포지셔널 인자로 받지 않고 클로저로 직접 읽는다** -(정확히 어떤 방식으로 "직접 읽는지"는 2차 라운드에서 확정 — self/with 값 둘 다 -lazy State 핸들로 통일, 아래 "Store/State/Source 온톨로지" 절의 "`:With`/ -`:Compute`" 부분 참고). - -**`fn`을 커링 스타일로 짜는 것도 권장(2026-08-07 일곱 번째 세션)** — -`key:Compute(makeFormatter("ko-KR"))`처럼 팩토리가 실제 `fn`을 만들어 -반환하는 패턴, Observer/Effect의 동일 관용구(아래 "`fn`을 커링 스타일로 -짜는 것도 모듈화 관용구로 권장" 절, `base/effect-plan.md`)와 같은 결 — -`:Compute`가 원래부터 이 셋 중 제일 먼저 있던 자리라 뒤늦게 문서화된 -것뿐, 새 결정이라기보다 이미 있던 패턴을 명문화한 것. - -### 네이밍 — `Compute`가 `-ed`가 아닌 이유 (2026-08-12, `State` 용어 정리 라운드 후속) - -`Tag`의 `Added`/`Removed`, `Modifier`의 `Overridden`은 전부 `-ed`(과거분사) -어미를 의도적으로 씀 — `tag-plan.md`가 밝힌 이유는 "`Add`/`Remove`로 쓰면 -뮤테이션 API처럼 보이기 때문"(실제로는 항상 clone 후 즉시 확정된 새 값을 -반환). **`:Compute`/`:With`는 정반대 이유로 이 관례를 의도적으로 안 따름.** -Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"(이미 끝난 -일)가 정확한 묘사지만, `:Compute(fn)`이 만드는 State 노드는 **호출 시점엔 -`fn`을 등록만 해둔 것뿐이고 실제 계산은 나중에 `:Get()`이 pull할 때 -일어남**(push-invalidate/pull-recompute 모델, 아래 "Store/State/Source -온톨로지" 절) — 즉 호출 시점에 "computed"(이미 계산됨)라고 부르면 거짓. -`State`를 `Computed`로 리네임하는 안이 최종 기각된 것(`question.md` 1번)도 -같은 이유의 연장 — Vue `computed()`/Svelte `$derived`가 lazy인데도 그 -이름을 쓰는 건 그쪽 생태계에서 문제없지만, quad 자신의 코퍼스 안에서는 -"-ed 어미 = 이미 즉시 확정된 값"이라는 관례가 Tag/Modifier로 이미 자리 -잡아서, 같은 어미를 lazy한 것에 재사용하면 quad 자기 관례와 충돌해 오히려 -더 헷갈림. 그래서 `Compute`(동사 원형, "계산을 등록/설정한다"는 뜻)가 -`Computed`보다 quad의 명명 체계 안에서 정확함. - -### `:Compute(fn, ...)` — 추가 의존성을 trailing args로 직접 받는 sugar (2026-08-11) - -**문제 제기(사용자)**: React의 `useMemo(fn, deps)`처럼 `:With(...)` 없이 -`:Compute(fn, a, b, c)`로 바로 추가 의존성을 선언할 수 있으면 더 편하지 -않은가 — `self`가 이미 lazy 핸들로 `fn`에 넘어가는 구조라 값 언랩 방식이 -아니므로, 예전에 기각된 `Store.Combine({a,b}, function(av,bv)...)`(포지셔널 -값 언랩이라 타입 표기가 꼬였던 안)과는 다른 제안. - -**확정 — `Compute`엔 채택, `Observer`/`Effect`엔 채택 안 함. 근거는 "새 -노드가 실제로 생기는가"의 차이(사용자가 직접 구분).** - -- **`:Compute(fn, ...)`는 진짜 공짜 sugar.** `:Compute` 호출은 원래도 - 결과를 담을 새 State 노드(자기 자신의 계산 캐시 슬롯)를 만들어야 - 하므로, 그 노드가 `self` 말고 `a,b,c`에도 구독(무효화 엣지)을 추가로 - 거는 건 **이미 만들어지는 노드에 엣지만 더 얹는 것** — `:With(a,b,c):Compute(fn)` - 체인(노드 2개: pass-through With 노드 + Compute 노드)과 달리 노드가 - 안 늘어남(노드 1개). 구현은 `:With(...)`가 이미 하는 "구독 목록 확장" - 로직을 Compute 노드 생성 시점에 그대로 적용하는 것뿐 — 새 메커니즘 - 아님. -- **`Effect(fn, ...)`/`state:Observer(fn, ...)`류 trailing-args 확장은 - 기각 — 여기선 진짜 새 노드가 생기기 때문.** Effect/Observer는 Compute와 - 달리 **자기 자신이 결과를 담는 State 노드가 아님**(파생값을 안 만드는 - 순수 leaf 소비자, `base/store-semantics.md`의 "독립 프리미티브 vs - 파생 데이터" 분류에서도 확인되는 차이) — `state`(receiver) 하나만 - 구독 가능하므로, 의존성이 둘 이상이면 그걸 하나로 합칠 별도 노드가 - 필요하고 그게 바로 `:With(...)`가 만드는 새 노드임. 이건 절대 공짜가 - 아니라 **정말 비용이 드는 지점**이라, trailing args로 감춰버리면 "이 - 줄이 실제로 새 노드/구독을 만든다"는 걸 코드만 보고 알 수 없게 됨 — - `:With`가 clone 빌더가 아니라 진짜 노드로 확정됐던 이유(2026-08-07 세 - 번째 세션, "코드상의 호출 체인이 그래프 엣지와 1:1로 대응돼야 quad-debug - 그래프가 안 꼬임")와 정확히 같은 원칙. 그래서 다중 의존성 Effect/Observer는 - **`Effect(fn, state:With(a,b,c))`처럼 `:With` 호출을 코드에 그대로 - 노출**하도록 유지 — 새 노드가 생기는 지점을 sugar로 숨기지 않는다는 - 게 핵심. -- **일반 원칙으로 정리**: "trailing args sugar는 그게 정말 무료일 때만 - 붙인다 — 호출부가 이미 만들어야 하는 노드에 엣지만 얹는 경우(Compute)엔 - sugar, 없던 노드를 새로 만들어야 하는 경우(Effect/Observer의 다중 - 의존성 병합)엔 sugar 없이 `:With`를 명시적으로 남긴다." `quadnomicon` - 에세이 후보로 좋음(`research/documentation-content-map.md` 6번 항목 - 다음에 추가) — "왜 Compute만 여러 deps를 편하게 받고 Effect/Observer는 - 안 그런가"가 겉보기엔 비일관적으로 보이지만 실제로는 "숨겨지는 비용이 - 있는가"라는 하나의 원칙에서 나온 것이라는 게 소재. - -### trailing deps를 `fn`에 lazy positional 인자로도 노출 — 방향+순서(`fn(self, previous?, ...deps)`) 확정, 이형 다중 deps 표현 가능 여부만 실측 필요 (2026-08-11 후속) - -> **[2026-08-13 열세 번째 세션, 해소]** 이 절이 얹혀 있던 "self도 lazy -> 핸들로 통일" 계약(구 `question.md` 0-Y)이 **그대로 유지로 확정**됨 — -> 전제가 안 흔들리므로 이 절의 결론도 유효. 다만 이 절이 남겨둔 실측 -> 항목(이형 다중 deps를 제네릭 팩으로 표현 가능한지)은 **여전히 -> 미검증**임: 그 스파이크(`15`)가 파싱 실패 상태라 재작성이 필요하고, -> 재작성해도 반환 타입 쪽은 `base/typing-limits.md` 1번 한계에 똑같이 -> 걸림(명시 주석 바인딩으로 대응). - -**문제 제기(사용자)**: `:Compute(fn, a, b, c)`가 이미 `a,b,c`를 trailing -args로 받아 구독을 건다면, 그 값을 `fn(self, a, b, c)`처럼 위치 인자로도 -그대로 넘겨줘도 되지 않는가 — `:With`가 값을 포지셔널로 안 주는 이유는 -`:With(a):With(b):With(c)`처럼 체인이 여러 호출에 걸쳐 길어지면 최종 -합쳐진 노드가 몇 번째 인자로 뭘 받는지 추적하기 복잡해지기 때문인데, -`:Compute(fn, a, b, c)`의 trailing args는 그 호출문 **하나 안에 로컬하게** -다 드러나 있어서 같은 문제가 없다는 지적. - -**방향 확정 — 채택.** 지적이 정확함: - -- **`:With`가 회피하는 문제 자체가 여기엔 없음.** `:With` 체인의 위험은 - 의존성 목록이 여러 호출/여러 스코프에 걸쳐 누적될 수 있어("체인이 - 길어지면 순서 지키기가 복잡") 최종 위치 매핑을 코드 한 줄만 보고 - 못 읽는다는 것 — `:Compute(fn, a, b, c)`는 그 반대로 한 호출문의 - 인자 목록 자체가 곧 최종 순서라 누적/추적 문제가 원천적으로 없음. -- **실질적 이득 — 커링 패턴에서의 중복/드리프트 위험 제거.** 지금 - 설계(trailing args는 구독 등록 전용, 값은 closure로 재획득)로 - `:Compute`를 커링 스타일(위 "`fn`을 커링 스타일로 짜는 것도 권장" 절)과 - 같이 쓰면 `a, b`를 **두 번** 써야 함 — 한 번은 `makeComputer(f, a, b)`의 - 클로저 캡처용, 한 번은 `:Compute(fn, a, b)`의 trailing args(구독 - 등록용). 리팩터링 중 한쪽만 바뀌면 "구독은 `a`에 걸려있는데 실제로 - 읽는 값은 `a'`"인 조용한 버그가 생길 수 있음. 값을 `fn`의 위치 - 인자로 노출하면 `makeComputer(f)`가 `a,b`를 아예 몰라도 되고 - (`function(self, a, b) return f(self:Get(), a:Get(), b:Get()) end`), - `:Compute`의 trailing args 목록 하나가 "무엇을 구독하는가"와 "`fn`이 - 몇 번째 인자로 뭘 받는가" 둘 다의 유일한 소스가 됨 — 중복 자체가 사라짐. -- **`self`가 이미 raw 값이 아니라 lazy 핸들로 넘어가는 원칙을 trailing - deps에도 그대로 적용** — `fn(self: State, dep1: State, dep2: - State, ...)`, 각 `depN:Get()`을 실제로 호출할 때만 그 값의 계산이 - 트리거됨. self에 대해 이미 확정된 "조건부로 특정 값을 아예 안 읽고 - 건너뛸 수 있음"이라는 이점이 trailing deps에도 똑같이 적용됨. - -**`previous`(아래 절, 2026-08-06)와의 위치 충돌 — 사용자 정정으로 확정, -`fn(self, previous?, ...deps)`.** 처음엔 "`previous`를 dep 개수와 무관하게 -항상 마지막 인자로 고정"(`fn(self, dep1, ..., depN, previous?)`)을 -제안했으나 **틀림 — 사용자가 정정**: Luau 값 레벨 `...`(vararg)가 -파라미터 리스트 맨 끝에만 올 수 있는 것과 똑같이, 타입 레벨 제네릭 팩 -(`...U`)도 함수 타입 시그니처에서 **항상 맨 끝**이어야 함(팩이 나머지 -자리를 전부 채우는 개념이라 그 뒤에 고정 타입이 하나 더 오는 건 Luau -타입 문법 자체가 원천적으로 허용 안 할 가능성이 매우 높음 — 이건 "안 -될 수도 있는 불확실성"이 아니라 "거의 확실히 안 되는 문법 제약"에 가까움). -반대로 **`previous`를 `self` 바로 다음, deps 팩 앞에 두면**(`fn(self, -previous?, dep1, dep2, ..., depN)`) 고정 인자 다음에 팩이 오는 정상적인 -모양이 되어 이 제약과 안 부딪힘 — **이게 유일하게 구조적으로 안전한 -순서라 이걸로 확정**. `N=0`이면 기존 `fn(self, previous?)`로 그대로 -축약되므로 하위 호환도 유지됨. **트레이드오프**: `previous`를 안 쓰고 -deps만 받고 싶어도 `previous`가 2번째 자리를 차지하므로, 그 경우 호출부는 -`function(self, _, dep1, dep2) ... end`처럼 안 쓰는 자리를 이름으로라도 -비워둬야 함 — deps만 쓰는 흔한 케이스가 약간 불편해지지만, Luau 문법 -제약상 다른 선택지가 없음(대안은 애초에 이 확장 자체를 안 하는 것뿐). - -**실측 필요 — `luau-test`의 `15-type-compute-trailing-deps-typepack.luau` -신규(ROADMAP.md M3 반영).** 순서 문제 자체는 위 정정으로 구조적으로 -풀렸으므로, 스파이크가 실제로 확인할 진짜 불확실성은 (B) 하나로 좁혀짐 — -나머지는 그 결론을 뒷받침하는 대조군: (A) 균일 타입 dep 1개를 고정 -인자로 좁히는 대조군(실패하면 B/C/D를 볼 것도 없이 기반 자체가 문제), -(B) 이형(heterogeneous) 타입 dep 여러 개를 제네릭 팩 하나로 정확히 -좁혀 받을 수 있는지(안 되면 위치 인자 노출 자체를 동종 타입 dep 1개로 -한정), (C) 처음 제안했던(틀린) "팩 뒤에 `previous?`" 순서가 실제로 -막히는지 보여주는 음성 대조군(막혀야 정상), (D) 정정된 "`previous?` 뒤에 -팩" 순서가 통과하는지 보여주는 양성 대조군(통과해야 정상 — 예상과 -다르게 C가 통과하거나 D가 막히면 이 순서 결정 자체를 재검토). - -### `:Compute(fn)`의 선택적 두 번째 인자 — `previous` (무거운 파생 객체 재사용, 2026-08-06) - -**배경**: `:Compute`의 결과가 그 자체로 무겁고 재생성 비용이 큰 엔진 -객체일 수 있음(예: 큰 로케일 테이블을 Roblox `LocalizationTable` -Instance로 변환하는 경우 — `LocalizationTable`은 `Set`/`Get`/`List`로 -부분 갱신 가능한 userdata). 매번 새로 만들지 않고 이전 결과를 그대로 -재사용해 필드만 patch하고 싶을 때를 위해, `fn(value, previous)` 형태로 -**직전에 이 Compute 함수가 반환했던 값**을 두 번째 인자로 받을 수 있게 -한다. - -- **opt-in**: 안 쓰는 Compute 함수는 두 번째 인자를 그냥 무시하면 됨 — - 비용 0. 대부분의 Compute는 이걸 쓸 필요 없음. -- **`previous`는 "바로 직전 버전"이 보장되지 않음.** lazy pull 모델이라 - 중간에 여러 번 무효화됐어도 실제로 관측(`Get()`) 안 됐으면 재계산 - 자체가 안 일어남 — 그래서 `previous`는 몇 세대 전 값인지 알 수 없음. - **따라서 `previous`를 다루는 로직은 반드시 "현재 입력 전체 대 이전 - 결과 전체"의 full diff여야 하고, "정확히 한 단계 전"이라고 가정하는 - incremental delta 로직을 짜면 안 됨.** 이건 React 자체의 reconciler가 - 하는 것과 같은 모양(old tree/new tree 전체 비교 후 실제 host 객체에 - 패치 적용)이라 새로 발명하는 패턴은 아님. -- 최종 소비처가 patch된 값을 다시 한번 Set/Parent하게 되는 경우가 - 있어도(레퍼런스는 같은데 다시 대입) 대체로 치명적이지 않음(Roblox - 프로퍼티 재대입은 저렴/멱등인 경우가 대부분) — 문서화만 해두면 충분. - -**⚠️ 이 패턴을 쓸 때 반드시 같이 지켜야 하는 것 — "확정(관측)되기 전엔 -연산이 없다".** `previous`를 mutate하는 로직은 Compute 함수 **본문 -안**에 있으므로, 그 함수가 재실행되지 않으면(=아무도 다시 `Get()`하지 -않으면) mutation 코드 자체가 아예 실행되지 않는다 — 단순히 "가끔 -stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. 이 패턴으로 -만든 State는 반드시 다음 중 하나로 계속 능동적으로 관측되어야 함: -1. quad의 정상적인 선언적 prop 바인딩 경로(`[Property "X"] = someState` - 류)에 실제로 물려있어서, dispatch 엔진이 무효화 시 자동으로 - 재`Get()`하게 되어 있거나, -2. 아래 "Observer" 절의 `state:Observer(fn)` + 콜백 안에서 명시적 - `Get()` 호출 + 그 결과를 children 배열에 넣어 라이프사이클에 - 묶어두기. -"Ref로 한 번 얻어서 수동으로 Parent만 하고 끝"처럼 능동적 관측 경로가 -안 남아있으면, 이 최적화는 그냥 조용히 작동을 멈춘다. - -**[2026-08-09 세션] 오버엔지니어링 의심 재검토 — 기각, 현재 설계 -유지.** `research/pre-implementation-audit.md` 3-1이 "클로저 업밸류로 -이미 되는 걸 별도 API로 만든 것 아니냐"고 의심했던 것에 대한 사용자 -반박: 클로저 업밸류 대안은 실제로 다음처럼 즉시실행함수(IIFE)로 감싸 -업밸류를 준비해야 함 — - -```lua -local computeFn = (function() - local prev - return function(self) - -- prev를 읽고 새 값을 계산, prev 갱신 - prev = ... - return prev - end -end)() -someSource:Compute(computeFn) -``` - -이 준비 코드 자체가 이미 별도 `previous` 인자 하나보다 무겁고 번거로움 -— "재사용하고 싶으면 그냥 캐시된 값을 바로 넘겨주면 되는" 게 더 -단순하다는 게 사용자 논거. 반대로 `previous`가 없으면 `fn`은 매 호출마다 -새 인스턴스를 만들어야 해서(예: `LocalizationTable.new()`) lazy든 -아니든 재계산이 일어날 때마다 항상 비싼 재생성이 발생 — `previous`가 -막으려는 문제는 실재함. **`pre-implementation-audit.md` 3-1 해소 — -현재 `fn(self, previous)` 설계 그대로 유지, API 표면을 줄이지 않음.** - -**스코핑 명확화(이번 세션에 확인, 새 결정 아님) — `previous`는 `self` -(입력)가 아니라 "이 `:Compute` 호출 하나가 만들어낸 결과 State 노드" -자신에 귀속된다.** State가 `:With`/`:Compute` 호출마다 새 노드를 -만든다는 건 이미 확정된 온톨로지(아래 "왜 State 체인을 Modifier처럼 -플래튼하지 않는가" 절)라, `previous`도 그 새 노드의 내부 캐시 슬롯일 -뿐 `self`에 얹히는 게 아님 — 같은 `self`에서 여러 `:Compute`가 갈라지는 -팬아웃(`c1 = w:Compute(g1)`, `c2 = w:Compute(g2)`)이 있어도 `g1`/`g2` -각자의 `previous`는 각자의 결과 노드에 독립적으로 저장되므로 서로 안 -섞임 — 새로 결정할 것 없이 기존 "노드별 캐시" 원칙의 당연한 귀결. -(참고: `self.Cache`처럼 `self` — 즉 입력 — 에 캐시를 얹는 모양은 이 -스코핑과 안 맞아 채택하지 않음 — 팬아웃 시 여러 소비자가 같은 -`self.Cache` 슬롯을 공유해 덮어쓰는 충돌이 생기기 때문.) - -### `state:Observer(fn)` — 값을 안 실어주는 구독, children 배열에 직접 놓는 leaf 값 - -**결정(2026-08-06 후속 세션, 사용자 확정)**: 별도 `ObserverHolder` -래퍼 타입은 안 만듦 — `state:Observer(fn)`가 반환하는 값 자체가 이미 -"children 배열에 바로 놓을 수 있는 leaf 값"이라 감쌀 필요가 없음. -`Ref`와 완전히 같은 층위. **자유 함수 `Observer(state, fn)`가 -아니라 메소드 `state:Observer(fn)`로 확정** — `state`가 항상 필요한 -필수 인자라 `:` 리시버 자리에 자연스럽게 들어가고(다른 형태면 인자 -두 개짜리 자유 함수가 되어 읽는 순서가 어색해짐), `architecture.md`의 -"함수지향 디폴트, `:` 체이닝은 예외적으로만(체이닝이 정말 편한 경우만)" -원칙이 정확히 이 경우를 가리킴 — Store 값 변경 체이닝과 같은 예외 -카테고리. **더 근본적인 이유**: `base/store-semantics.md`의 "독립 존재 -가능한 프리미티브 vs 원천에 종속된 파생 데이터" 원칙 참고 — Observer는 -State처럼 원천 없이는 존재할 수 없는 파생 데이터라, 애초에 "타입 -이름을 부르는 자유 함수 생성자" 카테고리에 안 속함(Source/Ref/Store/ -Modifier와는 다른 부류). - -```lua -local observer = state:Observer(function() - state:Get() -end) - -Frame { - observer, -} -``` - -이러면 `observer`는 `Frame`이 살아있는 동안만 유지되고, `Frame`이 -retract/Destroy되면 자동으로 정리됨. - -- **`fn`은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션, - 사용자 확정 — 이전까지 미명시였던 항목).** 근거: (1) 이미 채워진 - State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안 - 일어나는 문제가 생겨 초기화 순서에 디버깅 부담이 생김. (2) 초회 - 실행을 하지 말아야 할 구체적 근거가 약함. (3) **이 결정 덕에 - Observer 하나로 "초기값 적용"과 "이후 변경 반영"을 같은 코드 경로로 - 통일할 수 있음** — 예: State→프로퍼티 store-bind 핸들러가 그냥 - `state:Observer(function() inst.SomeProp = state:Get() end)`를 걸어 - 두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를 - 따로 안 짜도 됨). `state:Observer()`(인자 없는 "항상 관측" 유틸)도 - 이 규칙을 그대로 따름 — 호출 즉시 한 번 관측이 트리거됨. -- **값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함.** 기존 "emit은 - 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(아래 - "Store/State/Source 온톨로지" 절)이 그대로 적용됨: `fn`은 "뭔가 - 바뀌었으니 다시 확인하라"는 신호만 받고 새 값 자체는 안 받음 — - 위 예시처럼 `fn` 본문에서 `state:Get()`을 명시적으로 다시 - 읽어야 함. 자동으로 안 해주는 이유: 재계산이 진짜 필요한지가 다른 - `:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 - `noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()` - 호출 여부를 작성자가 직접 결정하게 열어둔 것. -- **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯 - 번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제 - `fn`을 만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링(`modifier-plan.md` - 8번)과 같은 결. `base/effect-plan.md`의 Effect도 동일하게 권장. -- **base가 제공하는 것은 `isObserver`류 타입 판별자 하나** — children - 배열 dispatch가 숫자 슬롯 값을 훑을 때 "이게 Observer인가"를 판별해 - `Ref`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 - 더 해줄 일이 없음. 새 dispatch 메커니즘이 아니라 기존 children-array - 참가자 패턴의 반복. -- **콜백 실행은 기존 `canExecute` predicate로 게이팅**(Slot 생존 확인과 - 동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님) - — 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op. - **[명시화, 2026-08-14 다섯 번째 세션] 이 게이팅이 일어나는 자리는 State의 - 전파 루프**다 — State는 구독자를 **weak로** 담고, 발화 시 각 구독자마다 - `canExecute(observer)`를 확인해 거짓이면 그 구독자만 건너뜀. 여기에 - `inst`가 없다는 사실이 `canExecute`가 `value` 하나만 받아야 하는 - 이유(`base/lifecycle-pattern.md`의 "실제 호출부" 절, 옛 2-인자 - 시그니처의 역전 경위는 `archive/canexecute-inst-arg-reversed.md`). - 구독자를 weak로 담아도 되는 이유는 살려두는 책임이 State가 아니라 - `gchold`(leaf) 또는 전역 `Subscribed` 레지스트리에 있기 때문 — 어디에도 - 안 묶인 Observer는 GC되어 구독 목록에서 자연히 빠짐. -- **구현 노트(사용자 제안, 확정된 아키텍처는 아니고 구현 시 참고)**: - 살아있는 Observer 집합을 Observer 값 내부 필드로 안 두고, 외부에 - weak table(`{[observer] = true}`, `__mode = "k"`)로 인덱싱하는 방식을 - 선호 — 포인터 해싱 비용만 들고 값 자체엔 부작용 없음. rbvm의 - `getNamespaceOf`류가 비슷한 외부 weak-table 인덱싱을 씀 - (`base/lifecycle-pattern.md` 참고). -- **인자 없는 `state:Observer()` — "항상 관측" 유틸.** `fn`을 생략하면 - 내부적으로 no-op 콜백을 쓰는 것으로 취급해, 그냥 "이 State를 계속 - 능동적으로 관측 상태로 유지"하는 용도로만 씀. 위 "`previous` 인자" - 절의 캐비엇("능동적 관측 경로가 안 남아있으면 mutate 로직이 조용히 - 멈춘다")을 만족시키는 가장 단순한 도구 — 별도 콜백 로직 없이 그냥 - 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 - 하면 별문제 없음(사용자 판단). - -### `state:Apply(factory)` — Modifier와 동일한 순수 체이닝 설탕으로 확정 (2026-08-07 일곱 번째 세션) - -**처음 제안됐던 "`:With`/`:Compute` 등록을 커링으로 자동화하는 조합기" -방향은 기각됨 — 사용자가 재확인한 실제 의도는 그보다 훨씬 단순함.** -`Modifier:Apply(factory)`도 매번 새 값을 만들어내는 체이닝 설탕일 뿐이듯, -State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양이라 — -`state:Apply(factory)`는 그냥 `factory(state)`를 메소드 체이닝 문법으로 -쓴 것뿐이고 그 이상의 계약은 없음(`Modifier:Apply`와 완전히 동일한 -정의: `function(self, factory) return factory(self) end`). - -- **동기**: 커링 팩토리 두 개 이상을 이미 있는 문법만으로 이으면 바깥에서 - 안으로 겹쳐 읽어야 하는 중첩 호출이 됨 — 실제 형태로 예를 들면, - ```lua - -- Apply 없이: 안쪽(가장 최근에 만든 것)부터 거꾸로 읽어야 함 - local capped = capAt(100)(withLocale(localeStore.locale)(rawScore)) - - -- state:Apply로: 왼쪽에서 오른쪽, 만든 순서 그대로 읽힘 - local capped = rawScore - :Apply(withLocale(localeStore.locale)) - :Apply(capAt(100)) - ``` - 팩토리가 세 개, 네 개로 늘어날수록 앞쪽 버전은 괄호 깊이와 읽는 방향이 - 코드 작성 순서와 반대로 꼬여 diff/리뷰에서 특히 안 좋음 — `:Apply` - 버전은 각 줄이 "그다음 뭘 했는지"를 순서대로 나열하므로 Modifier - 체이닝(`mod:FontSize(14):Apply(Boldify(10)):Apply(Italicify)`)과 읽는 - 방식이 완전히 통일됨. `:With`/`:Compute` 자체를 대신 호출해주는 - 자동화가 아니므로, 여전히 팩토리 본문 안에서 `:With`/`:Compute`를 - 직접 호출하는 건 팩토리 작성자 몫. -- **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`로 - 필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만 - 존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌 - 자체가 없음 — 그냥 고정 메소드 하나 추가하는 것. -- **타입은 `factory: (State) -> U): U`로 완전히 열어둠** — Modifier의 - `Apply`는 `factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이 - 이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종 - 요약된 plain 값)을 반환해 반응형 그래프를 벗어나는 탈출구로 쓰는 것도 - 막을 이유가 없음 — Modifier보다 오히려 더 자유로운 시그니처. -- **Source도 자동 포함**: Source가 State를 구조적으로 만족하는 기존 - 델리게이션(`__index`로 `:With`/`:Compute` 위임)에 `:Apply`도 그대로 - 얹히므로 별도 구현 불필요. -- **Effect/Observer/Compute의 `fn` 커링 권장(위 절들)과 같은 스레드지만 - 별개 기능** — 커링은 "`fn` 자체를 팩토리로 짜는 관용구" 권장이고, - `:Apply`는 그렇게 만든 팩토리를 체이닝 문법으로 적용하는 수단. 둘이 - 합쳐지면 `state:Apply(makeFormatter("ko-KR"))`처럼 자연스럽게 이어짐. -- **관용구 — 이름 붙여 재사용하는 콤비네이터는 항상 `:Apply`로 붙인다 - (2026-08-12 세션, `research/operator-sugar-plan.md`/`research/ - tween-plan.md`의 `Animate` 정정에서 도출)**: 그 자리에서 한 번 쓰고 - 마는 인라인 람다(deps도 그 호출문에 바로 나열)는 `:Compute(fn, - ...deps)`를 직접 쓰고, `local addTax = Sum(tax, shipping)`처럼 이름 - 붙여 여러 곳에서 재사용할 콤비네이터는 인자 개수(0항/N항)와 무관하게 - 전부 `factory(self) -> State`를 반환해 `:Apply`로 붙임 — 스타일 - 선호가 아니라 정합성 문제: quad는 암묵적 자동 추적을 기각했으므로 - (위 "암묵적 자동 추적 기각" 절) 재사용 팩토리가 캡처한 deps를 - `:Compute`에 직접 꽂으면 그 deps가 구독 목록에 안 걸려 조용히 - 멈추는 버그가 됨 — `:Apply`는 factory 내부에서 `self:Compute(fn, - ...deps)`를 스스로 다시 전달하므로 이 문제가 없음. - -**Observer/Effect의 `:Subscribe()`/`:Unsubscribe()`는 이 절과 무관한 -별개 주제** — 아래 새 절로 분리(이전에 이 헤더 아래 잘못 걸려 있던 -문서 버그 수정, 내용 자체는 이미 확정된 것 그대로). - -### Observer의 `:Subscribe()`/`:Unsubscribe()` — children 배열 밖 독립 구독 (2026-08-06 후속 세션) - -**문제**: children 배열에 넣는 자동 라이프사이클 바인딩은 Observer가 -"어딘가 leaf에 붙어있다"는 걸 전제함. 근데 흔한 실사용 패턴 하나가 이 -전제를 깨뜨림 — 개발자가 디버깅용으로 `RunService:IsStudio()` 가드 -안에서 Store에 직접 Observer를 걸어 `print`하는 패턴(원하면 BooleanValue -로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인 -엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table -기반 자동 추적이 적용 안 됨. **[용어 정정, 2026-08-09 여섯 번째 세션]** -여기서 "weak-table 기반 자동 추적"이라 부른 것이 나중에 정식으로 -`bindLifetime`(`base/lifecycle-pattern.md`)으로 명명됨 — 별도 메커니즘 -두 개가 아니라 같은 것의 명명 전/후 표현. - -**해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새 -설계가 아니라 `bind-system-plan.md`의 PA님 코드 교차검증(라이프사이클 -절)에서 이미 예고해둔 확장 지점을 실제로 채우는 것 — "나중에 GC만으로 -정말 부족한 케이스가 생기면 명시적 dispose 경로를 추가로 얹는 게 가능한 -디자인"이라고 그때 이미 못박아뒀음. - -- **`local` 변수로 참조만 들고 있는 것으로는 부족한 이유**: 토글(BooleanValue로 - 로깅 껐다 켰다) 케이스에서, 참조를 끊어도 실제 GC는 결정론적으로 즉시 - 일어나지 않음 — "껐다"고 생각한 뒤에도 한동안 계속 발화할 수 있음. - `:Unsubscribe()`는 즉시/결정론적으로 끊는 경로라 이 문제가 없음. -- **liveness 체크는 두 경로를 하나의 predicate로 OR 묶음**(사용자 제안) — - 자동(리프 부착=`bindLifetime`)/수동(전역 `:Subscribe()`) 두 라이프사이클 - 경로를 `canExecute(value)` 하나가 답함: - ```lua - -- 개념 스케치. 확정 구현은 base/lifecycle-pattern.md가 소스 - local gcconn = BindData:GetWeak(self, "gcconn") -- leaf 경로(bindLifetime이 복사해둠) - if gcconn ~= nil and gcconn.Connected then return true end - return self.Subscribed == true -- 전역 경로(:Subscribe()만 세팅) - ``` - **[정정, 2026-08-14 다섯 번째 세션]** 이 절의 옛 스케치는 `self.Subscribed`를 - 먼저 보고 `self.Connection`을 폴백으로 두는 모양이었는데, `.Subscribed`는 - **전역 경로 전용 필드라 리프 경로와 무관**하므로 우선순위 자체가 의미 - 없음(두 경로는 상호 배타라 OR 순서는 성능 취향일 뿐). "필드 접근이 weak - table 조회보다 싸다"는 관찰은 유효하지만, 그건 `.Subscribed`를 리프 - 경로에도 겸용하라는 근거가 못 됨 — 실제로 2026-08-08 세션이 그렇게 - 겸용했다가 `canExecute` 시그니처까지 오염됐음 - (`archive/canexecute-inst-arg-reversed.md`). 실측은 구현 단계에서 확인. -- **내부 강참조 레지스트리**: `SubscribedObservers: {[observer]: true}`류를 - **weak 아닌 강참조**로 둠 — 여기서 weak면 "구독해서 살려둔다"는 목적 - 자체가 무의미해짐. 위 자동 케이스의 weak table과 역할이 명확히 갈림 - (weak table=자동/리프 전용, 강참조 레지스트리=수동 구독 전용). - **`:Unsubscribe()`는 이 레지스트리에서 반드시 `SubscribedObservers[observer] - = nil`까지 해야 함** — `Subscribed` 플래그만 내리고 강참조를 안 끊으면 - GC 대상이 안 되는 반쪽짜리 해제가 됨, 둘은 항상 같이 일어나는 한 세트. -- **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 - 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 - no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. -- **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프) - 케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기 - 해제는 `unbindLifetime(value)`가 담당, `:Unsubscribe()`는 - 전역 강참조 레지스트리 경로 전용으로 남음.** 둘이 지우는 대상이 서로 - 다르기 때문 — `:Unsubscribe()`는 전역 레지스트리와 `.Subscribed` 필드를, - `unbindLifetime`은 `inst`의 gchold 항목과 `value`가 들고 있던 gcconn - 참조를 지움. 위 "이중 바인딩 금지" 절의 정정 참고. - **[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 들었던 이유(*"`inst`를 - 모르는 `:Unsubscribe()`가 어느 `inst`에 등록했는지 찾아낼 방법이 없다"*)는 - 이제 성립 안 함 — `unbindLifetime`도 `inst`를 안 받고 `value` 하나로 - 해제함(`value`가 자기 홀더를 알고 있음). 결론(두 함수를 안 합침)은 - 그대로지만 근거가 "찾을 수 없어서"가 아니라 "지우는 대상이 달라서"로 - 바뀜. -- **`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 담아도 정상** - — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 - 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 - 포인트). -- **⚠️ 이건 quad 전역의 "정리는 기본적으로 GC에 위임" 원칙의 의도적 - 예외 — 문서에 명시적으로 경고할 것(2026-08-09 열한 번째 세션).** - `:Subscribe()`로 등록한 뒤 로컬 변수 참조를 전부 놓아도(스코프 이탈, - 변수 재할당 등) **GC되지 않고 영원히 계속 실행됨** — 강참조 - 레지스트리가 그 자체로 생존을 보장하기 때문. `bindLifetime`(leaf - 부착 포함) 경로는 `inst`가 죽으면 자동으로 정리되는 GC-native 그대로지만, - `:Subscribe()` 경로는 오직 명시적 `:Unsubscribe()` 호출로만 끊김 — 이 - 차이를 모르고 "quad는 다 GC-native니까 참조만 버리면 되겠지"라고 - 가정하면 조용한 누수(메모리뿐 아니라 계속 재실행되는 콜백까지)로 - 이어짐. 용도도 "완전히 top-level(어떤 Instance 생명주기에도 안 묶인) - 사이드 이펙트"로 좁게 문서화할 것 — 특정 `inst`에 묶인 경우는 - `:Subscribe()`가 아니라 leaf 부착(`bindLifetime`)이 정상 경로. -- **`:Subscribe()`/`:Unsubscribe()` 둘 다 `self`를 리턴(대칭)** — - `local obs = state:Observer(fn):Subscribe()`처럼 "구독 시작 + 나중에 - 끊을 핸들 확보"가 한 줄로 되고, `table.insert(subs, state:Observer(fn) - :Subscribe())`처럼 리스트에 담을 때도 줄바꿈 없이 됨. Observer가 - immutable 값이 아니라 원래 mutable한 구독 핸들이라 fluent 체이닝이 - 자연스러움 — Modifier의 clone-then-return 체이닝과는 다른 이유(같은 - 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 - 체이닝 가능. - -### 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canExecute(value)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 `canBound`로 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정, **2026-08-14 다섯 번째 세션에 `canBound` 폐기·`canExecute`로 통합**) - -**규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 -딱 하나만 가질 수 있음 — `:Subscribe()`로 전역 강참조 레지스트리에 -등록되거나(위 절), `bindLifetime(inst, value)`로 특정 `inst`에 종속되거나 -(아래 "`bindLifetime`도 같은 게이트를 공유" 절) — **이 둘 중 하나만**. - -**[정정, 2026-08-09 여섯 번째 세션] "leaf 부착"은 세 번째 독립 경로가 -아니라 `bindLifetime`을 호출하는 것 그 자체다.** `Frame { observer }`처럼 -children 배열에 Observer를 직접 놓으면, `Dispatch/Leaf.luau`가 이걸 -매치해 내부적으로 `bindLifetime(inst, observer)`를 호출 — "children -배열에 놓여 leaf에 자동 부착"과 "`bindLifetime`으로 특정 `inst`에 -종속"은 **같은 동작**이라 서로 배타적일 수 없음(둘 다 하는 게 아니라 -leaf 부착이 곧 `bindLifetime` 호출 방식 중 하나일 뿐). 그래서 실제 -상호 배타는 "전역 소유(`:Subscribe()`)" vs "특정 `inst` 소유 -(`bindLifetime`, 직접 호출이든 leaf 부착을 통한 호출이든)"라는 -**2-way**로 정정 — 위 "Observer의 `:Subscribe()`/`:Unsubscribe()`" 절이 -leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifetime`이 -정식 이름을 얻기 전(2026-08-06 후속 세션) 표현이라 지금은 같은 것을 -가리킴 — 별도 메커니즘 두 개가 있던 게 아니었음. - -**둘 이상 동시에 걸리는 건 UB로 확정** — 이미 한 경로로 바인딩된 핸들을 -다른 경로로 또 바인딩하는 건 금지(leaf로 이미 부착된 걸 `:Subscribe()` -하는 것, 또는 그 반대). 같은 값을 `bindLifetime`으로 두 번(leaf 부착 -한 번 + 직접 호출 한 번, 또는 leaf로 두 Instance에 부착) 등록하려는 -것도 걸림 — 이건 "leaf vs bindLifetime 충돌"이 아니라 "같은 단일 -메커니즘을 중복 호출"하는 것이라 자연히 같은 게이트가 잡아줌. - -**UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 -0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 -바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈. - -**[역전, 2026-08-14 다섯 번째 세션] 별도 predicate `canBound(handle)`은 -폐기하고 `canExecute(value)` 하나로 통합.** 게이트는 이 모양: - -```lua --- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침) --- — 둘 다 진입 전 동일하게 확인 -if canExecute(self) then - error(if self.Subscribed - then "이미 :Subscribe()로 전역 바인딩된 값" - else "이미 다른 Instance에 바인딩된 값") -end -``` - -- **"이미 유효하게 묶여 있다"와 "지금 실행 가능하다"가 정확히 같은 - 조건**이라 predicate를 둘로 나눌 이유가 없었음 — `canExecute`가 - 참이면 그 값은 어딘가에 살아있는 바인딩을 갖고 있다는 뜻이고, 그게 - 곧 "새로 묶으면 안 된다"임. -- **에러 메시지에서 어느 경로인지는 `.Subscribed`로 가름** — 이 필드는 - **전역 `:Subscribe()` 경로에서만 세팅되므로**(아래 정정) 참이면 전역, - 거짓인데 `canExecute`가 참이면 leaf 경로. -- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 유효한 - 바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canExecute`를 확인하므로 - 순서와 무관하게 대칭적으로 막힘. -- **죽은 바인딩의 재사용은 허용** — `inst`가 Destroy됐거나 - `unbindLifetime`된 값은 `canExecute`가 거짓이라 게이트를 통과함(다른 - `inst`에 다시 걸 수 있음). 게이트가 막는 건 **살아있는** 이중 바인딩뿐. - -**[정정, 2026-08-14 다섯 번째 세션] 옛 서술 — "`canBound`의 내부 플래그는 -`canExecute`가 이미 보는 `.Subscribed` 필드 그 자체이고, `bindLifetime`도 -그 필드를 세팅한다"(2026-08-09 여섯 번째 세션)는 틀렸음.** -`.Subscribed`는 **전역 `:Subscribe()`/`:Unsubscribe()` 전용 필드로, -`bindLifetime`/`unbindLifetime`과는 일절 이해관계가 없다** — 이 둘은 -그 필드를 읽지도 쓰지도 않음. leaf 경로의 생존은 `bindLifetime`이 -`value` 쪽 릴레이션에 복사해둔 gcconn 참조로 판정됨(`base/lifecycle-pattern.md`). -옛 서술이 걱정했던 "필드를 둘로 나누면 `bindLifetime`으로만 등록된 -Observer가 `canExecute`에서 항상 `false`로 오판됨"은 실제로는 안 일어남 -— `canExecute`가 gcconn 경로를 **먼저** 보기 때문. 역전 원문·오염 경로· -교훈은 `archive/canexecute-inst-arg-reversed.md`. -- **`:Unsubscribe()`는 `:Subscribe()` 경로의 해제만 담당, `bindLifetime` - (leaf 부착 포함) 경로는 `unbindLifetime(value)`로 해제** — - 둘은 서로 다른 함수로 남음(호출자가 `bindLifetime`을 부른 쪽이 - `unbindLifetime`도 대칭적으로 부르는 책임을 짐). 지우는 대상이 - 서로 다르므로 하나로 합칠 수 없음 — 위 `:Subscribe()` 절의 같은 - 정정(2026-08-14 다섯 번째 세션) 참고. leaf 부착으로 - 세워진 바인딩의 실제 해제도(예: Instance 파괴 전 조기 해제하고 싶을 - 때) 결국 `unbindLifetime`이 담당 — 위 "`:Unsubscribe()`는 자동(리프) - 케이스에도 동일하게 씀" 절의 서술은 leaf 부착이 별도 메커니즘이라고 - 전제했던 것이라 **이 정정으로 대체**(`:Unsubscribe()`가 아니라 - `unbindLifetime`이 leaf 해제의 실제 통로). -- **Effect도 동일 규칙 적용(사용자 확인)** — Effect가 `state` 인자로 - 내부적으로 Observer를 조합하는 경우든, `state` 없는 경우든 같은 - `canExecute` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect - 자신이 아니라 내부 Observer가 게이트를 갖고 있어서, Effect 구현이 - 이 정정을 몰라도 자동으로 커버됨. 이전에 그 문서에 적어뒀던 "leaf - 부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 - 규칙으로 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 - 하는 조합이었음. -- **문서화 경고 대상(api/심화)**: "한 Effect/Observer 핸들을 children - 배열에 놓았다면(=`bindLifetime`으로 등록된 것) 그걸 다시 - `:Subscribe()`하거나 다른 Instance에 또 leaf로 놓지 말 것, 반대도 - 마찬가지 — 여러 경로를 동시에 쓰고 싶으면 각각 독립된 새 - `Effect(...)`/`state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. - -### `bindLifetime`이 이 게이트의 두 번째(이자 leaf 부착이 실제로 쓰는) 진입점이다 (2026-08-09 여섯 번째 세션) - -`Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때 -쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은 -`canExecute` 게이트를 확인** — 진입 전 `canExecute(value)`를 확인하고, -통과하면 gchold 등록 + gcconn 참조 복사를 수행. -**children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** — -`Dispatch/Leaf.luau`가 `(i:number, v=Observer/Effect)`를 매치하면 -그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용" -바인딩 로직이 따로 있는 게 아님. 그래서 **실제 상호 배타는 `:Subscribe()` -(전역 강참조 레지스트리)와 `bindLifetime`(inst별 gchold, 직접 호출이든 -leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만들 이유가 -없음, 기존 게이트에 진입점 하나(`bindLifetime`, leaf 부착이 그 특수 -사례)만 추가. - -```lua -function bindLifetime(inst, value) - if canExecute(value) then - error("이미 바인딩된 값") -- 메시지 분기는 위 게이트 스케치 참고 - end - ... -- gchold 등록 + gcconn 참조 복사(base/lifecycle-pattern.md) -end - -function unbindLifetime(value) - ... -- gchold 항목 제거 + gcconn 참조 해제 -end -``` - -- **[정정, 2026-08-14 다섯 번째 세션] 게이트는 값 타입을 안 가린다** — - 옛 서술은 "`canBound`는 `.Subscribed` 필드가 있는 Observer/Effect 전용 - predicate라 그 외 값(예: Tween 내부 클로저, Slot)은 그냥 통과"였는데, - `canExecute`는 gcconn 경로를 먼저 보므로 **어떤 값이든** 이미 살아있는 - 바인딩이 있으면 걸러짐. 이게 더 맞음 — Slot을 두 `inst`에 이중 마운트하는 - 것도 원래 금지(`base/slot-plan.md`의 `elementOwner`)라, 같은 실수를 - `bindLifetime` 층위에서도 공짜로 잡아줌. -- 값이 `bindLifetime`으로 바인딩된 뒤엔 `canExecute`가 참이 되므로, 그 - 뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면 기존 두 진입점의 기존 - 체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 없이 이미 성립. - -**quad의 Unix 파이프 영감(원래 동기)과 `Pipe`/`fromState` 후보 검토 경위는 -`archive/quad2-try-research-findings-rejected.md`로 이전됨** — 최종 결론만 -남기면: 목표(State끼리 자유롭게 합성/파이핑)는 아래 "Store/State/Source -온톨로지" 절의 `state(state)` 조합 모델로 달성됨, 별도 `Pipe`/`fromState` -콤비네이터 타입은 불필요로 폐기. - -## Store/State/Source 온톨로지 — 핵심 메커니즘 확정 (2026-08-04 2차 라운드) - -**상태**: 전파 모델/`:Compute` 인자 규칙/State 쓰기 금지/Slot 생존 확인/타입 -추론(dot-access) 전부 `AskUserQuestion`으로 확인 완료. 남은 건 정확한 함수/ -생성자 이름뿐(구현 단계). `base/store-semantics.md`의 "State 프리미티브는 -실제로 필요하다" 정정에서 이어짐. - -**핵심 온톨로지** (2026-08-06 후속 세션에서 Store/Source 부분 정정 — -아래 "State는 쓰기 대상이 아님" 절 이후 내용 및 `base/store-semantics.md`의 -"Source가 State를 만족함" 절 참고): -- **Source** — 실제 값이 존재하고 변경될 수 있는 단일 지점(v1의 "값의 근원"). - **구조적으로 State를 만족(단방향 호환)** — `:Get()`/`:With`/`:Compute` - 전부 지원 위에 `:Set(value)`/`:Emit()` 추가. -- **Store** — Source들의 이름 붙은 모음, 그 이상 아님. `store.a`처럼 키로 - 접근하면 **이미 만들어진 Source가 있으면 그대로 반환, 없으면 그 자리에서 - 만들어 저장한 뒤 반환**(더 이상 별도 State wrapper를 매번 만들거나 따로 - 캐싱하지 않음 — Source 자체가 이미 State를 만족하므로 wrapper 계층 - 자체가 불필요해짐. **[정정, 2026-08-07]** "Store 생성 시 전부 eager하게만 - 만들어진다"는 이전 서술은 부정확 — `defaults`가 선택이고 Luau 타입이 - 런타임에 강제 안 되므로, 생성 시점 eager 생성(각 `defaults` 키)과 - `store.key` 접근 시점 lazy 생성(아직 없는 키를 그 자리에서 만듦)이 둘 다 - 필요함, 상세는 `base/store-semantics.md` 참고). -- **State** — source(또는 다른 state)의 결과를 캐싱만 하는 존재, 자기 고유의 - 독립적 value 개념이 없음. `state(state)`로 기존 state의 결과를 받아 새 - state를 만들어 분기 가능 — 이게 사실상 Unix 파이프 영감의 "State끼리 - 합성 가능"이라는 원래 목표를 구현하는 방식. - -**전파 모델 확정: push-invalidate(신호만) / pull-recompute(`Get()` 시점에만) — -Fusion식 eager 노드·생성순 정렬은 안 만듦** - -- `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만 - 쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다 - 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구). -- 신호를 받은 State는 자기 `invalid` 플래그만 세우고, 이미 `invalid`였다면 - 그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는 - 장치(Vide가 저자 스스로 `todo.md`에 미해결로 남긴 문제의 해결책). -- 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 — - "필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/ - 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 - 소비자가 없다는 판단. 유일하게 "즉시 반응해야 하는" 소비자는 store-bind - pluggable 핸들러(`base/dispatch-core-plan.md`의 "확정된 디스패치 모델" 절)인데, 이건 무효화 신호를 - 받는 즉시 자기가 알아서 `Get()`을 호출해 pull하는 방식으로 충분함 — - State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀 - 필요 없음. -- `emit`은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함 - ("emit 필요 여부" 열린 질문은 이걸로 해소). - -**전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)** - -위 pull-recompute 규칙을 State 하나의 재계산 메커니즘으로만 읽지 말고, -프로젝트 전역에 적용되는 원칙으로 명시함: **어떤 파생값도 `:Get()`으로 -직접 읽히기(관측) 전까지는 계산되지 않는다.** 이 원칙은 State 자체뿐 아니라, -State를 필드 값으로 담고 있는 다른 구조(예: `base/modifier-plan.md`의 -Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 읽으면 그 -순간이 바로 관측이고, 그 순간 계산이 확정됨. - -**주의 — 구조적 복사는 관측이 아님.** `table.clone`처럼 테이블 레퍼런스만 -복사하는 연산은 안에 담긴 State 핸들을 그대로 옮길 뿐 `:Get()`을 -호출하지 않으므로 관측이 아니고, 계산을 트리거하지 않음. Modifier 체이닝 -메소드가 `table.clone` 후 필드를 덮어쓰는 것(위 "Immutable 값 + clone 기반 -체이닝")과 이 원칙이 충돌하지 않는 이유가 바로 이것 — clone은 그저 참조 -복사라 State 필드는 클론 이후에도 여전히 살아있는 lazy 핸들로 남음. - -**왜 State 체인을 Modifier처럼 플래튼하지 않는가 (2026-08-06 후속 세션)** - -**문제 제기(사용자)**: State가 `a → b → c`처럼 계속 연결되는 구조면, 이전 -노드가 다음 노드에 대한 emit 연결/값 연결을 항상 들고 있어야 함(weak -table로 GC는 되지만 별도 데이터스트럭처 관리 부담). 대안으로, 각 State가 -자기 Compute 함수 목록을 통째로 누적해서 갖고(Modifier의 clone-then-return -체이닝처럼) 매번 클론+append하면 링크드 그래프 자체가 필요 없어지지 -않는가? - -**기각 이유 — State의 정의 자체가 "캐싱하는 존재"임.** 위 온톨로지에 -"State — source(또는 다른 state)의 결과를 **캐싱만 하는** 존재"라고 -확정돼 있고, `previous` 두 번째 인자 메커니즘(무거운 파생 엔진 객체 -재생성 비용 절감)도 이 캐싱 전제 위에서만 의미가 있음. 만약 Compute -체인을 매번 통째로 클론해 각 leaf가 독립된 함수 목록을 갖게 하면, 중간 -State를 여러 갈래가 공유하는 다이아몬드 형태(`b`에서 `c1 = b:Compute(g1)`, -`c2 = b:Compute(g2)`로 분기)에서 `b`까지의 계산이 캐시 공유 없이 소비자 -수만큼 중복 실행됨 — `previous` 메커니즘이 막으려던 문제를 반대로 다시 -만들어내는 셈이라 방향이 안 맞음. - -**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** "관측해야 -실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건 -실제로 관측되는(`Get()`되는) State뿐 — 중간에 만들어놓고 아무도 안 보는 -State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 워크를 막는 -`invalid` 플래그 dedup 장치도 체인 전체가 링크드일 것을 요구하지 않고 -각 노드가 자기 구독자 목록만 가지면 되는 것이라, 이 결정과 무관하게 -그대로 유지됨. 구현은 Observer와 동일한 패턴(외부 weak table, -`{[child] = true}` 류)으로 충분 — 새 메커니즘 발명 아님. - -**결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 -플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는 -것이고, State는 존재 이유 자체(캐싱)가 달라 같은 패턴을 적용할 수 없음. -`research/documentation-plan.md`의 심화 문서 후보로 남겨둠 — "왜 State는 -Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사용자를 위한 -좋은 심화 콘텐츠 소재. - -### `:With`도 새 State 노드로 확정, 가변인자로 체인 남발 방지 (2026-08-07) - -**문제 제기(사용자)**: `:With(...)`가 문서상 가변인자 표기이긴 한데, 실제로 -호출마다(`:With(a):With(b):With(c)`처럼 체이닝할 때) 매번 새 State 노드를 -만드는 게 맞는지, 아니면 값 없이 의존성 목록만 clone-then-append로 누적하는 -가벼운 빌더로 만들어 노드 증식을 피해야 하는지가 불명확했음. - -**"빌더" 대안은 기각.** 세 가지 이유: - -1. **디버그 그래프가 꼬임.** `quad-debug`의 핵심 UX는 "무엇이 무엇에 - 연결됐는가" 그래프(`research/debug-tooling-plan.md`). With/Compute를 - 전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로 - 대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는 - 지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음. -2. **다이아몬드 dedup을 못 타고 특수 케이스가 생김.** With가 진짜 노드면 - `w = key1:With(key2)`에서 갈라지는 `c1 = w:Compute(g1)`, `c2 = - w:Compute(g2)` 같은 흔한 fan-out이 이미 확정된 "invalid 플래그로 - 다이아몬드 중복 워크 방지" 장치(위 "전파 모델 확정" 절)를 그대로 - 재사용함. 빌더면 c1/c2가 key1/key2에 각자 직접 구독을 걸어야 해서 - 기존 dedup 경로를 매번 우회하는 특수 케이스가 생김. -3. **clone 기반 구현은 Compute 노드 위에서 실제로 깨짐(사용자 지적, - 검증 완료).** `c = a:Compute(f)` 뒤에 `w = c:With(b)`를 clone으로 - 구현하면, `table.clone`이 `c`의 캐시 슬롯(계산된 값 + `invalid` - 플래그)까지 그대로 복사해 `w`가 `c`와 별개의 독립 캐시를 갖는 사실상 - 다른 노드가 됨. `c`와 `w`가 각자 관측되면 `f`가 두 번 따로 - 실행/캐싱됨 — 바로 위 "왜 State 체인을 Modifier처럼 플래튼하지 - 않는가" 절에서 이미 기각한 것과 정확히 같은 실패 모드(공유돼야 할 - 계산이 소비자 수만큼 중복 실행). Modifier의 clone-then-append 패턴을 - State 쪽에 그대로 가져오면 안 되는 이유가 바로 이것. - -**결정**: `:With(...)`는 호출마다 self+주어진 인자들을 구독하는 **새 State -노드**를 만든다(레퍼런스 기반 구독, clone 아님) — 계산 함수는 없고 값은 -`self`를 그대로 통과(pass-through)시키되 구독 목록만 넓힌 얇은 노드. 이 -노드는 Observer와 같은 패턴(외부 weak table)으로 상위 노드의 구독자 목록에 -등록됨. - -**⚠️ 문서 읽을 때 혼동 주의(2026-08-12 추가, 코퍼스 전체에 같은 패턴으로 -적용): `Tag`(`:Added`/`:Removed`)와 `Modifier`(`:Apply` 등)는 겉보기엔 -같은 `:` 체이닝 문법이지만 실제로는 clone-then-return이고, State의 -`:With`/`:Compute`는 이름은 비슷해 보여도 정반대(clone이 아니라 진짜 새 -노드)임.** 하나가 clone 계열, 다른 하나가 새-노드 계열이라는 걸 헷갈리기 -쉬우니(둘 다 "값을 안 바꾸고 새 걸 반환하는 메소드 체이닝"으로 보이기 -때문) 각 API 문서를 볼 때 이 문단을 기준으로 확인할 것 — clone 계열은 -`Tag`/`Modifier`(값 객체, 확정 상태), 새-노드 계열은 `State`의 -`:With`/`:Compute`(반응형, lazy)로 완전히 분리되어 있고 섞이지 않음. - -**노드 증식 걱정은 가변인자로 해소.** 처음 문제 제기("With 하나마다 노드가 -하나씩 늘어나는 게 낭비 아니냐")는 노드 자체를 없애는 대신, `:With(...)`가 -여러 의존성을 한 번에 받을 수 있게 해서 해소함: - -- `key1:With(a, b, c):Compute(fn)` — 노드 1개(구독 3개)로 끝남. -- `key1:With(a):With(b):With(c):Compute(fn)` — 여전히 가능하지만 노드 - 3개가 만들어짐. 이건 나쁜 게 아니라 각 노드가 dedup/디버그 그래프에서 - 실제 역할(구독 fan-in 지점)을 하는 저렴한 노드(계산 없음, Modifier - clone과 같은 급의 비용)라 걱정할 비용이 아님. -- 그래도 **가변인자 스타일을 권장 관례로 삼음** — 그래프로 그릴 때도 - `:With(a, b, c)`가 `:With(a):With(b):With(c)`보다 단순(노드 1개에 들어오는 - 엣지 3개 vs 노드 3개가 순서대로 이어지는 모양)해서 디버그하기 쉬움 - (사용자 확인). - -**`:With`/`:Compute` — self 인자도 lazy 핸들로 통일** - -> **[2026-08-13 열세 번째 세션, 해소 — 아래 계약은 그대로 확정]** -> 한때 이 계약이 Luau 추론과 충돌한다며 `question.md` 0-Y로 열려 있었고, -> "콜백이 raw 값을 받으면 완전히 클린"이라는 1차 판정까지 붙어 있었음. -> **44개 스파이크 재실측 결과 그 1차 판정이 뒤집혔음** — raw 값 계약도 -> 똑같이 불안전했고, 진짜 문제는 콜백 계약이 아니라 **`Compute`가 -> `State`(자기 이름을 다른 타입 인자로 감싼 타입)를 반환한다는 것 -> 자체**였음(Luau의 현 한계, RFC가 `Promise.andThen`으로 예시 든 바로 -> 그 패턴). **따라서 아래 lazy 핸들 계약은 바꿀 이유가 없고 그대로 -> 확정**이며, 콜백 파라미터 추론은 타입 선언을 "데이터부/메소드부"로 -> 쪼개면 해결됨. 반환 타입만 사용처에서 명시 주석으로 바인딩하면 됨 — -> 규약 전문은 **`base/typing-limits.md`**, 실측 근거는 -> `audit/type-recursion-issue/`. - -- 최초안(self 값은 포지셔널 raw 값, with한 값만 클로저로 읽음)에는 실제 - 단점이 있었음 — self가 raw 값이면 `fn` 호출 전에 항상 self를 먼저 - `Get()`해야 하므로, `fn` 내부 로직이 with한 다른 값을 보고 "이 경우엔 self - 계산 자체가 필요 없다"고 판단해도 이미 늦음(예: `:With(noprint)`이고 - `noprint:Get() == true`면 앞단 계산을 통째로 생략하고 싶은 경우). -- **해결(사용자 확정)**: self도 raw 값이 아니라 **State 핸들 그 자체**를 - `fn`의 포지셔널 인자로 넘긴다 — `fn(self: State)`, 내부에서 - `self:Get()`을 실제로 읽을 때만 계산이 트리거됨. with한 값과 동일한 - lazy 원칙을 self에도 그대로 적용 — 별도 `ComputeWithout` 변형은 - 불필요, `Compute` 하나로 일관. -- **[정정, 2026-08-07] `.value`는 State/Source에서 제외, `:Get()`만 지원.** - 이전엔 `Get()`을 감싼 읽기 전용 계산 속성(`base/lifecycle-pattern.md`의 - `Connected`와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴)으로 - `.value`/`:Get()` 둘 다 지원하고 `.value`를 관용적 표기로 앞세웠으나, - "관측해야 실체화된다"는 원칙이 가장 날카롭게 느껴져야 할 지점에서 - 프로퍼티 문법이 그 느낌을 무디게 한다는 재검토 끝에 함수 호출 - `:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. `.value` - 표기 자체는 폐기하지 않고 **Ref 전용으로 좁힘**(Ref는 lazy가 아니라 - 값을 읽어도 계산이 트리거되지 않으므로 프로퍼티 문법이 정직함 — 이 - 절 위쪽 "Ref 일반화" 절의 `.Value`가 그대로 유일한 존재가 됨, 이름 - 충돌 자체가 사라져 별도 표기 정리 불필요). -- 예시 갱신: `store "key1":With(store "key2"):Compute(function(key1) return - key1:Get() + store.key2:Get() end)` — `key1`은 이제 raw 숫자가 아니라 - State. - -**[2026-08-12 세션 감사에서 확인] `:Compute` 콜백 인자에 `:Get()`을 빠뜨리는 -실수가 반복되기 쉬움 — 실제로 `.claude/` 문서 예시 코드 4곳(`tag-plan.md`, -`slot-plan.md` 2곳, `base/tween-plan.md`)에서 발견·수정됨.** `fn(self, -...)`의 모든 인자가 raw 값이 아니라 lazy State 핸들이라는 원칙(바로 위 절)을 -사람도 에이전트도 코드 작성 중에 잊기 쉬운 지점 — `:Compute`/`:With` 콜백 -안에서 인자를 비교(`==`)/연산(`+`)/테이블에 담기 전에 항상 `:Get()`부터 -거쳤는지 확인할 것. 예: `function(name) return name == "x" end`(버그) vs -`function(name) return name:Get() == "x" end`(올바름). - -**State는 쓰기 대상이 아님 — 확정, Source는 독립 공개 프리미티브로 격상** - -- `state:Get()`은 항상 읽기 전용. State에는 쓰기 API가 아예 없음. "State에 - 직접 쓰기 API를 허용하면 다른 source에서 파생된 state에 직접 쓰기가 - 가능해져 버린다"는 이전 우려는 이걸로 근본적으로 해소(그런 API 자체가 - 없음). -- **[정정, 2026-08-06 후속 세션] 값을 쓰는 경로는 `store.key = value` - (`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — 이유와 - 상세는 `base/store-semantics.md`의 "Store 값 설정 문법" 절 참고(요지: - Source가 State를 만족하는 구조로 바뀌며 레코드 타입 읽기/쓰기 대칭을 - 맞추려면 대입 문법을 포기해야 함 + `=`가 암시하는 "즉시 커밋"이 실제 - lazy 동작과 정서적으로 안 맞는다는 논거). 같은 문서의 "Source가 State를 - 만족함" 절에 Source/State 서브타입 구조 전체가 정리돼 있음. -- **`Source`는 Store의 내부 구현 디테일이 아니라 별도의 가벼운 공개 - 프리미티브로 노출** — Store는 다수의 source를 등록/관리하는 무거운 - 구조라, 값 하나만 반응형으로 다루고 싶을 때 Store를 통째로 만드는 건 - 비효율이라는 게 사용자 판단("store가 source 수십 개 만드는건 비효율이니 - 둘이 다른 구현이라 봐도 될듯"). `Source(initial)` 류의 독립 생성자 - (정확한 이름은 구현 단계에서 확정)가 Store와 나란히 존재. -- **생성자 스타일 확정(2026-08-06 후속 세션): Kotlin Compose식 "타입 - 이름 자체를 팩토리 함수로" — `Source(default)`, `Ref(default)`, - `Store({defaults})`.** Ref도 예외 없이 이 스타일을 따름 — Ref가 - `Ref()`로 안 만들어질 특별한 이유는 없었고(이전 절에서 API 모양만 - 다루고 생성자를 명시 안 해서 생긴 공백), `architecture.md`의 "복사 - 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`도 - 같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도 - 됨, 순수 편의용 초기값 템플릿). -- **[보강, 2026-08-09 열한 번째 세션] `Source(default)`/`Ref(default)`의 - `default` 인자가 "선택"이라는 서술은 정확히는 `T`가 `nil`을 포함할 때만 - 성립함 — 생략하면 실제로 `nil`이 그 자리를 채우기 때문.** `Source()` - (무인자)는 `Source(nil)`과 동치라고 이미 명시돼 있으나, 이게 타입 - 레벨에서 뭘 뜻하는지(`T`가 nilable이 아니면 타입과 실제 저장값이 - 어긋난다는 것)는 지금까지 명시적으로 안 적혀 있었음. `Ref`도 마찬가지 - 캐비엇이 있고 오히려 더 눈에 띄게 드러남 — `:Callback(fn)`은 등록 - 즉시 그 시점 값으로 무조건 1회 호출되므로(미설정 상태여도 그 상태 - 그대로 호출, 아래 `Ref` "바인드 방법" 절 참고), `default`를 생략한 - `Ref()`에 콜백을 걸면 그 콜백이 즉시 `nil`로 한 번 불림 — `T`가 - non-nilable이면 이 시점에 이미 타입 위반. 따라서 `default`를 생략해도 - 되는 건 오직 `T`가 nilable(`T?`)로 선언된 경우뿐이라는 걸 문서 차원에서 - 명시할 것(non-nilable `T`에 `default` 없이 생성하는 건 사용자 실수, - 타입으로 막을 수 있으면 막고 안 되면 UB로 문서 경고). - **[정정, 2026-08-07]** 아래 두 문장은 이후 라운드에서 정정된 옛 서술 — - 실제 메커니즘·mutate 취급은 `base/store-semantics.md` "Source가 State를 - 만족함" 절이 최종 소스: (a) "`__newindex`/`__index` 프록시로 감싸면 - 됨"은 이후 `store.key = value` 쓰기 문법 자체가 `:Set()`으로 옮겨가며 - `__newindex`는 더 이상 관여 안 함(읽기 쪽 `__index`는 "없으면 그 자리에서 - Source를 만들어 저장"하는 lazy 생성 용도로 여전히 필요, 위 store-semantics.md - 참고). (b) "defaults 테이블 원본을 직접 mutate하는 건 UB로 둠"도 최신 - 모델과 안 맞음 — `defaults`는 라이브 백킹 스토리지가 아니라 "아직 안 - 만들어진 Source를 만들 때 참고하는 초기값 템플릿"으로만 쓰이므로, 생성 - 후 원본을 바꿔도 문제없고 UB가 아님. - -**Slot 생존 확인 — 별도 메커니즘 아님, `canExecute` 재사용으로 확정** - -- `base/store-semantics.md`에 있던 "`isInit=false`면 허용, `isInit=true`+ - 생존확인 거짓이면 불허" 분기 초안은 폐기. state-invalidate 리스너 - 클로저도 `base/lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute - predicate)로 등록하면, 발화 시 `canExecute(value)`(2026-08-14 세 번째 - 세션 최종 시그니처, `inst`를 안 받음) 하나만 확인하고 거짓이면 - 그냥 no-op — `isInit` 분기라는 별도 개념 자체가 불필요(사용자 확정: - "canExecute 하나로 통일"). - -**타입 추론 문제 — 확정(2026-08-04 3차 라운드)** - -- `store "key"`(문자열 커링)로 `state`를 오버로드 함수 타입으로 정확히 - 추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정** - — Store 타입을 `{key: Source, other: Source}`류 평범한 - 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 - 리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State` - 필드로 적혀있었으나 Source가 State를 만족하는 구조로 바뀌며 `Source`로 - 갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가 - 더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이 - 해소됨, `base/store-semantics.md` "Source가 State를 만족함" 절 참고). - `store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source`) - 폴백으로 격하. -- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 - 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 - **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). - 아래 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절이 최신 확정 내용. - -**`Pipe`(quad2-try 후보)는 폐기 확정** — 별도 `Pipe` 타입에 소유권/버전 -가드를 넣어 재설계하는 대신, State 자체가 파이핑 결합체이고 -`state(state)`로 분기하는 위 모델로 완전히 대체됨. - -**`store.key` 레코드 필드 타이핑 — Luau 타입함수로 해결 확인 -(2026-08-12 열일곱 번째 세션, `pre-implementation-audit.md` 1-10 해소).** - -위 "타입 추론 문제" 절이 "`store.key`를 평범한 레코드 필드 타이핑으로 자동 -해결"이라 서술했지만, `Store`가 입력 `T`(예: `{ty: string}`)를 받아 -`{ty: Source}`류 결과 타입을 실제로 어떻게 합성하는지는 미검증으로 -남아있었음. **Luau의 `type function`**(컴파일타임에 타입을 인자로 받아 새 -타입을 조립하는 기능, https://luau.org/types/type-functions/ , -https://luau.org/types-library/ — tbox에서도 이미 쓰이는 검증된 패턴)으로 -정확히 풀림: - -```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 - -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 -``` - -`ProcessStoreType<{ty: string}>` → `{ty: Source}`가 나옴 — 결과는 -선언 시점에 이름 붙은 `Source` 그 자체가 아니라 구조를 그대로 풀어낸 -(flatten) 익명 타입이지만, **Luau는 이름이 아니라 "만족하는가"로 구조적 -일치를 검사**하므로 문제없이 `Source` 자리에 대입 가능 — 오히려 이 -방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이 -확인돼 M0/M3 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 — -`ROADMAP.md`의 M0/M3 배치를 강제로 바꿀 필요는 없어짐, 검증 난이도 -문제였던 것만 해소. - -**PA님 코드와의 교차검증(2026-08-04 4차 라운드) — 둘 다 기존 확정 유지** - -`.claude/initreq/artworks/EventDrivenProgramming/`(Connection/Event/ -Observable/Observer)을 조사한 결과, 두 지점에서 기존 확정과 실제로 다른 -선택이 나와 재검토했으나 결론은 변경 없음. **이름 주의**: 아래에서 말하는 -`Observer`는 PA님 코드의 클래스 이름(pub-sub, 8개 `subscribeXxx` 헬퍼)이고, -위 "`state:Observer(fn)`" 절에서 확정한 quad의 `Observer`와는 이름만 -같을 뿐 무관한 별개 개념 — 이 절은 순수 역사적 교차검증 기록으로만 읽을 것. - -- **전파 모델**: PA님의 pub-sub은 push-invalidate가 아니라 **push-값** - (`Event:fire(...)`가 인자를 그대로 콜백에 전달, `Observable`의 `__newindex`가 - 새 값을 실어 즉시 `changed:fire(key, value)`, dirty-flag/`Get()` pull 단계 - 자체가 없음). 한때 "leaf(source 하나→sink 하나, 파생 없음)는 PA님처럼 - push-값으로 단순화하고 push-invalidate/pull-recompute는 실제 `:Compute` - 파생이 있을 때만 쓰자"는 이원화를 검토했으나 **기각** — invalidate+`Get()` - 방식도 leaf에서 딱히 더 복잡하지 않고(불리언 플래그 하나 + `Get()`/`emit` - 둘로 나뉘는 정도), 오히려 두 메커니즘을 병행하면 "leaf State가 나중에 - `:Compute`로 감싸일 때 두 메커니즘을 어떻게 연결하는가"라는 새 경계 문제가 - 생겨 이원화가 더 복잡함. **결정적으로, PA님 코드엔 애초에 `:Compute`/`:With` - 같은 파생·합성 개념 자체가 없음** — quad-v2가 lazy pull을 도입한 이유(여러 - 소비자가 하나의 파생 State를 공유할 때 오염 방지, 안 쓰이는 연산 스킵)를 - PA님 시스템은 처음부터 안 풀려던 문제라, 대등한 반례가 아니었음. **결론: - push-invalidate/pull-recompute로 통일 유지, 변경 없음.** 사용자 최종 확인 - 문구: "store 전파 처리는 우리 방식이 맞음. 이건 vide 에서 없었던것과 - 동일함, [PA님] 저기도 디자인 상 해결 못하는 문제가 된거거든. 비 필요 - 연산과 중복 연산을 지우는건 디자인 단계에서 구성할 일임. 우린 디자인 - 단계부터 해당 문제를 해결하고 싶었던거야." -- **라이프사이클**: PA님 코드는 GC-native가 아니라 **전부 수동 해제** - (`Connection.connected`는 계산 속성이 아니라 저장된 bool, `Observer`의 - 8개 `subscribeXxx` 헬퍼 전부 명시적 `:unsubscribe()` 필요, weak table은 - `Observable`의 subject↔observable 캐시 한 곳뿐). rbvm 기반으로 확정한 - "GC 위임, 명시적 dispose 없음" 원칙과 반대 선택이라 재확인 질문했으나, - **GC-native 유지로 확정** — 지금까지 이 정도 규모(명시적 dispose가 꼭 - 필요할 만큼 큰 자원)를 요구하는 실제 사례가 없었다는 게 사용자 판단. 다만 - **완전히 막다른 길은 아님**을 기록해둠: rbvm처럼 관계를 양쪽 다 weak-keyed로 - 두고 모든 걸 connection 람다에 담아 "연결이 살아있는 동안만 살아있게" 하는 - 방식이면, 나중에 GC만으로 정말 부족한 케이스가 생겨도 그 connection을 얻어 - `disconnect()`하는 명시적 dispose 경로를 추가로 얹는 게 가능한 디자인 — - 지금 마일스톤에서는 필요 없어서 안 함(사용자: "필요하다면 dispose 핸들러를 - 만들어주는 것도 가능한 디자인, 다만 지금까지 요구가 없었음"). (rbvm의 - GC-native 패턴이 실물에서 검증됐다는 근거는 `base/lifecycle-pattern.md` 상단 - 참고 메모 참고.) - -## quad2-try 리서치 결과 (완료) — 이전 시도에서 뭘 가져오고 뭘 버릴지 - -`.claude/initreq/quad2-try/out/quad-core`에 정확히 이 문제(Unix 파이프 영감의 -State/스트림)를 다뤘던 이전 시도가 있어 조사함 — **확인된 죽은 접근(OOP 상속 -`Base:Extends`/`--&` 커스텀 파서/Slot 빈 스텁/`Pipe` copy-on-write 절충안)은 -절대 반복 조사하지 말 것**, 상세 근거와 "건질 만한 것"(`:With` 이름의 -방증 등)은 `archive/quad2-try-research-findings-rejected.md` 참고 — 이 -조사의 최종 결론은 이미 아래 "Store/State/Source 온톨로지" 절의 `state(state)` -조합 모델로 대체되어 있고 Slot은 `base/slot-plan.md`의 from-scratch 설계를 -그대로 쓰면 됨(재조사 불필요). +## 분할된 문서로 가는 색인 + +- **디스패치 코어** — 핸들러 계약(`isHandlable`/`priority`/`process`), 확정된 + 디스패치 모델, `None` 센티널, `Dispatch`가 탑레벨 싱글톤인 이유, `chains` + 인덱스 체인과 `Dispatch.retractFrom`, Handler 작성 체크리스트, Length/Offset + (형제 순서 보장), "store 바인드는 래핑" 결론 → + **`base/dispatch-core-plan.md`**. +- **반응형 코어** — `Source`/`State` 온톨로지와 구조적 서브타입, push-invalidate/ + pull-recompute 전파 모델, "관측해야 실체화된다", `:With`/`:Compute`(trailing + args·`previous`·lazy 핸들 계약), `:Apply`, `state:Observer(fn)`, + `:Subscribe()`/`:Unsubscribe()`, 이중 바인딩 금지 게이트 → + **`base/source-state-plan.md`**. +- **Store** — 이름 붙은 Source 모음, `defaults`와 eager/lazy 생성, `store.key` + dot-access 타이핑(+Luau `type function`), `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`**. +- **이벤트 바인딩** — 핸들러가 self(Instance)를 안 받는다는 확정, 이벤트도 + store-bind 가능(`false`로 disconnect) → **`base/event-plan.md`**. 단 이벤트 + *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 아래 "인스턴스 생성 / + 이벤트 네이밍 인체공학" 절에 그대로 있음. +- **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, + `isState`를 10종 branded 타입 전부로 일반화) → **`base/brand-plan.md`**. +- **`Tag` / `Attribute` 특수 키** → **`base/tag-plan.md`** / + **`base/attribute-plan.md`**. 이 문서가 예전에 다루던 타입 파라미터화 문제 + (`[AttributeKey<> "name"]`(구 `Attribute<>`) vs + `[BooleanAttribute "name"]`)뿐 아니라 `None`/`process`/retract 동작까지 + 전부 그쪽에 확정 반영돼 있음. **[2026-08-11 아홉 번째 세션]** + `attribute-plan.md`에 여러 Store를 한 번에 attribute로 묶는 그룹 + `Attribute(...)` 프리미티브(`Tag`와 동형)가 추가되며, 단일 키 생성자는 + 이름 충돌 방지로 `AttributeKey<>`로 리네임됨. ## 확정된 것 (더 이상 열린 질문 아님) - **핸들러 계약**: `isHandlable(inst,k,v)` + `priority` + `process(inst,k,v,index)` **3종**으로 확정 — tbox식 6-hook 세분화는 지금은 안 함. 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 확장). - **[정정, 2026-08-13 다섯 번째 세션 — 이 항목이 갱신에서 누락돼 문서 상단 - "핸들러 계약" 절과 모순돼 있던 걸 같은 날 리뷰에서 발견]** 예전엔 + **[정정, 2026-08-13 다섯 번째 세션]** 예전엔 `process`(구 `bind`) + `retract`(구 `cleanup`) 4종이었으나, `retract`가 별도 필드에서 **`process`의 반환값(retractor 클로저)** 으로 합쳐짐 — 이름과 개념은 그대로 유효하고 자리만 옮겨온 것(`base/dispatch-core-plan.md`의 "핸들러 계약" 절이 정본). - **Signal 클래스**: 안 만듦, 콜백 + `Connected` 계산 속성만(`base/ lifecycle-pattern.md`). -- **Ref**: 도입 확정(위 절 참고), 용도는 "id 기반 조회 대체"가 아니라 "외부 - 관리 instance를 점진적으로 다루기 위한 직접 참조 획득". +- **Ref**: 도입 확정(`base/ref-plan.md`), 용도는 "id 기반 조회 대체"가 아니라 + "외부 관리 instance를 점진적으로 다루기 위한 직접 참조 획득". +- **quad2-try(폐기된 이전 시도)에서 뭘 가져오고 뭘 버릴지**: 조사 완료 — + **확인된 죽은 접근(OOP 상속 `Base:Extends`/`--&` 커스텀 파서/Slot 빈 스텁/ + `Pipe` copy-on-write 절충안)은 절대 반복 조사하지 말 것**, 상세 근거와 + "건질 만한 것"(`:With` 이름의 방증 등)은 + `archive/quad2-try-research-findings-rejected.md` 참고. 이 조사의 최종 + 결론은 `base/source-state-plan.md`의 `state(state)` 조합 모델로 대체되어 + 있고 Slot은 `base/slot-plan.md`의 from-scratch 설계를 그대로 쓰면 됨 + (재조사 불필요). ## base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입 (2026-08-04 보강) @@ -1124,8 +117,9 @@ RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들 ## 인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨) `Quad "Frame"`처럼 문자열로 인스턴스 종류를 지정하는 방식은 타입 추론이 -어려움(위 온톨로지 절의 Luau 오버로드 문제와 같은 원인). 사용자가 실제 -참고 코드를 `.claude/initreq/artworks/DeclarativeProgramming/ +어려움(`base/store-plan.md`의 "타입 추론 문제" 절이 다루는 Luau 오버로드 +문제와 같은 원인). 사용자가 실제 참고 코드를 +`.claude/initreq/artworks/DeclarativeProgramming/ DeclarativeInstance.luau`(PA님 작성, UI 포함 전반적 설계 패턴을 시범 적용한 데모 모듈)에 공유해줘서 직접 확인 — **"DI"는 Dependency Injection이 아니라 "Declarative Instance"(선언형 인스턴스 생성)**. @@ -1160,8 +154,7 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 **Store 쪽 dot-access는 그대로 유지**: `store.key`(1급 타입 경로)/ `store "key"`(문자열 커링, 동적 키 폴백)는 이벤트와 달리 실질적으로 Luau가 타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: Source, ...}`류 -평범한 레코드 타입으로 지어짐[정정: 2026-08-06 후속 세션에서 필드 타입이 -`State`→`Source`로 갱신, "Source가 State를 만족함" 절 참고]) 그대로 +평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 유지 — 이벤트만 예외였을 뿐, "정적으로 알려진 것=필드 접근" 원칙 자체가 깨진 건 아님. @@ -1184,24 +177,7 @@ DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal 인덱스라 지금 quad-v2 스코프 밖 — Instance가 아닌 데이터에 태깅이 필요해질 미래 시나리오를 위한 참고 자료로만 기록. - **Store/State 전파 모델, 라이프사이클 — 둘 다 재검토 후 기존 확정 유지** - (위 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고). - -## Tag/Attribute 특수 키 — 전용 문서로 분리됨 (2026-08-07 여덟 번째 세션) - -`base/tag-plan.md`/`base/attribute-plan.md`로 이동 — 이 절이 다루던 타입 -파라미터화 문제(`[AttributeKey<> "name"]`(구 `Attribute<>`) -vs `[BooleanAttribute "name"]`)뿐 아니라 `None`/`process`/`retract` 동작까지 -확정 반영됨. UICorner 숏핸드/Tween처럼 "1 프리미티브 1 파일" 관례를 따라야 -한다는 지적으로 분리. **[2026-08-11 아홉 번째 세션]** `attribute-plan.md`에 -여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브(`Tag`와 -동형)가 추가되며, 단일 키 생성자는 이름 충돌 방지로 `AttributeKey<>`로 -리네임됨. - -## `Brand` — 전용 문서로 분리됨 (2026-08-13 아홉 번째 세션) - -런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를 -10종 branded 타입 전부로 일반화)은 **`base/brand-plan.md`로 분리**됨 — -이 문서가 3000줄에 육박해 분할한 1단계. 내용/결정은 안 바뀜. + (`base/source-state-plan.md`의 "PA님 코드와의 교차검증" 절 참고). ## 남은 열린 질문 (`.claude/question.md`에도 취합) @@ -1218,21 +194,10 @@ vs `[BooleanAttribute "name"]`)뿐 아니라 `None`/`process`/`retract` 동작 `RobloxFactory` 재호출 가드)를 거치며 전부 확정됨. 그 라운드들 기준으로 남았던 건 순수 API 표면 이름뿐이었음: -- **`state()`/`Source()`/`Get()`/`DI`(또는 다른 이름) 등 정확한 함수·생성자· - 모듈 이름** — 방향은 전부 확정, 이름만 구현 단계에서 남음(`On` 모듈은 - 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 불필요해짐 — 위 "인스턴스 생성 / - 이벤트 네이밍" 절 참고). +- **`DI`(또는 다른 이름) 등 정확한 모듈 이름** — 방향은 전부 확정, 이름만 + 구현 단계에서 남음(`On` 모듈은 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 + 불필요해짐 — 위 "인스턴스 생성 / 이벤트 네이밍" 절 참고). Source/State + 쪽 이름 문제는 `base/source-state-plan.md`가 소스. - **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 검증 대상). - -**해소된 것**: "Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 -필요한가"는 재검토 결과 질문 자체가 성립 안 함으로 결론 — 두 가지 독립적인 -이유로 이중 해소됨. (1) 애초에 그런 경우를 만들지 않기로 확정(위 "Store가 -Store를 저장 가능한가" 절, 2026-08-04 6차 — Store는 Source에 준하는 "시작점" -이라 다른 반응형 값을 담아 자동 연결되는 용도로 안 씀). (2) 설령 발생해도 -State/Source 그래프 구독이 전부 weak-keyed GC-native(명시적 `dispose()` 호출이 -아예 없음, `base/lifecycle-pattern.md`의 GC 위임 원칙 재사용)라 "같은 걸 두 번 -해제"할 행위 자체가 존재하지 않음(GC는 멱등). "`:Compute`가 with한 값을 어떻게 -읽는가"/"emit 필요 여부"도 전파 모델 확정으로 해소, `RobloxFactory` 중복 -호출/충돌 시나리오·인스턴스 생성/이벤트 네이밍도 위 절에서 전부 확정. diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index bc98d88..2ae8c6a 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -17,9 +17,8 @@ 위험을 구조적으로 우회한다. **store 개발(M3)과 밀접하게 연관됨** — `state:Block(blocker)`가 State -위에 얹히는 메소드이므로 `base/store-semantics.md`의 Store/State/Source -온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/ -bind-system-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되 +위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State +온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되 State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것. ## 메커니즘 (확정) @@ -45,7 +44,7 @@ gated state의 동작: **`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도 누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서 -준다 — `store-semantics.md`의 "`Get()`은 라이브 레퍼런스를 준다" 원칙과 일치. +준다 — `base/source-state-plan.md`의 "Source 값을 직접 mutate한 뒤 전파 — `:Emit()`" 절("`Get()`은 라이브 레퍼런스를 준다" 캐비엇)과 일치. ## 사용 예시 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index e5f14be..6477724 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -67,7 +67,7 @@ Svelte `Writable extends Readable`와 같은 모양), `store.key`는 Store 쓰기 가능"이라는 위 2번 규칙과도 자연히 맞아떨어짐). 쓰기 문법도 같이 바뀜: `store.key = v`가 아니라 `store.key:Set(v)`(레코드 타입 읽기/쓰기 대칭 + lazy 동작에 `=`가 암시하는 "즉시 커밋"이 안 맞는다는 논거). 상세 -근거·타입 설계·Luau 솔버 검증 필요 항목은 `base/store-semantics.md`의 +근거·타입 설계·Luau 솔버 검증 필요 항목은 `base/source-state-plan.md`의 "Source가 State를 만족함" 절이 최종 소스. **[이전에 확정했다가 폐기된 `StoreSource` 프록시 설계는 이 결론으로 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 19c52fe..b24c728 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -721,6 +721,22 @@ end 전제하므로(quad 자신이 "props 순회 순서" 절에서 이 관례에 의존), 0을 쓰면 그 항목이 `ipairs` 순회에서 조용히 빠지고 `quad-debug`가 나중에 `chains`를 그대로 순회해서 보여주려는 계획과도 부딪힘. +- **위임 대상은 다른 `k`뿐 아니라 다른 `inst`여도 됨 — UB 아님 + (2026-08-14 세션, 명시화).** `chains`는 `(inst,k)` 쌍으로 인덱싱되므로 + `(inst,k1)`을 처리하던 핸들러가 `(inst,k2)`로 위임하는 것과 + `(child,k2)`로 위임하는 것은 Dispatch 입장에서 **구조적으로 완전히 같은 + 일**임(둘 다 별개의 새 배열, 그래서 둘 다 인덱스 `1`부터). 즉 핸들러가 + **자기가 관리하는 자식 Instance를 먼저 만들거나 찾아둔 뒤 그 자식에 + 대해 `Dispatch.process(child, prop, v, 1)`을 부르는 패턴은 정상**이고, + 이게 `base/ui-shorthand-plan.md`의 `UICorner`/`UIPadding`/`UIScale` + 숏핸드가 Tween을 공짜로 얻는 방식임(그 자식 프로퍼티를 최종 처리하는 + 건 `PropertyHandler`이고, Tween 해석은 원래 거기 하나에만 있음 — + `base/tween-plan.md`). 단 **그 자식의 수명은 위임한 핸들러가 책임진다** + — Dispatch는 `(child,prop)` 체인이 누구 소유인지 모르므로, 자식을 + 없앨 때 `retractFrom(child, prop, 1)`까지 부르는 건 위임한 쪽 몫 + (자식 Instance 자체를 버리면 `chains`가 `inst`로 weak-keyed라 결국 + GC되지만, 실행 중인 Tween/구독처럼 즉시 끊어야 하는 게 있으면 명시적 + 정리가 필요). - **`handler.process(inst,k,v,index)`를 `Dispatch.process`를 거치지 않고 직접 호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.** 이유: 핸들러 비교·`chains` 저장 bookkeeping이 `Dispatch.process` @@ -767,11 +783,15 @@ end `State>>`도 인덱스가 늘어날 뿐 정상 동작하고, 위 "깊은 체인에서도 힌트가 안 사라짐" 항목대로 **깜빡임 방지 최적화까지 정상 작동**함 — 유일하게 남는 UB는 위 "순환" 항목. -- **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에 - 중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰 - 미래의 재바인드는 `Dispatch.process(inst, k, newV, 1)` **한 줄**로 "이 - 키의 체인을 새 값에 맞춰 갈아 끼우기"가 됨(옛 모델에선 `retractFrom` + - `process` 두 줄이었음 — 하강 diff가 그 선행 철거를 흡수). 완전 해제만 +- **부수 효과 — quad-debug에 유리**: 이 체인이 Dispatch에 중앙화돼 + 있으므로, 임의 시점의 재바인드도 `Dispatch.process(inst, k, newV, 1)` + **한 줄**로 "이 키의 체인을 새 값에 맞춰 갈아 끼우기"가 됨(옛 모델에선 + `retractFrom` + `process` 두 줄이었음 — 하강 diff가 그 선행 철거를 + 흡수). **[2026-08-14 세션]** 이 문장이 원래 근거로 들던 "미래의 + existing-instance-bind"는 기각됐지만 + (`archive/existing-instance-bind-rejected.md`), 여기서 말하는 성질은 + quad가 **자기가 만든** 인스턴스의 store 재발행에서 매번 쓰는 그 경로 + 자체라 그대로 유효. 완전 해제만 원하면 `Dispatch.retractFrom(inst, k, 1)`. `research/debug-tooling-plan.md`의 "무엇이 무엇에 연결됐는가" 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — `handler`가 슬롯에 같이 저장되므로 "이 자리를 지금 누가 담당하는가"를 @@ -1011,7 +1031,7 @@ lazy 생성. **이 케이스를 명시적으로 UB로 명명(2026-08-11 세션, 사용자 제안)** — `Source`가 `State`를 "단방향"으로만 만족한다는 이미 확정된 원칙 -(`base/store-semantics.md` "Source가 State를 만족함" 절 — 파생값이 +(`base/source-state-plan.md` "Source가 State를 만족함" 절 — 파생값이 자기 upstream Source로 거꾸로 쓰기를 하지 않는다는 것)과 **같은 카테고리의 위반**이라는 게 근거: `recompute`가 만드는 `offset`/`Length`는 전부 `lengthList`(그 Slot의 upstream 입력)에서 파생된 다운스트림 값인데, @@ -1173,7 +1193,7 @@ value)로 `Dispatch.process(inst,k,realv,index+1)`를 재귀 호출"하는 식 **"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로 확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만 서술했는데(새 프리미티브를 발명하는 것처럼 읽힐 수 있었음), 실제로는 -`base/bind-system-plan.md`의 "`state:Observer(fn)`" 절에서 이미 확정된 것을 그대로 재사용하면 됨 — 새 +`base/source-state-plan.md`의 "`state:Observer(fn)`" 절에서 이미 확정된 것을 그대로 재사용하면 됨 — 새 구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음: ```lua diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index fa18f84..3d9ea89 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -47,10 +47,10 @@ leaf가 죽을 때 **마지막 cleanup을 한 번 더 호출**. 결과적으로 - **다수 의존성은 `:With(...)`로 먼저 하나의 State로 묶어서 넘길 것** — React식 별도 deps 배열을 새로 만들지 않음, quad가 이미 가진 다중 의존성 - 결합 관용구(`base/bind-system-plan.md` "`:With` + `:Compute`" 절)를 + 결합 관용구(`base/source-state-plan.md` "`:With` + `:Compute`" 절)를 그대로 재사용해 같은 일 하는 두 번째 경로를 안 만듦. **`Effect(fn, a, b, c)`처럼 trailing args로 바로 받는 sugar는 의도적으로 안 만듦**(2026-08-11 - 세션, `bind-system-plan.md` "`:Compute(fn, ...)` — 추가 의존성을 trailing + 세션, `source-state-plan.md` "`:Compute(fn, ...)` — 추가 의존성을 trailing args로 직접 받는 sugar" 절 참고) — `Compute`와 달리 Effect/Observer는 자기 자신이 결과를 담는 State 노드가 아니라서, 의존성이 둘 이상이면 그걸 합칠 **새 노드**(`:With`가 만드는 것)가 실제로 필요함. 그 비용을 sugar로 @@ -162,7 +162,7 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 규칙과 `canExecute(value)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` 플래그 → 2026-08-09 세션에 `canBound`로 명명 → **2026-08-14 세 번째 세션에 `canBound` 폐기, `canExecute`로 통합**)은 - `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정, + `base/source-state-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정, 2026-08-09 여섯 번째 세션] leaf 부착 후 조기 해제는 `:Unsubscribe()`가 아니라 `unbindLifetime(value)`** — leaf 부착 자체가 내부적으로 `bindLifetime(inst, value)` 호출이라, 그 해제도 짝인 `unbindLifetime` diff --git a/.claude/base/event-plan.md b/.claude/base/event-plan.md index be6f9f6..c1eb847 100644 --- a/.claude/base/event-plan.md +++ b/.claude/base/event-plan.md @@ -106,7 +106,7 @@ RenderStepped/마우스 무브처럼 안 쓸 때 Connection을 살려두는 것 낭비인 경우)나, 단순 on/off가 아니라 로직 자체가 바뀌는 드문 케이스. 자주 재계산되는 State에 이벤트를 직접 물리면 매 재계산마다 Disconnect+ Connect가 도는 숨은 churn 비용도 있음(Store Set은 dedup 안 함, -`store-semantics.md`) — 그래서 남용하지 말라는 캐비엇. +`base/store-plan.md`) — 그래서 남용하지 말라는 캐비엇. **그래도 일관성 있게 지원은 해둠.** "저빈도엔 필요 없다"가 "그러니 예외로 빼고 못 하게 막자"로 이어질 이유는 없음 — 프로퍼티/태그/어트리뷰트가 diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 9019747..4981d34 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -245,7 +245,7 @@ local InstData = Relate() -- inst -> gchold/gcconn (위 (0)에서 채워짐) local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움) function bindLifetime(inst, value) - -- 이중 바인딩 금지(base/bind-system-plan.md) — 게이트가 곧 canExecute. + -- 이중 바인딩 금지(base/source-state-plan.md) — 게이트가 곧 canExecute. -- "지금 실행 가능하다"는 곧 "이미 유효한 바인딩을 갖고 있다"는 뜻. if canExecute(value) then -- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건 @@ -308,7 +308,7 @@ end #### (2) 전역 경로 — `:Subscribe()`/`:Unsubscribe()` `inst`에 안 묶이는(모듈 최상위 디버그 print류) Observer/Effect 전용. 상세 -규칙과 경고는 `base/bind-system-plan.md`의 "`:Subscribe()`/`:Unsubscribe()`" +규칙과 경고는 `base/source-state-plan.md`의 "`:Subscribe()`/`:Unsubscribe()`" 절이 소스이고, 여기선 `canExecute`가 보는 상태만 못박음: ```lua @@ -336,7 +336,8 @@ end 강참조 루트(생존 보장), 필드는 `canExecute`가 매 발화마다 읽는 O(1) 경로 + 에러 메시지에서 "전역이냐 leaf냐"를 가르는 판별자. 둘은 항상 같이 쓰고 같이 지우는 한 세트(`:Unsubscribe()`가 필드만 내리고 테이블을 안 -비우면 반쪽짜리 해제가 됨 — `bind-system-plan.md`에 이미 확정된 규칙 그대로). +비우면 반쪽짜리 해제가 됨 — `base/source-state-plan.md`에 이미 확정된 규칙 +그대로). #### (3) `canBound` 폐기 — 게이트는 `canExecute` 하나 @@ -381,8 +382,8 @@ Instance 생성 시 한 번만 만들어지고, 이후는 `InstData:GetWeak`으 quad-roblox 구현 단계에서 실측 확인 대상 — 문제가 되면 gcconn을 `value`의 직접 필드로 내리는 선택지가 있음(옛 초안이 `self.Connection`으로 스케치했던 모양). 지금 `Relate` 쪽으로 둔 이유는 "Observer 값 자체에 부작용을 안 -남기고 외부 weak 인덱싱을 선호"라는 기존 사용자 방침(`base/bind-system-plan.md`의 -`state:Observer(fn)` 절 구현 노트)이고, 성능 근거가 나오면 뒤집어도 되는 +남기고 외부 weak 인덱싱을 선호"라는 기존 사용자 방침(`base/source-state-plan.md`의 +"`state:Observer(fn)`" 절 구현 노트)이고, 성능 근거가 나오면 뒤집어도 되는 순수 구현 세부. 이건 `base/dispatch-core-plan.md`의 "핸들러 내부 상태 저장" 유틸(`Relate` @@ -405,11 +406,10 @@ connection을 얻어 `disconnect()`하는 명시적 dispose 경로를 추가로 **재사용 사례(2026-08-04 2차 라운드)**: Store/State의 무효화(invalidate) 신호를 받는 리스너 클로저도 정확히 이 유틸로 등록됨 — `base/ -store-semantics.md`가 예전에 "state 옵저빙 결과로 slot을 조작할 때 생존 +store-plan.md`가 예전에 "state 옵저빙 결과로 slot을 조작할 때 생존 여부를 어떻게 확인할지" 미해결로 남겨뒀던 문제가, 사실은 새 메커니즘이 필요한 게 아니라 이 canExecute 게이트를 그대로 적용하면 되는 사례였음(별도 -`isInit` 분기 불필요). 상세는 `base/bind-system-plan.md`의 "Store/State/ -Source 온톨로지" 절 참고. +`isInit` 분기 불필요). 상세는 `base/source-state-plan.md`의 "Slot 생존 확인" 절 참고. ## 2026-08-04 검증 라운드에서 보강된 내용 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 746e133..4c2eaec 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -40,10 +40,11 @@ cascade 문제가 그대로 오는데, 이건 이미 확정된 "Store 바인드 "필수 관용구" 절 참고). 관련: 이미 마운트된 Instance에 재바인드할 때 Default→실값 flatten을 다시 -해야 하는지/clone이 필요한지는 별개 미정 문제로 -`research/existing-instance-bind-plan.md`의 "Default 값과 얽히는 문제" 절 -참고 — 여기서 다루는 건 컴포지션 타임의 modifier 값 자체 flatten이라 층위가 -다름. +해야 하는지/clone이 필요한지는 별개 문제였는데, **[2026-08-14 세션] 그 +재바인드 기능 자체가 기각**되어 질문이 없어짐 +(`archive/existing-instance-bind-rejected.md`의 "Default 값과 얽히는 문제" +절이 당시 서술) — 어차피 여기서 다루는 건 컴포지션 타임의 modifier 값 자체 +flatten이라 층위가 달랐음. **참고 — Property(일반 프로퍼티)에 Attribute식 "이름 소유권 레지스트리"를 적용하는 안은 검토 후 기각(2026-08-12 열일곱 번째 세션).** `Attribute` @@ -142,7 +143,7 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제 `Store({defaults})`와 같은 "`Type(args)` 팩토리" 관습을 그대로 적용하면 됨, Modifier는 초기 필드가 필수가 아니므로 `args`가 비어도 되는 `Modifier()`. `mod:FontSize(20)`처럼 체이닝하는 모든 예시가 실은 이 -`Modifier()`가 만든 빈 인스턴스 위에서 시작함. `base/store-semantics.md` +`Modifier()`가 만든 빈 인스턴스 위에서 시작함. `base/source-state-plan.md` "독립 존재 가능한 프리미티브" 절의 예시 목록도 이걸로 갱신. ### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음, 별도 Getter는 없음 @@ -308,7 +309,7 @@ Modifier에는 없음). "누가 modifier에 타입을 붙여주냐"는 새 문제가 아니라, Store/인스턴스 생성에 이미 적용한 "정적으로 알려진 건 dot-access, 동적인 건 문자열 폴백" 프로젝트 -전역 관습(`base/bind-system-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 +전역 관습(`base/store-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 됨 — `mod:UICorner(8)`/`mod:FontSize(...)`처럼 DI 쪽 "제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용. (주의: 이벤트는 이 관습의 유일한 예외라 인용 대상에서 제외 — 이벤트 바인딩은 @@ -379,7 +380,7 @@ predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접 분류. State/Source에 `Tween`가 담기는 것 자체는 여전히 문제없이 허용되지만(위 타입 대수 절 참고), 그 이유는 "재귀 dispatch 참가자라서"가 아니라 "그냥 raw 값이라서"로 바뀜. -- **`Store`의 `T`는 Modifier가 될 수 없음(`base/store-semantics.md` +- **`Store`의 `T`는 Modifier가 될 수 없음(`base/source-state-plan.md` "따름정리" 절)도 이 결정을 그대로 물려받음** — Source가 State를 구조적으로 만족하므로 별도로 다시 논증할 필요 없이 동일하게 적용됨. diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 2155ed6..c4c12fd 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -56,7 +56,7 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 부수적으로 확인된 것: - **Store 자체의 연산은 더 단순해져도 됨** — v1의 `:Add`/`:With`/`:Tween` 같은 이름 붙은 체이닝 연산(named modifier)은 명시적으로 안 만들기로 확정, 대신 - 일반 함수를 받는 형태로 통일(`base/store-semantics.md` 참고). "너무 verbose한 + 일반 함수를 받는 형태로 통일(`base/source-state-plan.md` 참고). "너무 verbose한 연산들은 오히려 일관성을 해친다"는 게 이유. (주의: 아래의 v2 `:With(...)`는 이름만 같을 뿐 여기서 안 만들기로 한 v1의 `:With`와는 다른 연산임 — v1은 "함수/테이블에서 값을 가져오는" 가공 연산이었고, v2는 그냥 "여러 State를 @@ -64,7 +64,7 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 - **여러 store 값을 묶어 유연하게 처리하는 방법**(`useEffect`류 dependency array)은 있으면 좋겠다는 요청이었고 — **API 시그니처도 확정됨**: `:With(...)`로 의존성을 모으고 `:Compute(fn)`으로 파생 State를 만드는 - 형태, 상세는 `base/store-semantics.md`의 "여러 스토어 값을 묶어 처리하는 + 형태, 상세는 `base/store-plan.md`의 "여러 스토어 값을 묶어 처리하는 것" 절 참고. - `can execute store bind` 후킹 자체는 `Connected` 계산 속성으로 대체된다는 잠정 제안이 그대로 유지되고, 여기에 더해 **완전 소멸(Destroy) 시점엔 아무 diff --git a/.claude/base/purity-and-effects-plan.md b/.claude/base/purity-and-effects-plan.md index 0653768..2ea14bc 100644 --- a/.claude/base/purity-and-effects-plan.md +++ b/.claude/base/purity-and-effects-plan.md @@ -36,7 +36,7 @@ ## 문서화 방향 -- `base/store-semantics.md`("Store는 부작용을 허용하는 게 기본 디자인")와 +- `base/store-plan.md`("Store는 부작용을 허용하는 게 기본 디자인")와 같은 결의 문제 — Store 자체의 부작용 허용 여부와는 별개로, **컴포넌트가 "자기 입력 밖의 상태"에 의존하면 이식성이 깨진다**는 원칙을 문서에 별도로 명시. diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 990091d..4b54012 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -84,7 +84,7 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 못 씀, 그래서 콜백도 같이 필요) **세 메소드로 확정(2026-08-07 여섯 번째 세션)**. `:Set`/`:Callback`/`:Wait` 전부 **mutation 패턴이라 자기 자신 (`Ref`)을 반환** — `store.key:Set(value)`류 "값을 바꾸는 연산엔 `:` - 체이닝 허용" 원칙(`base/store-semantics.md` 190행)의 자연스러운 재적용. + 체이닝 허용" 원칙(`base/store-plan.md`의 "Store 값 설정 문법" 절)의 자연스러운 재적용. 이 self-반환 덕에 Luau의 `if`-표현식과 결합해 흔한 관용구를 한 줄로 쓸 수 있음(사용자 제시 예): ```luau @@ -486,10 +486,13 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서 안 좋음). **`flatten(nonFlatten) -> flatten` 함수 자체에 얹는 방안은 검토 후 기각** — flatten은 Modifier 값을 합치는 순수 변환(현재 `inst`를 - 안 받음, `research/existing-instance-bind-plan.md`가 다루는 "이미 - 마운트된 Instance 재바인드 시 Default→실값 flatten을 다시 해야 하는가" - 질문이 실제로 열려있어 flatten이 한 인스턴스 생애주기 동안 **여러 번 - 재호출될 가능성이 있음** — 여기에 PreRef fire를 얹으면 재바인드마다 + 안 받음). 원래 근거는 "`archive/existing-instance-bind-rejected.md`가 + 다루던 '이미 마운트된 Instance 재바인드 시 Default→실값 flatten을 다시 + 해야 하는가' 질문이 열려있어 flatten이 한 인스턴스 생애주기 동안 + **여러 번 재호출될 가능성이 있다**"였고, **[2026-08-14 세션] 그 + 재바인드 기능 자체가 기각되며 이 위험은 사라졌지만 결론(기각)은 + 유지** — flatten은 여전히 `inst`를 모르는 순수 변환이라 fire 지점으로 + 부적절함 — 여기에 PreRef fire를 얹으면 재바인드마다 PreRef가 또 fire되어 "이 인스턴스 하나의 construction 훅"이라는 PreRef의 정의 자체가 깨짐. `Dispatch.drive`는 최초 마운트 시 정확히 한 번만 불리는 게 이미 전제라 이 위험이 없음. diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md index d102c21..502aaa3 100644 --- a/.claude/base/relate-plan.md +++ b/.claude/base/relate-plan.md @@ -51,9 +51,12 @@ Destroy 전까지 userdata 동일성을 고정해주므로, 모든 `inst`-키 `R 시점에 무조건 실행된다**(옛 lazy 생성에서 이번에 전환된 이유). **따름 정리 — quad 바깥에서 온 Instance를 `Relate` 키로 쓰는 건 UB.** quad가 -만들지 않은 Instance(`research/existing-instance-bind-plan.md`가 다루는 -영역)는 이 셋업을 안 거쳤을 수 있으므로, 그 스코프를 열 때 "키로 쓰기 전에 -gcconn 셋업을 먼저 건다"를 같이 설계해야 함. +만들지 않은 Instance는 이 셋업을 안 거쳤을 수 있으므로, 그런 스코프를 +여는 설계를 한다면 "키로 쓰기 전에 gcconn 셋업을 먼저 건다"를 같이 설계해야 +함. **[2026-08-14 세션]** 이 UB를 실제로 건드릴 뻔했던 유일한 기능(이미 +생성된 인스턴스 재바인드)은 기각됐으므로 +(`archive/existing-instance-bind-rejected.md`) 지금 열려 있는 경로는 없음 +— 이 따름 정리는 앞으로 비슷한 제안이 나올 때의 판단 기준으로만 유지. ## 일반 규칙 — 다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak` (2026-08-14 다섯 번째 세션) diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md new file mode 100644 index 0000000..6bcaeff --- /dev/null +++ b/.claude/base/source-state-plan.md @@ -0,0 +1,1148 @@ +# Source / State — 반응형 코어 (값의 원천과 그 위의 캐시 레이어) + +> **📄 [2026-08-14 신설] `bind-system-plan.md` 3단계 분할 + store-semantics.md +> 흡수.** `bind-system-plan.md`(반응형 코어)와 store-semantics.md(온톨로지 +> 배경)에 같은 내용이 반씩 흩어져 서로를 "상세는 저쪽 참고"로 가리키고 있던 +> 걸 한 군데로 합쳤음 — **내용/결정은 이동·병합 자체로는 안 바뀜**. Store +> 고유의 것(이름 붙은 Source 모음, `defaults`, dot-access 타이핑, 값 설정 +> 문법)은 짝 문서 **`base/store-plan.md`**로 갈라졌음. +> +> | 갈라진 곳 | 담는 것 | +> |---|---| +> | **이 문서** | `Source`/`State` 온톨로지·서브타입, 전파 모델, `:With`/`:Compute`/`:Apply`, `previous`, `:Emit`, `Observer`, 구독/생명주기 게이트 | +> | `base/store-plan.md` | Store = 이름 붙은 Source 모음 — `defaults`, eager/lazy 생성, `store.key` 타이핑, `:Set()` 문법 | +> | `base/dispatch-core-plan.md` | 디스패치 코어(핸들러 계약, `chains`, 하강 diff) | +> | `base/bind-system-plan.md` | 인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인 | + +**상태**: base — 전파 모델/`:Compute` 인자 규칙/State 쓰기 금지/Slot 생존 +확인/타입 추론(dot-access) 전부 2026-08-04 세 라운드에서 `AskUserQuestion`으로 +확인 완료, 이후 세션들에서 `:With` 새 노드화·trailing args·`Observer`/ +`:Subscribe`·이중 바인딩 게이트까지 확정. 남은 건 정확한 함수/생성자 +이름뿐(구현 단계). 원본: `.claude/initreq/raw-userinput.md` +"state는 어떻게 구현하는가" / "스토어는 스토어를 저장 가능한가" 절. + +> **[2026-08-13 열세 번째 세션, 해소]** self/deps를 lazy `State` 핸들로 +> 넘기는 `:Compute`/`:With` 콜백 계약은 한때 미해결(구 `question.md` +> 0-Y)이었으나 **그대로 유지로 확정**됨. 남은 것은 quad 설계 문제가 +> 아니라 Luau의 현 한계(파생 State의 반환 타입이 정적으로 검증되지 +> 않아 사용처에서 명시 주석 바인딩이 필요) — 전역 규약은 +> **`base/typing-limits.md`**, 실측 근거는 `audit/type-recursion-issue/`. + +## 핵심 온톨로지 + +- **Source** — 실제 값이 존재하고 변경될 수 있는 단일 지점(v1의 "값의 근원"). + **구조적으로 State를 만족(단방향 호환)** — `:Get()`/`:With`/`:Compute` + 전부 지원 위에 `:Set(value)`/`:Emit()` 추가. +- **Store** — Source들의 이름 붙은 모음, 그 이상 아님(상세는 짝 문서 + `base/store-plan.md`). +- **State** — source(또는 다른 state)의 결과를 캐싱만 하는 존재, 자기 고유의 + 독립적 value 개념이 없음. `state(state)`로 기존 state의 결과를 받아 새 + state를 만들어 분기 가능 — 이게 사실상 Unix 파이프 영감의 "State끼리 + 합성 가능"이라는 원래 목표를 구현하는 방식. + +### 정정(2026-08-04 검증 라운드): `State` 프리미티브는 실제로 필요하다 + +**이전 버전("State 프리미티브는 만들지 않는다")은 틀렸음 — 사용자가 +검증 라운드에서 직접 정정.** 정확한 모델은 위 온톨로지 그대로이고, 그 +중에서도 다음 두 가지가 핵심: + +- 단일 값에 대한 state 생성은 store가 자동으로 해주지만, 그 결과를 다시 + 분기하고 싶으면(하나의 파생 스트림에서 여러 소비자가 각자 다른 추가 + compute를 얹고 싶은 경우) `state(state)`처럼 기존 state의 결과를 받아 + 새 state를 만드는 조합이 필요. +- **[정정, 2026-08-06 후속 세션] store에서 값을 얻는 연산(`store.key`)은 + Source를 직접 반환한다 — 더 이상 별도 State 인스턴스를 감싸서 반환하지 + 않음.** 이 항목의 원래 버전("항상 새 state 인스턴스를 반환")은 틀림 — + Store가 별도 wrapper 없이 자기 안에 만들어둔 Source를 그대로 돌려주는 + 쪽으로 재정리됨(아래 "Source가 State를 만족함" 절). + +**`Pipe`(quad2-try 후보)는 폐기 확정** — 별도 `Pipe` 타입에 소유권/버전 +가드를 넣어 재설계하는 대신, State 자체가 파이핑 결합체이고 +`state(state)`로 분기하는 위 모델로 완전히 대체됨. quad의 Unix 파이프 +영감(원래 동기)과 `Pipe`/`fromState` 후보 검토 경위는 +`archive/quad2-try-research-findings-rejected.md`로 이전됨 — +**확인된 죽은 접근(OOP 상속 `Base:Extends`/`--&` 커스텀 파서/Slot 빈 스텁/ +`Pipe` copy-on-write 절충안)은 절대 반복 조사하지 말 것.** + +## Source가 State를 만족함 — 구조적 서브타입, RefSource 개념 폐기 (2026-08-06 후속 세션) + +**배경**: `store.key`가 매번 새 State를 감싸 반환하던 이전 모델의 타입 +문제(레코드 타입 `{key: State}`가 읽기/쓰기 비대칭이라 Luau +타이핑이 안 맞음)를 풀다가 사용자가 제안한 더 근본적인 재구성. +`RefSource`(store 슬롯을 가리키는 전용 타입)를 따로 만드는 중간안도 +검토했으나, 최종적으로 **Source 자체가 State를 만족하도록 만들고, +RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴. + +**확정 방향**: +- **`Source`가 구조적으로 `State`를 만족(단방향 호환)** — State + 자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable extends + Readable`와 같은 모양). Source는 State가 주는 모든 것(`:Get()`, + `:With(...)`, `:Compute(fn)`) 위에 `:Set(value)`/`:Emit()`을 + 추가로 가짐([정정, 2026-08-07] `.value`는 State/Source에서 제외되고 + `Get()`으로 통일됨, `.value` 표기는 Ref 전용으로 좁혀짐 — 아래 + "`:With`/`:Compute` — self 인자도 lazy 핸들로 통일" 절 참고). +- **`:With`/`:Compute`는 Source에서도 항상 `State`를 반환** — Source + 자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는 + 것과 동치. 구현은 metatable `__index` 델리게이션(Source의 메소드 + 테이블이 State의 메소드 테이블로 폴백)으로 충분 — `Modifier`의 제네릭 + `__index` 트릭(`base/modifier-plan.md`)과 같은 패턴이라 로직 중복이 + 생기지 않음. +- **`RefSource` 같은 별도 타입은 불필요, `Store({defaults})`가 + 내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨** — Store + 쪽 상세(eager/lazy 생성, `defaults` 템플릿의 성격, 구현 스케치)는 + `base/store-plan.md`가 소스. 별도 `__values`류 그림자 실값 저장소도 + 불필요 — Source 객체 자체가 저장소 역할을 함. 이 모델은 이전에 + 검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/ + 캐싱 단계 자체가 사라짐). +- **이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 OOP 상속과는 + 다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류 + 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 + 구현 델리게이션 포함)이라 그 금지와 충돌하지 않음. +- **동적 키 폴백(`store "key"`)은 이제 `State`가 아니라 `Source`를 + 반환**하는 것으로 자연히 갱신됨(`base/store-plan.md`의 "타입 추론 + 문제" 절과 연동). + +**[해소됨, 2026-08-13 첫 실측 라운드]** 핵심 질문(Source가 State를 구조적으로 +만족하는 제네릭 메소드 체이닝)은 `08-type-source-satisfies-state.luau`로 +실측 통과 확인됨 — 아래 우려대로 "두 제네릭 타입 별칭이 서로를 참조하는 +상호 재귀"는 실제로 위험했지만, 그 아래 제안한 단방향 의존(`State`가 +`Source`를 참조 안 함) 회피책이 그대로 맞아떨어짐. **다만 좁은 잔여 +케이스 하나는 남음**: `State`가 **자기 자신**을 다른 타입 인자로 +재귀 참조하면(`Recursive type being used with different parameters`) +막힘 — 이건 아래 논의 대상이던 "두 타입 간 상호 재귀"와는 다른 문제로, +**[2026-08-13 열세 번째 세션 결론] Luau의 현 한계로 확정**되어 +`base/typing-limits.md` 1번이 담당함(구 `question.md` 0-Y는 해소). +당시 검토됐던 "구울 때 인라이닝"(T별 코드 생성) 방향은 **채택 안 함** — +제네릭 자체를 없애버려 나중에 Luau가 고쳐져도 수혜를 못 받기 때문. +아래는 그 판단에 이른 원래 추론 과정(구분 기준 등)이라 계속 +유효한 배경 — `Source`의 `:Compute` 시그니처가 자기 자신(`Source`)과 +`State`를 동시에 참조하는 제네릭 메소드라, Luau 솔버가 재귀 타입 +조합에서 막히지 않는지가 원래 질문이었음. 구분해서 볼 것: +- **자기 자신을 가리키는 self 타이핑**(`{ Compute: (self: Source, ...) -> State }` + 같은 패턴)은 Luau에서 극히 흔하고 대체로 안전 — 모든 메소드 테이블 + 클래스가 쓰는 패턴이라 이것 자체가 위험 신호는 아님. +- **진짜 위험한 건 두 제네릭 타입 별칭이 서로를 참조하는 상호 재귀** + (`Source` 정의가 `State`를 참조하고, `State`도 거꾸로 + `Source`를 참조하는 경우) — 이게 Luau 솔버가 알려진 대로 취약한 + 패턴. **`State`가 `Source`를 전혀 참조하지 않도록 먼저 독립적으로 + 정의하고, `Source`만 `State`를 참조하는 단방향 의존으로 두면** + 이 위험한 패턴 자체를 피할 수 있어 보임 — 다만 이것도 추론이라 실제 + Luau로 확인 전엔 확정 아님. +- 사용자는 `&`(교차 타입) 조합보다 **타입을 손으로 펼쳐 쓰는(flatten) + 쪽을 선호**(엔지니어링 비용을 감수하더라도 솔버 안정성 우선) — 이건 + 런타임 구현의 델리게이션(위 항목)과는 별개 축이라 서로 충돌 안 함: + 타입은 펼쳐 쓰고 구현은 공유하는 조합이 가능함. + +**이름 주의 — [해소됨, 2026-08-12 스무 번째 세션]**: `Source`/`State`라는 +이름이 한때 용어 정리 대상(특히 `State`)이었으나, **`State`는 현재 이름 +그대로 유지로 최종 확정됨**(`Computed`/`Derived`/`Pipe` 전부 기각 — 근거는 +아래 "네이밍 — `Compute`가 `-ed`가 아닌 이유" 절과 `question.md` 1번). +더 이상 가칭이 아님. + +## 일반 원칙 — 독립 존재 가능한 프리미티브 vs 원천에 종속된 파생 데이터 (2026-08-06 후속 세션) + +위 "State는 자기 고유의 독립적 value 개념이 없다"는 관찰을 일반 원칙으로 +확장(사용자 관찰): quad의 개념들은 두 부류로 갈린다. + +- **독립 존재 가능한 프리미티브** — Source, Ref, Store, Modifier. 다른 + 무언가 없이 그 자체로 `Type(args)` 팩토리 함수로 만들어짐(`Source(default)`/ + `Ref(default)`/`Store({defaults})`/`Modifier()`, 아래 "생성자 + 스타일 확정" 참고 — `Modifier()`는 빈 인스턴스, 실제 필드는 + `mod:UICorner(8)`류 체이닝으로 그 위에 얹음). +- **원천에 종속된 파생 데이터** — State, Observer. 자기 혼자 존재할 수 + 없고 항상 특정 원천(Source/다른 State)에 의존 — 그래서 이 둘은 자유 + 함수 생성자가 없고, 항상 원천에 대한 메소드 호출로만 얻어진다 + (`store.key`/`state:Compute(fn)`/`state:With(...)` → State, + `state:Observer(fn)` → Observer). "클래스 같은 독립 타입"이라기보다 + "State를 관측·핸들링하는 데이터"에 가까움. + +이게 아래 `state:Observer(fn)`가 메소드고 `Observer(state, fn)`라는 자유 +함수가 없는 더 근본적인 이유 — 단순히 "읽기 편해서"가 아니라 Observer +자체가 State처럼 원천 없인 존재할 수 없는 카테고리라서. 앞으로 새 개념을 +추가할 때도 이 두 부류 중 어디에 속하는지가 생성자 모양(자유 함수 팩토리 +vs 원천에 대한 메소드)을 결정하는 기준으로 쓸 수 있음. + +**세 번째 카테고리 — Handler는 둘 중 어디에도 안 낌(2026-08-08 두 번째 +세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process` 3종 계약 — +`process`가 자기 retract 클로저를 반환, 2026-08-13 다섯 번째 세션 정정, +`base/dispatch-core-plan.md` "핸들러 계약" 절)는 위 분류가 다루는 +"quad 사용자가 직접 다루는 리액티브 값"이 아니라 **그 자체로는 구현체가 +없는 순수 타입 계약**이라 애초에 이 분류표의 대상이 아님 — Source/Ref처럼 +`Type(args)` 자유 함수로 인스턴스를 만들 수도 없고(계약을 만족하는 값은 +`PropertyHandler`/`TagHandler`/`Dispatch/StoreBind.luau`의 `NoneHandler`처럼 +**구현하는 쪽**이 리터럴 테이블로 직접 채워 넣는 것), State/Observer처럼 +어떤 원천에 종속된 파생물도 아님(애초에 "원천"이라는 개념 자체가 안 맞음). +Handler는 quad 사용자가 아니라 **백엔드/핸들러 구현자가 채우는 확장 +지점**이라는 완전히 다른 축의 개념이라, 여기 분류를 "왜 Handler가 +빠졌는지" 궁금해할 필요 없음 — 프리미티브 분류가 불완전한 게 아니라 +Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispatch`) 자체가 +왜 프리미티브가 아니라 탑레벨 싱글톤인지는 `base/dispatch-core-plan.md`의 +"Dispatch는 프리미티브가 아니다" 절 참고. + +## 전파 모델 확정: push-invalidate(신호만) / pull-recompute(`Get()` 시점에만) + +**Fusion식 eager 노드·생성순 정렬은 안 만듦.** + +- `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만 + 쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다 + 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구). +- 신호를 받은 State는 자기 `invalid` 플래그만 세우고, 이미 `invalid`였다면 + 그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는 + 장치(Vide가 저자 스스로 `todo.md`에 미해결로 남긴 문제의 해결책). +- 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 — + "필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/ + 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 + 소비자가 없다는 판단. 유일하게 "즉시 반응해야 하는" 소비자는 store-bind + pluggable 핸들러(`base/dispatch-core-plan.md`의 "확정된 디스패치 모델" 절)인데, 이건 무효화 신호를 + 받는 즉시 자기가 알아서 `Get()`을 호출해 pull하는 방식으로 충분함 — + State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀 + 필요 없음. +- `emit`은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함 + ("emit 필요 여부" 열린 질문은 이걸로 해소). + +**전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)** + +위 pull-recompute 규칙을 State 하나의 재계산 메커니즘으로만 읽지 말고, +프로젝트 전역에 적용되는 원칙으로 명시함: **어떤 파생값도 `:Get()`으로 +직접 읽히기(관측) 전까지는 계산되지 않는다.** 이 원칙은 State 자체뿐 아니라, +State를 필드 값으로 담고 있는 다른 구조(예: `base/modifier-plan.md`의 +Modifier)에도 그대로 적용됨 — Modifier의 getter가 State 필드를 읽으면 그 +순간이 바로 관측이고, 그 순간 계산이 확정됨. + +**주의 — 구조적 복사는 관측이 아님.** `table.clone`처럼 테이블 레퍼런스만 +복사하는 연산은 안에 담긴 State 핸들을 그대로 옮길 뿐 `:Get()`을 +호출하지 않으므로 관측이 아니고, 계산을 트리거하지 않음. Modifier 체이닝 +메소드가 `table.clone` 후 필드를 덮어쓰는 것(`base/modifier-plan.md`의 +"Immutable 값 + clone 기반 체이닝")과 이 원칙이 충돌하지 않는 이유가 바로 +이것 — clone은 그저 참조 복사라 State 필드는 클론 이후에도 여전히 살아있는 +lazy 핸들로 남음. + +### 왜 State 체인을 Modifier처럼 플래튼하지 않는가 (2026-08-06 후속 세션) + +**문제 제기(사용자)**: State가 `a → b → c`처럼 계속 연결되는 구조면, 이전 +노드가 다음 노드에 대한 emit 연결/값 연결을 항상 들고 있어야 함(weak +table로 GC는 되지만 별도 데이터스트럭처 관리 부담). 대안으로, 각 State가 +자기 Compute 함수 목록을 통째로 누적해서 갖고(Modifier의 clone-then-return +체이닝처럼) 매번 클론+append하면 링크드 그래프 자체가 필요 없어지지 +않는가? + +**기각 이유 — State의 정의 자체가 "캐싱하는 존재"임.** 위 온톨로지에 +"State — source(또는 다른 state)의 결과를 **캐싱만 하는** 존재"라고 +확정돼 있고, `previous` 두 번째 인자 메커니즘(무거운 파생 엔진 객체 +재생성 비용 절감)도 이 캐싱 전제 위에서만 의미가 있음. 만약 Compute +체인을 매번 통째로 클론해 각 leaf가 독립된 함수 목록을 갖게 하면, 중간 +State를 여러 갈래가 공유하는 다이아몬드 형태(`b`에서 `c1 = b:Compute(g1)`, +`c2 = b:Compute(g2)`로 분기)에서 `b`까지의 계산이 캐시 공유 없이 소비자 +수만큼 중복 실행됨 — `previous` 메커니즘이 막으려던 문제를 반대로 다시 +만들어내는 셈이라 방향이 안 맞음. + +**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** "관측해야 +실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건 +실제로 관측되는(`Get()`되는) State뿐 — 중간에 만들어놓고 아무도 안 보는 +State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 워크를 막는 +`invalid` 플래그 dedup 장치도 체인 전체가 링크드일 것을 요구하지 않고 +각 노드가 자기 구독자 목록만 가지면 되는 것이라, 이 결정과 무관하게 +그대로 유지됨. 구현은 Observer와 동일한 패턴(외부 weak table, +`{[child] = true}` 류)으로 충분 — 새 메커니즘 발명 아님. + +**결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 +플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는 +것이고, State는 존재 이유 자체(캐싱)가 달라 같은 패턴을 적용할 수 없음. +`research/documentation-plan.md`의 심화 문서 후보로 남겨둠 — "왜 State는 +Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사용자를 위한 +좋은 심화 콘텐츠 소재. + +## 여러 값을 묶어 파생값 만들기 — `:With` + `:Compute`, 포지셔널 인자 지양 + +**사용자 확인 완료, 상세 방향 확정.** 후보로 검토했던 두 방식 모두 기각: + +- **암묵적 자동 추적(Vide식 ambient stack)** 기각 — "함수 실행 중과 끝 사이를 + 확인하고 부작용이 필요"한 방식이라 Lua에서 깔끔한 방법이 아니라고 판단. +- **명시적 디펜던시 배열 + 포지셔널 인자**(`Store.Combine({a,b}, function(av,bv) + ...)`)도 기각 — 두 가지 이유: (1) 팩토리 함수로 store-bind 처리기를 쉽게 못 + 만들어줌, (2) 여러 팩토리를 체이닝하면 인자 순서가 꼬일 수 있고, 타입 표기도 + 어려워짐. + +**채택 방향**: `:With(...)`로 필요한 의존성을 모으고, 그 뒤 `:Compute(function() +... end)`에서 **`with`한 값을 포지셔널 인자로 받지 않고 클로저로 직접 읽는다** +(정확히 어떤 방식으로 "직접 읽는지"는 2차 라운드에서 확정 — self/with 값 둘 다 +lazy State 핸들로 통일, 아래 "`:With`/`:Compute` — self 인자도 lazy 핸들로 +통일" 절 참고). + +**v1과의 이름 충돌 주의**: **v1의 `:Add`/`:With`/`:Tween`처럼 값을 직접 +가공하는 이름 붙은(named) 체이닝 연산은 만들지 않음** — 대신 일반 함수를 +받아 처리. 아래의 v2 `:With(...)`는 이름만 같을 뿐 v1의 `:With`와는 다른 +연산임 — v1은 "함수/테이블에서 값을 가져오는" 가공 연산이었고, v2는 그냥 +"여러 State를 의존성으로 모으는" 수집 연산(v1의 `myStore "a,b"` 콤마-조인 +문자열 방식은 폐기 대상 — `reference/quad-v1-architecture.md`의 "문자열 DSL" +문제점 참고). + +**`fn`을 커링 스타일로 짜는 것도 권장(2026-08-07 일곱 번째 세션)** — +`key:Compute(makeFormatter("ko-KR"))`처럼 팩토리가 실제 `fn`을 만들어 +반환하는 패턴, Observer/Effect의 동일 관용구(아래 "`fn`을 커링 스타일로 +짜는 것도 모듈화 관용구로 권장" 절, `base/effect-plan.md`)와 같은 결 — +`:Compute`가 원래부터 이 셋 중 제일 먼저 있던 자리라 뒤늦게 문서화된 +것뿐, 새 결정이라기보다 이미 있던 패턴을 명문화한 것. + +**여러 소스를 한 번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하려면 +`Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아 +Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는 +store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브 +`base/blocker-plan.md`가 다룸(State 개발과 같은 +마일스톤, `ROADMAP.md` M3에서 함께 구현). lexical `Batch(fn)`으로 풀려던 +초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 — +`archive/batch-rejected.md` 참고. + +### 네이밍 — `Compute`가 `-ed`가 아닌 이유 (2026-08-12, `State` 용어 정리 라운드 후속) + +`Tag`의 `Added`/`Removed`, `Modifier`의 `Overridden`은 전부 `-ed`(과거분사) +어미를 의도적으로 씀 — `tag-plan.md`가 밝힌 이유는 "`Add`/`Remove`로 쓰면 +뮤테이션 API처럼 보이기 때문"(실제로는 항상 clone 후 즉시 확정된 새 값을 +반환). **`:Compute`/`:With`는 정반대 이유로 이 관례를 의도적으로 안 따름.** +Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"(이미 끝난 +일)가 정확한 묘사지만, `:Compute(fn)`이 만드는 State 노드는 **호출 시점엔 +`fn`을 등록만 해둔 것뿐이고 실제 계산은 나중에 `:Get()`이 pull할 때 +일어남**(push-invalidate/pull-recompute 모델, 위 "전파 모델 확정" 절) — +즉 호출 시점에 "computed"(이미 계산됨)라고 부르면 거짓. +`State`를 `Computed`로 리네임하는 안이 최종 기각된 것(`question.md` 1번)도 +같은 이유의 연장 — Vue `computed()`/Svelte `$derived`가 lazy인데도 그 +이름을 쓰는 건 그쪽 생태계에서 문제없지만, quad 자신의 코퍼스 안에서는 +"-ed 어미 = 이미 즉시 확정된 값"이라는 관례가 Tag/Modifier로 이미 자리 +잡아서, 같은 어미를 lazy한 것에 재사용하면 quad 자기 관례와 충돌해 오히려 +더 헷갈림. 그래서 `Compute`(동사 원형, "계산을 등록/설정한다"는 뜻)가 +`Computed`보다 quad의 명명 체계 안에서 정확함. + +### `:With`도 새 State 노드로 확정, 가변인자로 체인 남발 방지 (2026-08-07) + +**문제 제기(사용자)**: `:With(...)`가 문서상 가변인자 표기이긴 한데, 실제로 +호출마다(`:With(a):With(b):With(c)`처럼 체이닝할 때) 매번 새 State 노드를 +만드는 게 맞는지, 아니면 값 없이 의존성 목록만 clone-then-append로 누적하는 +가벼운 빌더로 만들어 노드 증식을 피해야 하는지가 불명확했음. + +**"빌더" 대안은 기각.** 세 가지 이유: + +1. **디버그 그래프가 꼬임.** `quad-debug`의 핵심 UX는 "무엇이 무엇에 + 연결됐는가" 그래프(`research/debug-tooling-plan.md`). With/Compute를 + 전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로 + 대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는 + 지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음. +2. **다이아몬드 dedup을 못 타고 특수 케이스가 생김.** With가 진짜 노드면 + `w = key1:With(key2)`에서 갈라지는 `c1 = w:Compute(g1)`, `c2 = + w:Compute(g2)` 같은 흔한 fan-out이 이미 확정된 "invalid 플래그로 + 다이아몬드 중복 워크 방지" 장치(위 "전파 모델 확정" 절)를 그대로 + 재사용함. 빌더면 c1/c2가 key1/key2에 각자 직접 구독을 걸어야 해서 + 기존 dedup 경로를 매번 우회하는 특수 케이스가 생김. +3. **clone 기반 구현은 Compute 노드 위에서 실제로 깨짐(사용자 지적, + 검증 완료).** `c = a:Compute(f)` 뒤에 `w = c:With(b)`를 clone으로 + 구현하면, `table.clone`이 `c`의 캐시 슬롯(계산된 값 + `invalid` + 플래그)까지 그대로 복사해 `w`가 `c`와 별개의 독립 캐시를 갖는 사실상 + 다른 노드가 됨. `c`와 `w`가 각자 관측되면 `f`가 두 번 따로 + 실행/캐싱됨 — 바로 위 "왜 State 체인을 Modifier처럼 플래튼하지 + 않는가" 절에서 이미 기각한 것과 정확히 같은 실패 모드(공유돼야 할 + 계산이 소비자 수만큼 중복 실행). Modifier의 clone-then-append 패턴을 + State 쪽에 그대로 가져오면 안 되는 이유가 바로 이것. + +**결정**: `:With(...)`는 호출마다 self+주어진 인자들을 구독하는 **새 State +노드**를 만든다(레퍼런스 기반 구독, clone 아님) — 계산 함수는 없고 값은 +`self`를 그대로 통과(pass-through)시키되 구독 목록만 넓힌 얇은 노드. 이 +노드는 Observer와 같은 패턴(외부 weak table)으로 상위 노드의 구독자 목록에 +등록됨. + +**⚠️ 문서 읽을 때 혼동 주의(2026-08-12 추가, 코퍼스 전체에 같은 패턴으로 +적용): `Tag`(`:Added`/`:Removed`)와 `Modifier`(`:Apply` 등)는 겉보기엔 +같은 `:` 체이닝 문법이지만 실제로는 clone-then-return이고, State의 +`:With`/`:Compute`는 이름은 비슷해 보여도 정반대(clone이 아니라 진짜 새 +노드)임.** 하나가 clone 계열, 다른 하나가 새-노드 계열이라는 걸 헷갈리기 +쉬우니(둘 다 "값을 안 바꾸고 새 걸 반환하는 메소드 체이닝"으로 보이기 +때문) 각 API 문서를 볼 때 이 문단을 기준으로 확인할 것 — clone 계열은 +`Tag`/`Modifier`(값 객체, 확정 상태), 새-노드 계열은 `State`의 +`:With`/`:Compute`(반응형, lazy)로 완전히 분리되어 있고 섞이지 않음. + +**노드 증식 걱정은 가변인자로 해소.** 처음 문제 제기("With 하나마다 노드가 +하나씩 늘어나는 게 낭비 아니냐")는 노드 자체를 없애는 대신, `:With(...)`가 +여러 의존성을 한 번에 받을 수 있게 해서 해소함: + +- `key1:With(a, b, c):Compute(fn)` — 노드 1개(구독 3개)로 끝남. +- `key1:With(a):With(b):With(c):Compute(fn)` — 여전히 가능하지만 노드 + 3개가 만들어짐. 이건 나쁜 게 아니라 각 노드가 dedup/디버그 그래프에서 + 실제 역할(구독 fan-in 지점)을 하는 저렴한 노드(계산 없음, Modifier + clone과 같은 급의 비용)라 걱정할 비용이 아님. +- 그래도 **가변인자 스타일을 권장 관례로 삼음** — 그래프로 그릴 때도 + `:With(a, b, c)`가 `:With(a):With(b):With(c)`보다 단순(노드 1개에 들어오는 + 엣지 3개 vs 노드 3개가 순서대로 이어지는 모양)해서 디버그하기 쉬움 + (사용자 확인). + +### `:With`/`:Compute` — self 인자도 lazy 핸들로 통일 + +> **[2026-08-13 열세 번째 세션, 해소 — 아래 계약은 그대로 확정]** +> 한때 이 계약이 Luau 추론과 충돌한다며 `question.md` 0-Y로 열려 있었고, +> "콜백이 raw 값을 받으면 완전히 클린"이라는 1차 판정까지 붙어 있었음. +> **44개 스파이크 재실측 결과 그 1차 판정이 뒤집혔음** — raw 값 계약도 +> 똑같이 불안전했고, 진짜 문제는 콜백 계약이 아니라 **`Compute`가 +> `State`(자기 이름을 다른 타입 인자로 감싼 타입)를 반환한다는 것 +> 자체**였음(Luau의 현 한계, RFC가 `Promise.andThen`으로 예시 든 바로 +> 그 패턴). **따라서 아래 lazy 핸들 계약은 바꿀 이유가 없고 그대로 +> 확정**이며, 콜백 파라미터 추론은 타입 선언을 "데이터부/메소드부"로 +> 쪼개면 해결됨. 반환 타입만 사용처에서 명시 주석으로 바인딩하면 됨 — +> 규약 전문은 **`base/typing-limits.md`**, 실측 근거는 +> `audit/type-recursion-issue/`. + +- 최초안(self 값은 포지셔널 raw 값, with한 값만 클로저로 읽음)에는 실제 + 단점이 있었음 — self가 raw 값이면 `fn` 호출 전에 항상 self를 먼저 + `Get()`해야 하므로, `fn` 내부 로직이 with한 다른 값을 보고 "이 경우엔 self + 계산 자체가 필요 없다"고 판단해도 이미 늦음(예: `:With(noprint)`이고 + `noprint:Get() == true`면 앞단 계산을 통째로 생략하고 싶은 경우). +- **해결(사용자 확정)**: self도 raw 값이 아니라 **State 핸들 그 자체**를 + `fn`의 포지셔널 인자로 넘긴다 — `fn(self: State)`, 내부에서 + `self:Get()`을 실제로 읽을 때만 계산이 트리거됨. with한 값과 동일한 + lazy 원칙을 self에도 그대로 적용 — 별도 `ComputeWithout` 변형은 + 불필요, `Compute` 하나로 일관. +- **[정정, 2026-08-07] `.value`는 State/Source에서 제외, `:Get()`만 지원.** + 이전엔 `Get()`을 감싼 읽기 전용 계산 속성(`base/lifecycle-pattern.md`의 + `Connected`와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴)으로 + `.value`/`:Get()` 둘 다 지원하고 `.value`를 관용적 표기로 앞세웠으나, + "관측해야 실체화된다"는 원칙이 가장 날카롭게 느껴져야 할 지점에서 + 프로퍼티 문법이 그 느낌을 무디게 한다는 재검토 끝에 함수 호출 + `:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. `.value` + 표기 자체는 폐기하지 않고 **Ref 전용으로 좁힘**(Ref는 lazy가 아니라 + 값을 읽어도 계산이 트리거되지 않으므로 프로퍼티 문법이 정직함 — + `base/ref-plan.md`의 `.Value`가 그대로 유일한 존재가 됨, 이름 + 충돌 자체가 사라져 별도 표기 정리 불필요). +- 예시 갱신: `store "key1":With(store "key2"):Compute(function(key1) return + key1:Get() + store.key2:Get() end)` — `key1`은 이제 raw 숫자가 아니라 + State. + +**[2026-08-12 세션 감사에서 확인] `:Compute` 콜백 인자에 `:Get()`을 빠뜨리는 +실수가 반복되기 쉬움 — 실제로 `.claude/` 문서 예시 코드 4곳(`tag-plan.md`, +`slot-plan.md` 2곳, `base/tween-plan.md`)에서 발견·수정됨.** `fn(self, +...)`의 모든 인자가 raw 값이 아니라 lazy State 핸들이라는 원칙(바로 위 절)을 +사람도 에이전트도 코드 작성 중에 잊기 쉬운 지점 — `:Compute`/`:With` 콜백 +안에서 인자를 비교(`==`)/연산(`+`)/테이블에 담기 전에 항상 `:Get()`부터 +거쳤는지 확인할 것. 예: `function(name) return name == "x" end`(버그) vs +`function(name) return name:Get() == "x" end`(올바름). + +### `:Compute(fn, ...)` — 추가 의존성을 trailing args로 직접 받는 sugar (2026-08-11) + +**문제 제기(사용자)**: React의 `useMemo(fn, deps)`처럼 `:With(...)` 없이 +`:Compute(fn, a, b, c)`로 바로 추가 의존성을 선언할 수 있으면 더 편하지 +않은가 — `self`가 이미 lazy 핸들로 `fn`에 넘어가는 구조라 값 언랩 방식이 +아니므로, 예전에 기각된 `Store.Combine({a,b}, function(av,bv)...)`(포지셔널 +값 언랩이라 타입 표기가 꼬였던 안)과는 다른 제안. + +**확정 — `Compute`엔 채택, `Observer`/`Effect`엔 채택 안 함. 근거는 "새 +노드가 실제로 생기는가"의 차이(사용자가 직접 구분).** + +- **`:Compute(fn, ...)`는 진짜 공짜 sugar.** `:Compute` 호출은 원래도 + 결과를 담을 새 State 노드(자기 자신의 계산 캐시 슬롯)를 만들어야 + 하므로, 그 노드가 `self` 말고 `a,b,c`에도 구독(무효화 엣지)을 추가로 + 거는 건 **이미 만들어지는 노드에 엣지만 더 얹는 것** — `:With(a,b,c):Compute(fn)` + 체인(노드 2개: pass-through With 노드 + Compute 노드)과 달리 노드가 + 안 늘어남(노드 1개). 구현은 `:With(...)`가 이미 하는 "구독 목록 확장" + 로직을 Compute 노드 생성 시점에 그대로 적용하는 것뿐 — 새 메커니즘 + 아님. +- **`Effect(fn, ...)`/`state:Observer(fn, ...)`류 trailing-args 확장은 + 기각 — 여기선 진짜 새 노드가 생기기 때문.** Effect/Observer는 Compute와 + 달리 **자기 자신이 결과를 담는 State 노드가 아님**(파생값을 안 만드는 + 순수 leaf 소비자, 위 "독립 프리미티브 vs 파생 데이터" 분류에서도 + 확인되는 차이) — `state`(receiver) 하나만 + 구독 가능하므로, 의존성이 둘 이상이면 그걸 하나로 합칠 별도 노드가 + 필요하고 그게 바로 `:With(...)`가 만드는 새 노드임. 이건 절대 공짜가 + 아니라 **정말 비용이 드는 지점**이라, trailing args로 감춰버리면 "이 + 줄이 실제로 새 노드/구독을 만든다"는 걸 코드만 보고 알 수 없게 됨 — + `:With`가 clone 빌더가 아니라 진짜 노드로 확정됐던 이유(2026-08-07 세 + 번째 세션, "코드상의 호출 체인이 그래프 엣지와 1:1로 대응돼야 quad-debug + 그래프가 안 꼬임")와 정확히 같은 원칙. 그래서 다중 의존성 Effect/Observer는 + **`Effect(fn, state:With(a,b,c))`처럼 `:With` 호출을 코드에 그대로 + 노출**하도록 유지 — 새 노드가 생기는 지점을 sugar로 숨기지 않는다는 + 게 핵심. +- **일반 원칙으로 정리**: "trailing args sugar는 그게 정말 무료일 때만 + 붙인다 — 호출부가 이미 만들어야 하는 노드에 엣지만 얹는 경우(Compute)엔 + sugar, 없던 노드를 새로 만들어야 하는 경우(Effect/Observer의 다중 + 의존성 병합)엔 sugar 없이 `:With`를 명시적으로 남긴다." `quadnomicon` + 에세이 후보로 좋음(`research/documentation-content-map.md` 6번 항목 + 다음에 추가) — "왜 Compute만 여러 deps를 편하게 받고 Effect/Observer는 + 안 그런가"가 겉보기엔 비일관적으로 보이지만 실제로는 "숨겨지는 비용이 + 있는가"라는 하나의 원칙에서 나온 것이라는 게 소재. + +### trailing deps를 `fn`에 lazy positional 인자로도 노출 — 방향+순서(`fn(self, previous?, ...deps)`) 확정, 이형 다중 deps 표현 가능 여부만 실측 필요 (2026-08-11 후속) + +> **[2026-08-13 열세 번째 세션, 해소]** 이 절이 얹혀 있던 "self도 lazy +> 핸들로 통일" 계약(구 `question.md` 0-Y)이 **그대로 유지로 확정**됨 — +> 전제가 안 흔들리므로 이 절의 결론도 유효. 다만 이 절이 남겨둔 실측 +> 항목(이형 다중 deps를 제네릭 팩으로 표현 가능한지)은 **여전히 +> 미검증**임: 그 스파이크(`15`)가 파싱 실패 상태라 재작성이 필요하고, +> 재작성해도 반환 타입 쪽은 `base/typing-limits.md` 1번 한계에 똑같이 +> 걸림(명시 주석 바인딩으로 대응). + +**문제 제기(사용자)**: `:Compute(fn, a, b, c)`가 이미 `a,b,c`를 trailing +args로 받아 구독을 건다면, 그 값을 `fn(self, a, b, c)`처럼 위치 인자로도 +그대로 넘겨줘도 되지 않는가 — `:With`가 값을 포지셔널로 안 주는 이유는 +`:With(a):With(b):With(c)`처럼 체인이 여러 호출에 걸쳐 길어지면 최종 +합쳐진 노드가 몇 번째 인자로 뭘 받는지 추적하기 복잡해지기 때문인데, +`:Compute(fn, a, b, c)`의 trailing args는 그 호출문 **하나 안에 로컬하게** +다 드러나 있어서 같은 문제가 없다는 지적. + +**방향 확정 — 채택.** 지적이 정확함: + +- **`:With`가 회피하는 문제 자체가 여기엔 없음.** `:With` 체인의 위험은 + 의존성 목록이 여러 호출/여러 스코프에 걸쳐 누적될 수 있어("체인이 + 길어지면 순서 지키기가 복잡") 최종 위치 매핑을 코드 한 줄만 보고 + 못 읽는다는 것 — `:Compute(fn, a, b, c)`는 그 반대로 한 호출문의 + 인자 목록 자체가 곧 최종 순서라 누적/추적 문제가 원천적으로 없음. +- **실질적 이득 — 커링 패턴에서의 중복/드리프트 위험 제거.** 지금 + 설계(trailing args는 구독 등록 전용, 값은 closure로 재획득)로 + `:Compute`를 커링 스타일(위 "`fn`을 커링 스타일로 짜는 것도 권장" 절)과 + 같이 쓰면 `a, b`를 **두 번** 써야 함 — 한 번은 `makeComputer(f, a, b)`의 + 클로저 캡처용, 한 번은 `:Compute(fn, a, b)`의 trailing args(구독 + 등록용). 리팩터링 중 한쪽만 바뀌면 "구독은 `a`에 걸려있는데 실제로 + 읽는 값은 `a'`"인 조용한 버그가 생길 수 있음. 값을 `fn`의 위치 + 인자로 노출하면 `makeComputer(f)`가 `a,b`를 아예 몰라도 되고 + (`function(self, a, b) return f(self:Get(), a:Get(), b:Get()) end`), + `:Compute`의 trailing args 목록 하나가 "무엇을 구독하는가"와 "`fn`이 + 몇 번째 인자로 뭘 받는가" 둘 다의 유일한 소스가 됨 — 중복 자체가 사라짐. +- **`self`가 이미 raw 값이 아니라 lazy 핸들로 넘어가는 원칙을 trailing + deps에도 그대로 적용** — `fn(self: State, dep1: State, dep2: + State, ...)`, 각 `depN:Get()`을 실제로 호출할 때만 그 값의 계산이 + 트리거됨. self에 대해 이미 확정된 "조건부로 특정 값을 아예 안 읽고 + 건너뛸 수 있음"이라는 이점이 trailing deps에도 똑같이 적용됨. + +**`previous`(아래 절, 2026-08-06)와의 위치 충돌 — 사용자 정정으로 확정, +`fn(self, previous?, ...deps)`.** 처음엔 "`previous`를 dep 개수와 무관하게 +항상 마지막 인자로 고정"(`fn(self, dep1, ..., depN, previous?)`)을 +제안했으나 **틀림 — 사용자가 정정**: Luau 값 레벨 `...`(vararg)가 +파라미터 리스트 맨 끝에만 올 수 있는 것과 똑같이, 타입 레벨 제네릭 팩 +(`...U`)도 함수 타입 시그니처에서 **항상 맨 끝**이어야 함(팩이 나머지 +자리를 전부 채우는 개념이라 그 뒤에 고정 타입이 하나 더 오는 건 Luau +타입 문법 자체가 원천적으로 허용 안 할 가능성이 매우 높음 — 이건 "안 +될 수도 있는 불확실성"이 아니라 "거의 확실히 안 되는 문법 제약"에 가까움). +반대로 **`previous`를 `self` 바로 다음, deps 팩 앞에 두면**(`fn(self, +previous?, dep1, dep2, ..., depN)`) 고정 인자 다음에 팩이 오는 정상적인 +모양이 되어 이 제약과 안 부딪힘 — **이게 유일하게 구조적으로 안전한 +순서라 이걸로 확정**. `N=0`이면 기존 `fn(self, previous?)`로 그대로 +축약되므로 하위 호환도 유지됨. **트레이드오프**: `previous`를 안 쓰고 +deps만 받고 싶어도 `previous`가 2번째 자리를 차지하므로, 그 경우 호출부는 +`function(self, _, dep1, dep2) ... end`처럼 안 쓰는 자리를 이름으로라도 +비워둬야 함 — deps만 쓰는 흔한 케이스가 약간 불편해지지만, Luau 문법 +제약상 다른 선택지가 없음(대안은 애초에 이 확장 자체를 안 하는 것뿐). + +**실측 필요 — `luau-test`의 `15-type-compute-trailing-deps-typepack.luau` +신규(ROADMAP.md M3 반영).** 순서 문제 자체는 위 정정으로 구조적으로 +풀렸으므로, 스파이크가 실제로 확인할 진짜 불확실성은 (B) 하나로 좁혀짐 — +나머지는 그 결론을 뒷받침하는 대조군: (A) 균일 타입 dep 1개를 고정 +인자로 좁히는 대조군(실패하면 B/C/D를 볼 것도 없이 기반 자체가 문제), +(B) 이형(heterogeneous) 타입 dep 여러 개를 제네릭 팩 하나로 정확히 +좁혀 받을 수 있는지(안 되면 위치 인자 노출 자체를 동종 타입 dep 1개로 +한정), (C) 처음 제안했던(틀린) "팩 뒤에 `previous?`" 순서가 실제로 +막히는지 보여주는 음성 대조군(막혀야 정상), (D) 정정된 "`previous?` 뒤에 +팩" 순서가 통과하는지 보여주는 양성 대조군(통과해야 정상 — 예상과 +다르게 C가 통과하거나 D가 막히면 이 순서 결정 자체를 재검토). + +### `:Compute(fn)`의 선택적 두 번째 인자 — `previous` (무거운 파생 객체 재사용, 2026-08-06) + +**배경**: `:Compute`의 결과가 그 자체로 무겁고 재생성 비용이 큰 엔진 +객체일 수 있음(예: 큰 로케일 테이블을 Roblox `LocalizationTable` +Instance로 변환하는 경우 — `LocalizationTable`은 `Set`/`Get`/`List`로 +부분 갱신 가능한 userdata). 매번 새로 만들지 않고 이전 결과를 그대로 +재사용해 필드만 patch하고 싶을 때를 위해, `fn(value, previous)` 형태로 +**직전에 이 Compute 함수가 반환했던 값**을 두 번째 인자로 받을 수 있게 +한다. + +- **opt-in**: 안 쓰는 Compute 함수는 두 번째 인자를 그냥 무시하면 됨 — + 비용 0. 대부분의 Compute는 이걸 쓸 필요 없음. +- **`previous`는 "바로 직전 버전"이 보장되지 않음.** lazy pull 모델이라 + 중간에 여러 번 무효화됐어도 실제로 관측(`Get()`) 안 됐으면 재계산 + 자체가 안 일어남 — 그래서 `previous`는 몇 세대 전 값인지 알 수 없음. + **따라서 `previous`를 다루는 로직은 반드시 "현재 입력 전체 대 이전 + 결과 전체"의 full diff여야 하고, "정확히 한 단계 전"이라고 가정하는 + incremental delta 로직을 짜면 안 됨.** 이건 React 자체의 reconciler가 + 하는 것과 같은 모양(old tree/new tree 전체 비교 후 실제 host 객체에 + 패치 적용)이라 새로 발명하는 패턴은 아님. +- 최종 소비처가 patch된 값을 다시 한번 Set/Parent하게 되는 경우가 + 있어도(레퍼런스는 같은데 다시 대입) 대체로 치명적이지 않음(Roblox + 프로퍼티 재대입은 저렴/멱등인 경우가 대부분) — 문서화만 해두면 충분. + +**⚠️ 이 패턴을 쓸 때 반드시 같이 지켜야 하는 것 — "확정(관측)되기 전엔 +연산이 없다".** `previous`를 mutate하는 로직은 Compute 함수 **본문 +안**에 있으므로, 그 함수가 재실행되지 않으면(=아무도 다시 `Get()`하지 +않으면) mutation 코드 자체가 아예 실행되지 않는다 — 단순히 "가끔 +stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. 이 패턴으로 +만든 State는 반드시 다음 중 하나로 계속 능동적으로 관측되어야 함: +1. quad의 정상적인 선언적 prop 바인딩 경로(`[Property "X"] = someState` + 류)에 실제로 물려있어서, dispatch 엔진이 무효화 시 자동으로 + 재`Get()`하게 되어 있거나, +2. 아래 "Observer" 절의 `state:Observer(fn)` + 콜백 안에서 명시적 + `Get()` 호출 + 그 결과를 children 배열에 넣어 라이프사이클에 + 묶어두기. +"Ref로 한 번 얻어서 수동으로 Parent만 하고 끝"처럼 능동적 관측 경로가 +안 남아있으면, 이 최적화는 그냥 조용히 작동을 멈춘다. + +**[2026-08-09 세션] 오버엔지니어링 의심 재검토 — 기각, 현재 설계 +유지.** `research/pre-implementation-audit.md` 3-1이 "클로저 업밸류로 +이미 되는 걸 별도 API로 만든 것 아니냐"고 의심했던 것에 대한 사용자 +반박: 클로저 업밸류 대안은 실제로 다음처럼 즉시실행함수(IIFE)로 감싸 +업밸류를 준비해야 함 — + +```lua +local computeFn = (function() + local prev + return function(self) + -- prev를 읽고 새 값을 계산, prev 갱신 + prev = ... + return prev + end +end)() +someSource:Compute(computeFn) +``` + +이 준비 코드 자체가 이미 별도 `previous` 인자 하나보다 무겁고 번거로움 +— "재사용하고 싶으면 그냥 캐시된 값을 바로 넘겨주면 되는" 게 더 +단순하다는 게 사용자 논거. 반대로 `previous`가 없으면 `fn`은 매 호출마다 +새 인스턴스를 만들어야 해서(예: `LocalizationTable.new()`) lazy든 +아니든 재계산이 일어날 때마다 항상 비싼 재생성이 발생 — `previous`가 +막으려는 문제는 실재함. **`pre-implementation-audit.md` 3-1 해소 — +현재 `fn(self, previous)` 설계 그대로 유지, API 표면을 줄이지 않음.** + +**스코핑 명확화(2026-08-09 세션에 확인, 새 결정 아님) — `previous`는 `self` +(입력)가 아니라 "이 `:Compute` 호출 하나가 만들어낸 결과 State 노드" +자신에 귀속된다.** State가 `:With`/`:Compute` 호출마다 새 노드를 +만든다는 건 이미 확정된 온톨로지(위 "왜 State 체인을 Modifier처럼 +플래튼하지 않는가" 절)라, `previous`도 그 새 노드의 내부 캐시 슬롯일 +뿐 `self`에 얹히는 게 아님 — 같은 `self`에서 여러 `:Compute`가 갈라지는 +팬아웃(`c1 = w:Compute(g1)`, `c2 = w:Compute(g2)`)이 있어도 `g1`/`g2` +각자의 `previous`는 각자의 결과 노드에 독립적으로 저장되므로 서로 안 +섞임 — 새로 결정할 것 없이 기존 "노드별 캐시" 원칙의 당연한 귀결. +(참고: `self.Cache`처럼 `self` — 즉 입력 — 에 캐시를 얹는 모양은 이 +스코핑과 안 맞아 채택하지 않음 — 팬아웃 시 여러 소비자가 같은 +`self.Cache` 슬롯을 공유해 덮어쓰는 충돌이 생기기 때문.) + +## State는 쓰기 대상이 아님 — 확정, Source는 독립 공개 프리미티브로 격상 + +- `state:Get()`은 항상 읽기 전용. State에는 쓰기 API가 아예 없음. "State에 + 직접 쓰기 API를 허용하면 다른 source에서 파생된 state에 직접 쓰기가 + 가능해져 버린다"는 이전 우려는 이걸로 근본적으로 해소(그런 API 자체가 + 없음). +- **[정정, 2026-08-06 후속 세션] 값을 쓰는 경로는 `store.key = value` + (`__newindex`)가 아니라 `store.key:Set(value)`로 전환됨** — 이유와 + 상세는 `base/store-plan.md`의 "Store 값 설정 문법" 절 참고(요지: + Source가 State를 만족하는 구조로 바뀌며 레코드 타입 읽기/쓰기 대칭을 + 맞추려면 대입 문법을 포기해야 함 + `=`가 암시하는 "즉시 커밋"이 실제 + lazy 동작과 정서적으로 안 맞는다는 논거). +- **`Source`는 Store의 내부 구현 디테일이 아니라 별도의 가벼운 공개 + 프리미티브로 노출** — Store는 다수의 source를 등록/관리하는 무거운 + 구조라, 값 하나만 반응형으로 다루고 싶을 때 Store를 통째로 만드는 건 + 비효율이라는 게 사용자 판단("store가 source 수십 개 만드는건 비효율이니 + 둘이 다른 구현이라 봐도 될듯"). `Source(initial)` 류의 독립 생성자 + (정확한 이름은 구현 단계에서 확정)가 Store와 나란히 존재. +- **생성자 스타일 확정(2026-08-06 후속 세션): Kotlin Compose식 "타입 + 이름 자체를 팩토리 함수로" — `Source(default)`, `Ref(default)`, + `Store({defaults})`.** Ref도 예외 없이 이 스타일을 따름 — Ref가 + `Ref()`로 안 만들어질 특별한 이유는 없었고(이전 절에서 API 모양만 + 다루고 생성자를 명시 안 해서 생긴 공백), `architecture.md`의 "복사 + 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`도 + 같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도 + 됨, 순수 편의용 초기값 템플릿). +- **[보강, 2026-08-09 열한 번째 세션] `Source(default)`/`Ref(default)`의 + `default` 인자가 "선택"이라는 서술은 정확히는 `T`가 `nil`을 포함할 때만 + 성립함 — 생략하면 실제로 `nil`이 그 자리를 채우기 때문.** `Source()` + (무인자)는 `Source(nil)`과 동치라고 이미 명시돼 있으나, 이게 타입 + 레벨에서 뭘 뜻하는지(`T`가 nilable이 아니면 타입과 실제 저장값이 + 어긋난다는 것)는 지금까지 명시적으로 안 적혀 있었음. `Ref`도 마찬가지 + 캐비엇이 있고 오히려 더 눈에 띄게 드러남 — `:Callback(fn)`은 등록 + 즉시 그 시점 값으로 무조건 1회 호출되므로(미설정 상태여도 그 상태 + 그대로 호출, `base/ref-plan.md`의 "바인드 방법" 절 참고), + `default`를 생략한 `Ref()`에 콜백을 걸면 그 콜백이 즉시 `nil`로 한 번 + 불림 — `T`가 non-nilable이면 이 시점에 이미 타입 위반. 따라서 + `default`를 생략해도 되는 건 오직 `T`가 nilable(`T?`)로 선언된 + 경우뿐이라는 걸 문서 차원에서 명시할 것(non-nilable `T`에 `default` + 없이 생성하는 건 사용자 실수, 타입으로 막을 수 있으면 막고 안 되면 + UB로 문서 경고). + +## Source 값을 직접 mutate한 뒤 전파 — `:Emit()` (2026-08-06 후속 세션, 호출부 정정) + +**결정**: Source가 들고 있는 값을 새 값으로 교체하지 않고 제자리에서 +mutate한 뒤, `:Emit()`으로 무효화 신호만 별도로 쏘는 것을 **Source +원천(store가 직접 들고 있는 값)에 한해 허용**한다. + +**[정정, 같은 세션 후반]** 원래 `Store:Emit(key)`(Store에 key를 넘겨 +호출)로 적혀있었으나, 위 "Source가 State를 만족함" 절에서 `store.key` +자체가 Source를 직접 반환하는 것으로 바뀌면서 `Emit`도 Source의 평범한 +메소드로 이동 — `store.key:Emit()`(key 인자 불필요, 이미 손에 든 Source +핸들에 바로 호출). `Store:Emit(key)`라는 별도 경로는 유지할 이유가 +없어져 폐기(같은 걸 하는 두 번째 경로를 남기지 않는다는 그 세션 전반의 +원칙과 일치 — `store.key = value` → `store.key:Set(value)` 정리와 같은 결). + +**존재 이유(우선순위순)**: +1. **clone이 아예 불가능한 값이 있음.** userdata나 외부 라이브러리 + 객체(엔진 Instance 등)는 `table.clone`으로 새 값을 만들 수 없음 — + 이런 값은 "새 값을 만들어 Set"이라는 대안 자체가 없으므로, in-place + mutation + `Emit`이 변경을 전파하는 유일한 수단. +2. Lua의 불변 업데이트가 verbose함(JS의 `{...t, x=1}` 같은 문법이 없어 + `table.clone` 후 필드 덮어쓰기 + 재대입 필요) — 이걸 줄여주는 부차적 + 이득도 있지만, 이게 주된 이유는 아님(1번이 진짜 이유). + +**왜 새 구멍이 아닌가**: `Get()`은 원래도 라이브 테이블 레퍼런스를 +돌려주므로, 그 레퍼런스를 mutate하는 것 자체는 `Emit` 유무와 무관하게 +Lua에서 항상 가능한 일. `Emit`이 없으면 그 mutation은 "조용히 반영 안 +되는"(dependent가 재계산 안 됨, UI가 stale한 채 멈춤) 상태로 남을 뿐이라 +오히려 `Emit` 없는 쪽이 더 나쁜 버그 클래스 — `Emit`은 이미 가능한 +mutation에 정식 신호를 붙여주는 것뿐. + +**남는 캐비엇(문서에 반드시 명시) — `Get()`은 라이브 레퍼런스를 준다**: +`Get()`으로 이전에 그 테이블을 읽어서 어딘가(로컬 변수, 다른 코드가 들고 +있는 참조)에 캐시해둔 게 있다면, mutation 순간 그것도 같이 바뀐다 — 새 +테이블이 아니라 같은 레퍼런스라서. **`Get()` 결과를 나중 비교(`==`)나 +diff 캐시 용도로 들고 있으면 안 됨 — 항상 다시 `Get()`할 것.** + +**하드 경계 — Source 원천에만 허용, 중간/파생 State에는 없음.** `:With`/ +`:Compute`로 만들어진 파생 State에는 `Emit`이라는 개념 자체가 없다 — +허용하면 "이 State의 현재 값이 뭘 근거로 계산됐는가"를 아무도 설명할 수 +없게 되어(quad-debug가 추적하려는 "무엇이 무엇을 계산했는가" 그래프가 +깨짐) 디버깅이 사실상 불가능해짐. State의 값은 항상 "선언된 Compute +함수를 실제로 실행한 결과"여야 한다는 불변식이 깨지면 안 됨. 무거운 +파생 객체를 재사용하고 싶은 경우(Compute의 결과 자체가 무거운 userdata인 +경우)를 위한 별도 메커니즘은 위 "`:Compute(fn)`의 선택적 두 번째 인자 — +`previous`" 절 참고 — 이건 `Emit`과 다른 메커니즘. + +### 따름정리 — `Store`/`Source`의 `T`는 Modifier가 될 수 없음 + +Modifier는 정적 flatten으로 dispatch와 완전히 별개인 단계에서 처리되고 +(`base/modifier-plan.md`) — Store/State/dispatch 경로엔 애초에 +Modifier용 processor가 없음. **[정정, 2026-08-09 세션]** `State` +조합은 "UB, 가능하면 타입 차단"이 아니라 **명시적 `error`로 확정** +(`modifier-plan.md` 7번) — `isModifier` predicate를 `Source:Set()`/ +Store 생성 시 eager `Source(default)`/State의 `:Compute` 결과 캐싱 +지점에서 확인해 런타임에 직접 막음, 타입 차단은 되면 좋은 보너스일 +뿐 유일한 방어선이 아님. **[2026-08-06 후속 세션 추가]** Source가 +State를 구조적으로 만족하게 되면서 이 제약은 `Source`(Store를 +거치지 않는 독립 `Source(someModifier)`)에도 동일하게 적용됨을 명시 — +Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processor +부재)가 그대로 적용되고, 별도로 다시 논증할 필요 없음. 위 "하드 경계"와 +같은 이유로, `Emit`이 Modifier의 정적 flatten과 충돌할 걱정 자체가 +성립하지 않음(둘이 만날 지점이 없음). + +## `state:Apply(factory)` — Modifier와 동일한 순수 체이닝 설탕으로 확정 (2026-08-07 일곱 번째 세션) + +**처음 제안됐던 "`:With`/`:Compute` 등록을 커링으로 자동화하는 조합기" +방향은 기각됨 — 사용자가 재확인한 실제 의도는 그보다 훨씬 단순함.** +`Modifier:Apply(factory)`도 매번 새 값을 만들어내는 체이닝 설탕일 뿐이듯, +State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양이라 — +`state:Apply(factory)`는 그냥 `factory(state)`를 메소드 체이닝 문법으로 +쓴 것뿐이고 그 이상의 계약은 없음(`Modifier:Apply`와 완전히 동일한 +정의: `function(self, factory) return factory(self) end`). + +- **동기**: 커링 팩토리 두 개 이상을 이미 있는 문법만으로 이으면 바깥에서 + 안으로 겹쳐 읽어야 하는 중첩 호출이 됨 — 실제 형태로 예를 들면, + ```lua + -- Apply 없이: 안쪽(가장 최근에 만든 것)부터 거꾸로 읽어야 함 + local capped = capAt(100)(withLocale(localeStore.locale)(rawScore)) + + -- state:Apply로: 왼쪽에서 오른쪽, 만든 순서 그대로 읽힘 + local capped = rawScore + :Apply(withLocale(localeStore.locale)) + :Apply(capAt(100)) + ``` + 팩토리가 세 개, 네 개로 늘어날수록 앞쪽 버전은 괄호 깊이와 읽는 방향이 + 코드 작성 순서와 반대로 꼬여 diff/리뷰에서 특히 안 좋음 — `:Apply` + 버전은 각 줄이 "그다음 뭘 했는지"를 순서대로 나열하므로 Modifier + 체이닝(`mod:FontSize(14):Apply(Boldify(10)):Apply(Italicify)`)과 읽는 + 방식이 완전히 통일됨. `:With`/`:Compute` 자체를 대신 호출해주는 + 자동화가 아니므로, 여전히 팩토리 본문 안에서 `:With`/`:Compute`를 + 직접 호출하는 건 팩토리 작성자 몫. +- **구현 비용 거의 0**: Modifier와 달리 State/Source는 제네릭 `__index`로 + 필드 setter를 즉석 합성하는 메커니즘이 없어서(고정된 메소드 표면만 + 존재), Modifier의 `Apply`처럼 "필드 이름으로 예약해야 하는" 충돌 + 자체가 없음 — 그냥 고정 메소드 하나 추가하는 것. +- **타입은 `factory: (State) -> U): U`로 완전히 열어둠** — Modifier의 + `Apply`는 `factory: (M) -> M`으로 같은 타입을 유지해야 체이닝이 + 이어지지만, State의 `:Apply`는 팩토리가 State가 아닌 값(예: 최종 + 요약된 plain 값)을 반환해 반응형 그래프를 벗어나는 탈출구로 쓰는 것도 + 막을 이유가 없음 — Modifier보다 오히려 더 자유로운 시그니처. +- **Source도 자동 포함**: Source가 State를 구조적으로 만족하는 기존 + 델리게이션(`__index`로 `:With`/`:Compute` 위임)에 `:Apply`도 그대로 + 얹히므로 별도 구현 불필요. +- **Effect/Observer/Compute의 `fn` 커링 권장(위 절들)과 같은 스레드지만 + 별개 기능** — 커링은 "`fn` 자체를 팩토리로 짜는 관용구" 권장이고, + `:Apply`는 그렇게 만든 팩토리를 체이닝 문법으로 적용하는 수단. 둘이 + 합쳐지면 `state:Apply(makeFormatter("ko-KR"))`처럼 자연스럽게 이어짐. +- **관용구 — 이름 붙여 재사용하는 콤비네이터는 항상 `:Apply`로 붙인다 + (2026-08-12 세션, `research/operator-sugar-plan.md`/`base/tween-plan.md`의 + `Animate` 정정에서 도출)**: 그 자리에서 한 번 쓰고 + 마는 인라인 람다(deps도 그 호출문에 바로 나열)는 `:Compute(fn, + ...deps)`를 직접 쓰고, `local addTax = Sum(tax, shipping)`처럼 이름 + 붙여 여러 곳에서 재사용할 콤비네이터는 인자 개수(0항/N항)와 무관하게 + 전부 `factory(self) -> State`를 반환해 `:Apply`로 붙임 — 스타일 + 선호가 아니라 정합성 문제: quad는 암묵적 자동 추적을 기각했으므로 + (위 "암묵적 자동 추적 기각" 부분) 재사용 팩토리가 캡처한 deps를 + `:Compute`에 직접 꽂으면 그 deps가 구독 목록에 안 걸려 조용히 + 멈추는 버그가 됨 — `:Apply`는 factory 내부에서 `self:Compute(fn, + ...deps)`를 스스로 다시 전달하므로 이 문제가 없음. + +## `state:Observer(fn)` — 값을 안 실어주는 구독, children 배열에 직접 놓는 leaf 값 + +**결정(2026-08-06 후속 세션, 사용자 확정)**: 별도 `ObserverHolder` +래퍼 타입은 안 만듦 — `state:Observer(fn)`가 반환하는 값 자체가 이미 +"children 배열에 바로 놓을 수 있는 leaf 값"이라 감쌀 필요가 없음. +`Ref`와 완전히 같은 층위. **자유 함수 `Observer(state, fn)`가 +아니라 메소드 `state:Observer(fn)`로 확정** — `state`가 항상 필요한 +필수 인자라 `:` 리시버 자리에 자연스럽게 들어가고(다른 형태면 인자 +두 개짜리 자유 함수가 되어 읽는 순서가 어색해짐), `architecture.md`의 +"함수지향 디폴트, `:` 체이닝은 예외적으로만(체이닝이 정말 편한 경우만)" +원칙이 정확히 이 경우를 가리킴 — Store 값 변경 체이닝과 같은 예외 +카테고리. **더 근본적인 이유**: 위 "독립 존재 가능한 프리미티브 vs +원천에 종속된 파생 데이터" 원칙 참고 — Observer는 State처럼 원천 없이는 +존재할 수 없는 파생 데이터라, 애초에 "타입 이름을 부르는 자유 함수 +생성자" 카테고리에 안 속함(Source/Ref/Store/Modifier와는 다른 부류). + +```lua +local observer = state:Observer(function() + state:Get() +end) + +Frame { + observer, +} +``` + +이러면 `observer`는 `Frame`이 살아있는 동안만 유지되고, `Frame`이 +retract/Destroy되면 자동으로 정리됨. + +- **`fn`은 등록 시점에 즉시 1회 실행된다(2026-08-07 여섯 번째 세션, + 사용자 확정 — 이전까지 미명시였던 항목).** 근거: (1) 이미 채워진 + State를 나중에 구독하면 그 값을 반영하는 연산이 아예 한 번도 안 + 일어나는 문제가 생겨 초기화 순서에 디버깅 부담이 생김. (2) 초회 + 실행을 하지 말아야 할 구체적 근거가 약함. (3) **이 결정 덕에 + Observer 하나로 "초기값 적용"과 "이후 변경 반영"을 같은 코드 경로로 + 통일할 수 있음** — 예: State→프로퍼티 store-bind 핸들러가 그냥 + `state:Observer(function() inst.SomeProp = state:Get() end)`를 걸어 + 두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를 + 따로 안 짜도 됨). `state:Observer()`(인자 없는 "항상 관측" 유틸)도 + 이 규칙을 그대로 따름 — 호출 즉시 한 번 관측이 트리거됨. +- **값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함.** 기존 "emit은 + 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(위 + "전파 모델 확정" 절)이 그대로 적용됨: `fn`은 "뭔가 + 바뀌었으니 다시 확인하라"는 신호만 받고 새 값 자체는 안 받음 — + 위 예시처럼 `fn` 본문에서 `state:Get()`을 명시적으로 다시 + 읽어야 함. 자동으로 안 해주는 이유: 재계산이 진짜 필요한지가 다른 + `:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 + `noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()` + 호출 여부를 작성자가 직접 결정하게 열어둔 것. +- **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯 + 번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제 + `fn`을 만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링(`modifier-plan.md` + 8번)과 같은 결. `base/effect-plan.md`의 Effect도 동일하게 권장. +- **base가 제공하는 것은 `isObserver`류 타입 판별자 하나** — children + 배열 dispatch가 숫자 슬롯 값을 훑을 때 "이게 Observer인가"를 판별해 + `Ref`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 + 더 해줄 일이 없음. 새 dispatch 메커니즘이 아니라 기존 children-array + 참가자 패턴의 반복. +- **콜백 실행은 기존 `canExecute` predicate로 게이팅**(Slot 생존 확인과 + 동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님) + — 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op. + **[명시화, 2026-08-14 다섯 번째 세션] 이 게이팅이 일어나는 자리는 State의 + 전파 루프**다 — State는 구독자를 **weak로** 담고, 발화 시 각 구독자마다 + `canExecute(observer)`를 확인해 거짓이면 그 구독자만 건너뜀. 여기에 + `inst`가 없다는 사실이 `canExecute`가 `value` 하나만 받아야 하는 + 이유(`base/lifecycle-pattern.md`의 "실제 호출부" 절, 옛 2-인자 + 시그니처의 역전 경위는 `archive/canexecute-inst-arg-reversed.md`). + 구독자를 weak로 담아도 되는 이유는 살려두는 책임이 State가 아니라 + `gchold`(leaf) 또는 전역 `Subscribed` 레지스트리에 있기 때문 — 어디에도 + 안 묶인 Observer는 GC되어 구독 목록에서 자연히 빠짐. +- **구현 노트(사용자 제안, 확정된 아키텍처는 아니고 구현 시 참고)**: + 살아있는 Observer 집합을 Observer 값 내부 필드로 안 두고, 외부에 + weak table(`{[observer] = true}`, `__mode = "k"`)로 인덱싱하는 방식을 + 선호 — 포인터 해싱 비용만 들고 값 자체엔 부작용 없음. rbvm의 + `getNamespaceOf`류가 비슷한 외부 weak-table 인덱싱을 씀 + (`base/lifecycle-pattern.md` 참고). +- **인자 없는 `state:Observer()` — "항상 관측" 유틸.** `fn`을 생략하면 + 내부적으로 no-op 콜백을 쓰는 것으로 취급해, 그냥 "이 State를 계속 + 능동적으로 관측 상태로 유지"하는 용도로만 씀. 위 "`previous` 인자" + 절의 캐비엇("능동적 관측 경로가 안 남아있으면 mutate 로직이 조용히 + 멈춘다")을 만족시키는 가장 단순한 도구 — 별도 콜백 로직 없이 그냥 + 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 + 하면 별문제 없음(사용자 판단). + +### Slot 생존 확인 — 별도 메커니즘 아님, `canExecute` 재사용으로 확정 + +state를 옵저빙해서 나온 결과로 slot에 `clear`/`add` 같은 연산을 할 때, +그 시점에 대상 slot이 이미 죽어있으면 어떻게 되는가 — 별도 메커니즘을 +새로 만들 필요 없이, `base/lifecycle-pattern.md`의 "생명 바인드 +유틸"(canExecute predicate)을 state-invalidate 리스너 클로저 등록에도 +그대로 재사용하면 됨: 발화 시 `canExecute(value)`(2026-08-14 다섯 번째 +세션 최종 시그니처, `inst`를 안 받음) 하나만 확인하고 거짓이면 그냥 +no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인 +거짓이면 불허" 분기 초안은 폐기 — `isInit` 분기라는 별도 개념 자체가 +불필요(사용자 확정: "canExecute 하나로 통일"). + +## Observer의 `:Subscribe()`/`:Unsubscribe()` — children 배열 밖 독립 구독 (2026-08-06 후속 세션) + +**문제**: children 배열에 넣는 자동 라이프사이클 바인딩은 Observer가 +"어딘가 leaf에 붙어있다"는 걸 전제함. 근데 흔한 실사용 패턴 하나가 이 +전제를 깨뜨림 — 개발자가 디버깅용으로 `RunService:IsStudio()` 가드 +안에서 Store에 직접 Observer를 걸어 `print`하는 패턴(원하면 BooleanValue +로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인 +엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table +기반 자동 추적이 적용 안 됨. **[용어 정정, 2026-08-09 여섯 번째 세션]** +여기서 "weak-table 기반 자동 추적"이라 부른 것이 나중에 정식으로 +`bindLifetime`(`base/lifecycle-pattern.md`)으로 명명됨 — 별도 메커니즘 +두 개가 아니라 같은 것의 명명 전/후 표현. + +**해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새 +설계가 아니라 PA님 코드 교차검증(아래 라이프사이클 절)에서 이미 예고해둔 +확장 지점을 실제로 채우는 것 — "나중에 GC만으로 정말 부족한 케이스가 +생기면 명시적 dispose 경로를 추가로 얹는 게 가능한 디자인"이라고 그때 +이미 못박아뒀음. + +- **`local` 변수로 참조만 들고 있는 것으로는 부족한 이유**: 토글(BooleanValue로 + 로깅 껐다 켰다) 케이스에서, 참조를 끊어도 실제 GC는 결정론적으로 즉시 + 일어나지 않음 — "껐다"고 생각한 뒤에도 한동안 계속 발화할 수 있음. + `:Unsubscribe()`는 즉시/결정론적으로 끊는 경로라 이 문제가 없음. +- **liveness 체크는 두 경로를 하나의 predicate로 OR 묶음**(사용자 제안) — + 자동(리프 부착=`bindLifetime`)/수동(전역 `:Subscribe()`) 두 라이프사이클 + 경로를 `canExecute(value)` 하나가 답함: + ```lua + -- 개념 스케치. 확정 구현은 base/lifecycle-pattern.md가 소스 + local gcconn = BindData:GetWeak(self, "gcconn") -- leaf 경로(bindLifetime이 복사해둠) + if gcconn ~= nil and gcconn.Connected then return true end + return self.Subscribed == true -- 전역 경로(:Subscribe()만 세팅) + ``` + **[정정, 2026-08-14 다섯 번째 세션]** 이 절의 옛 스케치는 `self.Subscribed`를 + 먼저 보고 `self.Connection`을 폴백으로 두는 모양이었는데, `.Subscribed`는 + **전역 경로 전용 필드라 리프 경로와 무관**하므로 우선순위 자체가 의미 + 없음(두 경로는 상호 배타라 OR 순서는 성능 취향일 뿐). "필드 접근이 weak + table 조회보다 싸다"는 관찰은 유효하지만, 그건 `.Subscribed`를 리프 + 경로에도 겸용하라는 근거가 못 됨 — 실제로 2026-08-08 세션이 그렇게 + 겸용했다가 `canExecute` 시그니처까지 오염됐음 + (`archive/canexecute-inst-arg-reversed.md`). 실측은 구현 단계에서 확인. +- **내부 강참조 레지스트리**: `SubscribedObservers: {[observer]: true}`류를 + **weak 아닌 강참조**로 둠 — 여기서 weak면 "구독해서 살려둔다"는 목적 + 자체가 무의미해짐. 위 자동 케이스의 weak table과 역할이 명확히 갈림 + (weak table=자동/리프 전용, 강참조 레지스트리=수동 구독 전용). + **`:Unsubscribe()`는 이 레지스트리에서 반드시 `SubscribedObservers[observer] + = nil`까지 해야 함** — `Subscribed` 플래그만 내리고 강참조를 안 끊으면 + GC 대상이 안 되는 반쪽짜리 해제가 됨, 둘은 항상 같이 일어나는 한 세트. +- **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 + 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 + no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. +- **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프) + 케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기 + 해제는 `unbindLifetime(value)`가 담당, `:Unsubscribe()`는 + 전역 강참조 레지스트리 경로 전용으로 남음.** 둘이 지우는 대상이 서로 + 다르기 때문 — `:Unsubscribe()`는 전역 레지스트리와 `.Subscribed` 필드를, + `unbindLifetime`은 `inst`의 gchold 항목과 `value`가 들고 있던 gcconn + 참조를 지움. 아래 "이중 바인딩 금지" 절의 정정 참고. + **[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 들었던 이유(*"`inst`를 + 모르는 `:Unsubscribe()`가 어느 `inst`에 등록했는지 찾아낼 방법이 없다"*)는 + 이제 성립 안 함 — `unbindLifetime`도 `inst`를 안 받고 `value` 하나로 + 해제함(`value`가 자기 홀더를 알고 있음). 결론(두 함수를 안 합침)은 + 그대로지만 근거가 "찾을 수 없어서"가 아니라 "지우는 대상이 달라서"로 + 바뀜. +- **`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 담아도 정상** + — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 + 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 + 포인트). +- **⚠️ 이건 quad 전역의 "정리는 기본적으로 GC에 위임" 원칙의 의도적 + 예외 — 문서에 명시적으로 경고할 것(2026-08-09 열한 번째 세션).** + `:Subscribe()`로 등록한 뒤 로컬 변수 참조를 전부 놓아도(스코프 이탈, + 변수 재할당 등) **GC되지 않고 영원히 계속 실행됨** — 강참조 + 레지스트리가 그 자체로 생존을 보장하기 때문. `bindLifetime`(leaf + 부착 포함) 경로는 `inst`가 죽으면 자동으로 정리되는 GC-native 그대로지만, + `:Subscribe()` 경로는 오직 명시적 `:Unsubscribe()` 호출로만 끊김 — 이 + 차이를 모르고 "quad는 다 GC-native니까 참조만 버리면 되겠지"라고 + 가정하면 조용한 누수(메모리뿐 아니라 계속 재실행되는 콜백까지)로 + 이어짐. 용도도 "완전히 top-level(어떤 Instance 생명주기에도 안 묶인) + 사이드 이펙트"로 좁게 문서화할 것 — 특정 `inst`에 묶인 경우는 + `:Subscribe()`가 아니라 leaf 부착(`bindLifetime`)이 정상 경로. +- **`:Subscribe()`/`:Unsubscribe()` 둘 다 `self`를 리턴(대칭)** — + `local obs = state:Observer(fn):Subscribe()`처럼 "구독 시작 + 나중에 + 끊을 핸들 확보"가 한 줄로 되고, `table.insert(subs, state:Observer(fn) + :Subscribe())`처럼 리스트에 담을 때도 줄바꿈 없이 됨. Observer가 + immutable 값이 아니라 원래 mutable한 구독 핸들이라 fluent 체이닝이 + 자연스러움 — Modifier의 clone-then-return 체이닝과는 다른 이유(같은 + 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 + 체이닝 가능. + +## 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canExecute(value)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 `canBound`로 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정, **2026-08-14 다섯 번째 세션에 `canBound` 폐기·`canExecute`로 통합**) + +**규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 +딱 하나만 가질 수 있음 — `:Subscribe()`로 전역 강참조 레지스트리에 +등록되거나(위 절), `bindLifetime(inst, value)`로 특정 `inst`에 종속되거나 +(아래 "`bindLifetime`이 이 게이트의 두 번째 진입점" 절) — **이 둘 중 하나만**. + +**[정정, 2026-08-09 여섯 번째 세션] "leaf 부착"은 세 번째 독립 경로가 +아니라 `bindLifetime`을 호출하는 것 그 자체다.** `Frame { observer }`처럼 +children 배열에 Observer를 직접 놓으면, `Dispatch/Leaf.luau`가 이걸 +매치해 내부적으로 `bindLifetime(inst, observer)`를 호출 — "children +배열에 놓여 leaf에 자동 부착"과 "`bindLifetime`으로 특정 `inst`에 +종속"은 **같은 동작**이라 서로 배타적일 수 없음(둘 다 하는 게 아니라 +leaf 부착이 곧 `bindLifetime` 호출 방식 중 하나일 뿐). 그래서 실제 +상호 배타는 "전역 소유(`:Subscribe()`)" vs "특정 `inst` 소유 +(`bindLifetime`, 직접 호출이든 leaf 부착을 통한 호출이든)"라는 +**2-way**로 정정 — 위 "Observer의 `:Subscribe()`/`:Unsubscribe()`" 절이 +leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifetime`이 +정식 이름을 얻기 전(2026-08-06 후속 세션) 표현이라 지금은 같은 것을 +가리킴 — 별도 메커니즘 두 개가 있던 게 아니었음. + +**둘 이상 동시에 걸리는 건 UB로 확정** — 이미 한 경로로 바인딩된 핸들을 +다른 경로로 또 바인딩하는 건 금지(leaf로 이미 부착된 걸 `:Subscribe()` +하는 것, 또는 그 반대). 같은 값을 `bindLifetime`으로 두 번(leaf 부착 +한 번 + 직접 호출 한 번, 또는 leaf로 두 Instance에 부착) 등록하려는 +것도 걸림 — 이건 "leaf vs bindLifetime 충돌"이 아니라 "같은 단일 +메커니즘을 중복 호출"하는 것이라 자연히 같은 게이트가 잡아줌. + +**UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 +0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 +바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈. + +**[역전, 2026-08-14 다섯 번째 세션] 별도 predicate `canBound(handle)`은 +폐기하고 `canExecute(value)` 하나로 통합.** 게이트는 이 모양: + +```lua +-- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침) +-- — 둘 다 진입 전 동일하게 확인 +if canExecute(self) then + error(if self.Subscribed + then "이미 :Subscribe()로 전역 바인딩된 값" + else "이미 다른 Instance에 바인딩된 값") +end +``` + +- **"이미 유효하게 묶여 있다"와 "지금 실행 가능하다"가 정확히 같은 + 조건**이라 predicate를 둘로 나눌 이유가 없었음 — `canExecute`가 + 참이면 그 값은 어딘가에 살아있는 바인딩을 갖고 있다는 뜻이고, 그게 + 곧 "새로 묶으면 안 된다"임. +- **에러 메시지에서 어느 경로인지는 `.Subscribed`로 가름** — 이 필드는 + **전역 `:Subscribe()` 경로에서만 세팅되므로**(아래 정정) 참이면 전역, + 거짓인데 `canExecute`가 참이면 leaf 경로. +- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 유효한 + 바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canExecute`를 확인하므로 + 순서와 무관하게 대칭적으로 막힘. +- **죽은 바인딩의 재사용은 허용** — `inst`가 Destroy됐거나 + `unbindLifetime`된 값은 `canExecute`가 거짓이라 게이트를 통과함(다른 + `inst`에 다시 걸 수 있음). 게이트가 막는 건 **살아있는** 이중 바인딩뿐. + +**[정정, 2026-08-14 다섯 번째 세션] 옛 서술 — "`canBound`의 내부 플래그는 +`canExecute`가 이미 보는 `.Subscribed` 필드 그 자체이고, `bindLifetime`도 +그 필드를 세팅한다"(2026-08-09 여섯 번째 세션)는 틀렸음.** +`.Subscribed`는 **전역 `:Subscribe()`/`:Unsubscribe()` 전용 필드로, +`bindLifetime`/`unbindLifetime`과는 일절 이해관계가 없다** — 이 둘은 +그 필드를 읽지도 쓰지도 않음. leaf 경로의 생존은 `bindLifetime`이 +`value` 쪽 릴레이션에 복사해둔 gcconn 참조로 판정됨(`base/lifecycle-pattern.md`). +옛 서술이 걱정했던 "필드를 둘로 나누면 `bindLifetime`으로만 등록된 +Observer가 `canExecute`에서 항상 `false`로 오판됨"은 실제로는 안 일어남 +— `canExecute`가 gcconn 경로를 **먼저** 보기 때문. 역전 원문·오염 경로· +교훈은 `archive/canexecute-inst-arg-reversed.md`. +- **`:Unsubscribe()`는 `:Subscribe()` 경로의 해제만 담당, `bindLifetime` + (leaf 부착 포함) 경로는 `unbindLifetime(value)`로 해제** — + 둘은 서로 다른 함수로 남음(호출자가 `bindLifetime`을 부른 쪽이 + `unbindLifetime`도 대칭적으로 부르는 책임을 짐). 지우는 대상이 + 서로 다르므로 하나로 합칠 수 없음 — 위 `:Subscribe()` 절의 같은 + 정정(2026-08-14 다섯 번째 세션) 참고. leaf 부착으로 + 세워진 바인딩의 실제 해제도(예: Instance 파괴 전 조기 해제하고 싶을 + 때) 결국 `unbindLifetime`이 담당 — 위 "`:Unsubscribe()`는 자동(리프) + 케이스에도 동일하게 씀" 절의 서술은 leaf 부착이 별도 메커니즘이라고 + 전제했던 것이라 **이 정정으로 대체**(`:Unsubscribe()`가 아니라 + `unbindLifetime`이 leaf 해제의 실제 통로). +- **Effect도 동일 규칙 적용(사용자 확인)** — Effect가 `state` 인자로 + 내부적으로 Observer를 조합하는 경우든, `state` 없는 경우든 같은 + `canExecute` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect + 자신이 아니라 내부 Observer가 게이트를 갖고 있어서, Effect 구현이 + 이 정정을 몰라도 자동으로 커버됨. 이전에 그 문서에 적어뒀던 "leaf + 부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 + 규칙으로 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 + 하는 조합이었음. +- **문서화 경고 대상(api/심화)**: "한 Effect/Observer 핸들을 children + 배열에 놓았다면(=`bindLifetime`으로 등록된 것) 그걸 다시 + `:Subscribe()`하거나 다른 Instance에 또 leaf로 놓지 말 것, 반대도 + 마찬가지 — 여러 경로를 동시에 쓰고 싶으면 각각 독립된 새 + `Effect(...)`/`state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. + +### `bindLifetime`이 이 게이트의 두 번째(이자 leaf 부착이 실제로 쓰는) 진입점이다 (2026-08-09 여섯 번째 세션) + +`Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때 +쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은 +`canExecute` 게이트를 확인** — 진입 전 `canExecute(value)`를 확인하고, +통과하면 gchold 등록 + gcconn 참조 복사를 수행. +**children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** — +`Dispatch/Leaf.luau`가 `(i:number, v=Observer/Effect)`를 매치하면 +그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용" +바인딩 로직이 따로 있는 게 아님. 그래서 **실제 상호 배타는 `:Subscribe()` +(전역 강참조 레지스트리)와 `bindLifetime`(inst별 gchold, 직접 호출이든 +leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만들 이유가 +없음, 기존 게이트에 진입점 하나(`bindLifetime`, leaf 부착이 그 특수 +사례)만 추가. + +```lua +function bindLifetime(inst, value) + if canExecute(value) then + error("이미 바인딩된 값") -- 메시지 분기는 위 게이트 스케치 참고 + end + ... -- gchold 등록 + gcconn 참조 복사(base/lifecycle-pattern.md) +end + +function unbindLifetime(value) + ... -- gchold 항목 제거 + gcconn 참조 해제 +end +``` + +- **[정정, 2026-08-14 다섯 번째 세션] 게이트는 값 타입을 안 가린다** — + 옛 서술은 "`canBound`는 `.Subscribed` 필드가 있는 Observer/Effect 전용 + predicate라 그 외 값(예: Tween 내부 클로저, Slot)은 그냥 통과"였는데, + `canExecute`는 gcconn 경로를 먼저 보므로 **어떤 값이든** 이미 살아있는 + 바인딩이 있으면 걸러짐. 이게 더 맞음 — Slot을 두 `inst`에 이중 마운트하는 + 것도 원래 금지(`base/slot-plan.md`의 `elementOwner`)라, 같은 실수를 + `bindLifetime` 층위에서도 공짜로 잡아줌. +- 값이 `bindLifetime`으로 바인딩된 뒤엔 `canExecute`가 참이 되므로, 그 + 뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면 기존 두 진입점의 기존 + 체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 없이 이미 성립. + +## PA님 코드와의 교차검증(2026-08-04 4차 라운드) — 둘 다 기존 확정 유지 + +`.claude/initreq/artworks/EventDrivenProgramming/`(Connection/Event/ +Observable/Observer)을 조사한 결과, 두 지점에서 기존 확정과 실제로 다른 +선택이 나와 재검토했으나 결론은 변경 없음. **이름 주의**: 아래에서 말하는 +`Observer`는 PA님 코드의 클래스 이름(pub-sub, 8개 `subscribeXxx` 헬퍼)이고, +위 "`state:Observer(fn)`" 절에서 확정한 quad의 `Observer`와는 이름만 +같을 뿐 무관한 별개 개념 — 이 절은 순수 역사적 교차검증 기록으로만 읽을 것. + +- **전파 모델**: PA님의 pub-sub은 push-invalidate가 아니라 **push-값** + (`Event:fire(...)`가 인자를 그대로 콜백에 전달, `Observable`의 `__newindex`가 + 새 값을 실어 즉시 `changed:fire(key, value)`, dirty-flag/`Get()` pull 단계 + 자체가 없음). 한때 "leaf(source 하나→sink 하나, 파생 없음)는 PA님처럼 + push-값으로 단순화하고 push-invalidate/pull-recompute는 실제 `:Compute` + 파생이 있을 때만 쓰자"는 이원화를 검토했으나 **기각** — invalidate+`Get()` + 방식도 leaf에서 딱히 더 복잡하지 않고(불리언 플래그 하나 + `Get()`/`emit` + 둘로 나뉘는 정도), 오히려 두 메커니즘을 병행하면 "leaf State가 나중에 + `:Compute`로 감싸일 때 두 메커니즘을 어떻게 연결하는가"라는 새 경계 문제가 + 생겨 이원화가 더 복잡함. **결정적으로, PA님 코드엔 애초에 `:Compute`/`:With` + 같은 파생·합성 개념 자체가 없음** — quad-v2가 lazy pull을 도입한 이유(여러 + 소비자가 하나의 파생 State를 공유할 때 오염 방지, 안 쓰이는 연산 스킵)를 + PA님 시스템은 처음부터 안 풀려던 문제라, 대등한 반례가 아니었음. **결론: + push-invalidate/pull-recompute로 통일 유지, 변경 없음.** 사용자 최종 확인 + 문구: "store 전파 처리는 우리 방식이 맞음. 이건 vide 에서 없었던것과 + 동일함, [PA님] 저기도 디자인 상 해결 못하는 문제가 된거거든. 비 필요 + 연산과 중복 연산을 지우는건 디자인 단계에서 구성할 일임. 우린 디자인 + 단계부터 해당 문제를 해결하고 싶었던거야." +- **라이프사이클**: PA님 코드는 GC-native가 아니라 **전부 수동 해제** + (`Connection.connected`는 계산 속성이 아니라 저장된 bool, `Observer`의 + 8개 `subscribeXxx` 헬퍼 전부 명시적 `:unsubscribe()` 필요, weak table은 + `Observable`의 subject↔observable 캐시 한 곳뿐). rbvm 기반으로 확정한 + "GC 위임, 명시적 dispose 없음" 원칙과 반대 선택이라 재확인 질문했으나, + **GC-native 유지로 확정** — 지금까지 이 정도 규모(명시적 dispose가 꼭 + 필요할 만큼 큰 자원)를 요구하는 실제 사례가 없었다는 게 사용자 판단. 다만 + **완전히 막다른 길은 아님**을 기록해둠: rbvm처럼 관계를 양쪽 다 weak-keyed로 + 두고 모든 걸 connection 람다에 담아 "연결이 살아있는 동안만 살아있게" 하는 + 방식이면, 나중에 GC만으로 정말 부족한 케이스가 생겨도 그 connection을 얻어 + `disconnect()`하는 명시적 dispose 경로를 추가로 얹는 게 가능한 디자인 — + 지금 마일스톤에서는 필요 없어서 안 함(사용자: "필요하다면 dispose 핸들러를 + 만들어주는 것도 가능한 디자인, 다만 지금까지 요구가 없었음"). (rbvm의 + GC-native 패턴이 실물에서 검증됐다는 근거는 `base/lifecycle-pattern.md` 상단 + 참고 메모 참고.) + +## 남은 열린 질문 + +- **`state()`/`Source()`/`Get()` 등 정확한 함수·생성자 이름** — 방향은 + 전부 확정, 이름만 구현 단계에서 남음. `State`/`Source`/`Compute` 자체는 + 이미 최종 확정(위 "이름 주의"/"네이밍 — `Compute`가 `-ed`가 아닌 이유"). +- **이형 다중 trailing deps를 제네릭 팩 하나로 좁힐 수 있는지** — 위 + "trailing deps를 `fn`에 lazy positional 인자로도 노출" 절의 실측 항목 + (`luau-test`의 `15-...`, 현재 스파이크 재작성 필요 상태). diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md new file mode 100644 index 0000000..8d2baa8 --- /dev/null +++ b/.claude/base/store-plan.md @@ -0,0 +1,224 @@ +# Store — 이름 붙은 Source 모음, 그 이상 아님 + +> **📄 [2026-08-14 신설] `bind-system-plan.md` 3단계 분할 + store-semantics.md +> 흡수.** Store가 "Source들을 담고, 없으면 만들어주는 도구"로 좁혀지고 나서도 +> 관련 서술이 store-semantics.md(부작용 허용, 값 설정 문법)와 +> `bind-system-plan.md`(dot-access 타이핑, Store가 Store를 담는가)에 반씩 +> 흩어져 있었음 — 한 군데로 합쳤고 **내용/결정은 이동·병합 자체로는 안 +> 바뀜**. 반응형 코어(Source/State 자체)는 짝 문서 +> **`base/source-state-plan.md`**. + +**상태**: base — Store가 부작용을 허용한다는 핵심 결정, "이름 붙은 Source +모음"이라는 정의, eager+lazy 생성, `store.key` dot-access 타이핑, `:Set()` +문법 전환까지 전부 확정. 원본: `.claude/initreq/raw-userinput.md` +"store는 부작용을 허용함" / "스토어는 스토어를 저장 가능한가" 절. + +## Store는 부작용을 허용하는 게 기본 디자인 + +부작용 없이(파라메터 패싱만으로) 쓰는 것도 물론 가능하지만, 라이브러리 차원에서 +막지 않는다. 부작용 유무는 **사용자가 직접 문서화**하는 관례로 둔다 — 라이브러리가 +순수성을 강제하지 않음. + +다만 한 가지는 명확히 구분: **렌더 리턴 위에서 무언가를 observe하는 것은 그냥 +부작용**이다 (`useEffect`와 유사한 것으로 문서화). 이건 "허용되는 부작용"이 아니라 +"당연히 부작용"이라는 뜻 — 문서화 시 이 경계를 분명히 할 것 (`base/ +purity-and-effects-plan.md`와 연결됨). + +**보강(2026-08-04 검증 라운드): 부작용은 심각도가 다른 두 갈래로 나뉜다.** + +1. **국소적 부작용** — 입력으로 받았거나 자신이 만들어 소유한 대상에 대한 + 부작용(예: 렌더 리턴 아래에서 옵저빙해서 자기 slot을 갱신). 이건 편의성이 + 커서 적극 환영하는 영역. +2. **경계를 넘는 부작용** — globalStore처럼 컴포넌트 바깥의 전역 상태를 + 다루는 경우. 게임 UI 특성상(스킬/주변 환경에 영향받는 UI 등) 완전히 + 막을 수는 없지만, 라이브러리로 재사용하려는 컴포넌트가 이런 부작용을 + 가지면 이식성이 떨어짐(`base/purity-and-effects-plan.md`와 연결). + +**해소됨(2026-08-04 2차 라운드)**: "state를 옵저빙해서 나온 결과로 slot에 +`clear`/`add` 같은 연산을 할 때, 그 시점에 대상 slot이 이미 죽어있으면 +어떻게 되는가"는 별도 메커니즘 없이 `canExecute` 재사용으로 해결됨 — +`base/source-state-plan.md`의 "Slot 생존 확인" 절이 소스. + +## Store = Source들의 이름 붙은 모음 (eager + lazy 생성) + +`store.a`처럼 키로 접근하면 **이미 만들어진 Source가 있으면 그대로 반환, +없으면 그 자리에서 만들어 저장한 뒤 반환** — 더 이상 별도 State wrapper를 +매번 만들거나 따로 캐싱하지 않음(Source 자체가 이미 State를 만족하므로 +wrapper 계층 자체가 불필요해짐, `base/source-state-plan.md`의 "Source가 +State를 만족함" 절). + +- **`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 생성**(아직 없는 키를 그 자리에서 만들어 + 저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함. +- **`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 생성할 때 이 무인자 형태를 씀. + +v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게 +쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 타입이 핵심 +제약이라 그 전제 자체가 더 이상 안 맞고, 2026-08-06 후속 세션의 정리로 +Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐. 값 하나만 +반응형으로 다루고 싶으면 Store를 통째로 만들지 말고 독립 +`Source(default)`를 쓸 것(`base/source-state-plan.md`의 "Source는 독립 +공개 프리미티브로 격상" 절). + +## Store 값 설정 문법 — `myStore.key = value` 폐기, `source:Set(value)`로 전환 (2026-08-06 후속 세션, 정정) + +**이전 버전("v1 인체공학 유지, `__newindex` 기반 `myStore.key = value` +그대로")은 폐기됨.** `base/source-state-plan.md`의 "Source가 State를 +만족함" 절 타입 설계와 맞물려 재검토된 결과: + +1. **타입 대칭성**: `store.key`가 이제 `Source`를 직접 반환하는 + 평범한 레코드 필드(`{key: Source}`)로 타이핑되는데, 레코드 + 필드는 읽기/쓰기 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립함. + `store.key = value`(raw `T` 대입)를 유지하면 읽기(`Source`)/쓰기(`T`) + 타입이 갈려 mismatch가 남음 — `store.key:Set(value)`로 통일하면 필드 + 타입이 항상 `Source`로 대칭적이라 문제 자체가 안 생김(사용자 지적). +2. **의미론적 정직성**: `=` 대입 문법은 관례상 "그 자리에서 즉시 확정되는 + 부작용 없는 값 쓰기"를 암시하는데, quad의 실제 동작은 **lazy** — + `Set`은 무효화 신호만 쏘고, 실제 재계산은 나중에 누군가 관측(`Get()`)할 + 때만 일어남("Emit으로 필요한 사람 있어? 하고 물어보고, 있어야 진짜 + 계산 시작"). 이건 `=`가 암시하는 "즉시 커밋"과 정서가 안 맞고, 메소드 + 호출(`:Set()`)이 "이건 프로세스를 트리거하는 연산"이라는 걸 더 정직하게 + 신호함(사용자 확정 논거). +3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙(`base/ + architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게 + 아니라 기존 원칙의 정상적인 적용. + +**남는 것**: `myStore "key"`(문자열 커링)는 이미 3차 라운드에서 동적 키 +전용 미타입 폴백으로 격하돼 있었으므로 이번 정정과 무관하게 그대로 유지. +`:` 체이닝 원칙도 `:Set()` 자체가 그 사례라 유지. + +`base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 함께 +읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 +"불변 빌더"를 흉내낸 것이었지, `:` 체이닝 문법 자체나 커링 문법 자체가 +아니었음. + +## 타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정 (2026-08-04 3차 라운드) + +- `store "key"`(문자열 커링)로 `state`를 오버로드 함수 타입으로 정확히 + 추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정** + — Store 타입을 `{key: Source, other: Source}`류 평범한 + 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 + 리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State` + 필드로 적혀있었으나 Source가 State를 만족하는 구조로 바뀌며 `Source`로 + 갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가 + 더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이 + 해소됨, 위 "Store 값 설정 문법" 절 참고). + `store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source`) + 폴백으로 격하. +- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 + 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 + **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). + `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" + 절이 최신 확정 내용. + +### `store.key` 레코드 필드 타이핑 — Luau 타입함수로 해결 확인 (2026-08-12 열일곱 번째 세션, `pre-implementation-audit.md` 1-10 해소) + +위 절이 "`store.key`를 평범한 레코드 필드 타이핑으로 자동 해결"이라 +서술했지만, `Store`가 입력 `T`(예: `{ty: string}`)를 받아 +`{ty: Source}`류 결과 타입을 실제로 어떻게 합성하는지는 미검증으로 +남아있었음. **Luau의 `type function`**(컴파일타임에 타입을 인자로 받아 새 +타입을 조립하는 기능, https://luau.org/types/type-functions/ , +https://luau.org/types-library/ — tbox에서도 이미 쓰이는 검증된 패턴)으로 +정확히 풀림: + +```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 + +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 +``` + +`ProcessStoreType<{ty: string}>` → `{ty: Source}`가 나옴 — 결과는 +선언 시점에 이름 붙은 `Source` 그 자체가 아니라 구조를 그대로 풀어낸 +(flatten) 익명 타입이지만, **Luau는 이름이 아니라 "만족하는가"로 구조적 +일치를 검사**하므로 문제없이 `Source` 자리에 대입 가능 — 오히려 이 +방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이 +확인돼 M0/M3 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 — +`ROADMAP.md`의 M0/M3 배치를 강제로 바꿀 필요는 없어짐, 검증 난이도 +문제였던 것만 해소. (이 방식이 못 해주는 것은 `base/typing-limits.md`가 +따로 정리.) + +## Store가 Store를 저장 가능한가 + +사용자 원 메모: "슬롯을 스토어처럼 생각 가능하다면 이건 가능하다고 봐야하는가? +아니면 아예 다른 값으로 둬야 하는가? table/number 같은 프리미티브 타입이나 +ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플러그를 만드는 걸로." + +**2026-08-04 6차 확정: 그런 경우는 없다고 본다.** "재실행 래핑으로 +기계적으로는 커버 가능하다"는 제안은 메커니즘상 틀리지 않지만, 실제 설계 +의도와 안 맞음 — Store는 Source에 준하는 존재로 모든 반응형 값의 "시작점" +역할만 함. 시작점은 다른 변화하는 무언가에 연결되는 것을 제공하고자 하지 +않음(= Store가 다른 Store/State를 값으로 담아 자동으로 따라가게 하는 용도로 +쓰지 않음). Store에서 값을 꺼내 State를 옵저빙하다가 콜백으로 다른 Store 값을 +바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의 +보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음. + +**[2026-08-13 세션, 스코프 명확화, 같은 날 다섯 번째 세션에 결론 갱신]** +이 절은 "Store *필드*가 Store/State를 담는가"(예: `store.a = otherStore`) +얘기이고, "State가 *emit하는 값*이 State/Source인가"(`State>`, +예: `store.key`에 대입된 값 자체가 State)는 다른 축. 이 절의 "별도로 +신경 쓰지 않음"(Store 필드 얘기)은 그대로 유지 — 후자(`State>`)는 +한때 실제 체인 파손 버그로 확인돼 `Dispatch.process`가 명시적으로 error +하도록 막았었으나, 같은 날 다섯 번째 세션에 `chains`의 인덱스 기반 +재설계로 그 버그의 근본 원인이 없어져 **지금은 정상 지원 대상** +(`base/dispatch-core-plan.md`의 "Dispatch 체인" 절 참고 — 열네 번째 +세션의 하강 diff로 깜빡임 방지 힌트까지 깊은 체인에서 유지됨) — "신경 안 씀"의 +의미가 "조용히 UB"도 "즉시 실패"도 아니라 "그냥 정상적으로 동작함"으로 +다시 한번 바뀜. + +**따라서 "Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가 +필요한가"라는 질문도 성립 안 함으로 종결** — 두 가지 독립적인 이유로 +이중 해소됨. (1) 애초에 그런 경우를 만들지 않기로 확정(위 문단). (2) 설령 +발생해도 State/Source 그래프 구독이 전부 weak-keyed GC-native(명시적 +`dispose()` 호출이 아예 없음, `base/lifecycle-pattern.md`의 GC 위임 원칙 +재사용)라 "같은 걸 두 번 해제"할 행위 자체가 존재하지 않음(GC는 멱등). + +## Store가 담을 수 없는 값 — Modifier + +`Store`/`Source`의 `T`는 Modifier가 될 수 없음(런타임 `error`) — +근거와 검사 지점은 `base/source-state-plan.md`의 "따름정리" 절이 소스. + +## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 확정 + +`useEffect`처럼 여러 store 값을 디펜던시로 묶어 파생값을 계산하고 싶다는 +요구는 `:With(...)`로 의존성을 모으고 `:Compute(fn)`으로 파생 State를 +만드는 것으로 확정 — `Store.Combine({a,b}, fn)`류 포지셔널 인자 방식은 +기각됨. 정확한 lazy 인자 규칙(self/with 값 둘 다 State 핸들로 넘기고 +`:Get()`을 실제로 읽을 때만 계산), v1 `myStore "a,b"` 콤마-조인 문자열 +방식의 폐기, 여러 값을 한 번에 바꿀 때 재계산을 한 번으로 묶는 `Blocker` +연결은 전부 `base/source-state-plan.md`의 "여러 값을 묶어 파생값 만들기" +절이 소스. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md deleted file mode 100644 index ffdcdac..0000000 --- a/.claude/base/store-semantics.md +++ /dev/null @@ -1,346 +0,0 @@ -# Store 의미론 — 부작용 허용, State는 Source 위의 조합 가능한 캐시 레이어 - -**상태**: base — Store가 부작용을 허용한다는 핵심 결정과 State/Source -온톨로지 구조 자체는 확정(2026-08-04 검증 라운드에서 새로 열려 같은 세션 -2~4차 라운드에 걸쳐 확정까지 마침 — 최신 상세는 `base/bind-system-plan.md` -참고). 원본: `.claude/initreq/raw-userinput.md` "store는 부작용을 허용함" / -"state는 어떻게 구현하는가" 절. - -> **[2026-08-13 열세 번째 세션, 해소]** self/deps를 lazy `State` 핸들로 -> 넘기는 `:Compute`/`:With` 콜백 계약은 한때 미해결(구 `question.md` -> 0-Y)이었으나 **그대로 유지로 확정**됨. 남은 것은 quad 설계 문제가 -> 아니라 Luau의 현 한계(파생 State의 반환 타입이 정적으로 검증되지 -> 않아 사용처에서 명시 주석 바인딩이 필요) — 전역 규약은 -> **`base/typing-limits.md`**, 실측 근거는 `audit/type-recursion-issue/`. - -## Store는 부작용을 허용하는 게 기본 디자인 - -부작용 없이(파라메터 패싱만으로) 쓰는 것도 물론 가능하지만, 라이브러리 차원에서 -막지 않는다. 부작용 유무는 **사용자가 직접 문서화**하는 관례로 둔다 — 라이브러리가 -순수성을 강제하지 않음. - -다만 한 가지는 명확히 구분: **렌더 리턴 위에서 무언가를 observe하는 것은 그냥 -부작용**이다 (`useEffect`와 유사한 것으로 문서화). 이건 "허용되는 부작용"이 아니라 -"당연히 부작용"이라는 뜻 — 문서화 시 이 경계를 분명히 할 것 (`base/ -purity-and-effects-plan.md`와 연결됨). - -**보강(2026-08-04 검증 라운드): 부작용은 심각도가 다른 두 갈래로 나뉜다.** - -1. **국소적 부작용** — 입력으로 받았거나 자신이 만들어 소유한 대상에 대한 - 부작용(예: 렌더 리턴 아래에서 옵저빙해서 자기 slot을 갱신). 이건 편의성이 - 커서 적극 환영하는 영역. -2. **경계를 넘는 부작용** — globalStore처럼 컴포넌트 바깥의 전역 상태를 - 다루는 경우. 게임 UI 특성상(스킬/주변 환경에 영향받는 UI 등) 완전히 - 막을 수는 없지만, 라이브러리로 재사용하려는 컴포넌트가 이런 부작용을 - 가지면 이식성이 떨어짐(`base/purity-and-effects-plan.md`와 연결). - -**해소됨(2026-08-04 2차 라운드)**: state를 옵저빙해서 나온 결과로 slot에 -`clear`/`add` 같은 연산을 할 때, 그 시점에 대상 slot이 이미 죽어있으면 -어떻게 되는가 — 별도 메커니즘을 새로 만들 필요 없이, `base/ -lifecycle-pattern.md`의 "생명 바인드 유틸"(canExecute predicate)을 state- -invalidate 리스너 클로저 등록에도 그대로 재사용하면 됨: 발화 시 -`canExecute(value)`(2026-08-14 다섯 번째 세션 최종 시그니처, `inst`를 안 받음 -— `base/lifecycle-pattern.md` 참고) 하나만 확인, 거짓이면 no-op. 한때 검토했던 `isInit=false`면 -허용/`isInit=true`+생존확인 거짓이면 불허 분기 초안은 폐기 — `canExecute` -하나로 통일(사용자 확정). 상세는 `base/bind-system-plan.md`의 -"Store/State/Source 온톨로지" 절 참고. - -## 정정(2026-08-04 검증 라운드): `State` 프리미티브는 실제로 필요하다 - -**후속(2026-08-04 2차 라운드)**: 아래 온톨로지의 전파 모델(push-invalidate/ -pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 -프리미티브화·Slot 생존 확인까지 전부 확정됨 — 최신 상세는 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지 — 핵심 메커니즘 확정" -절이 최종 소스, 이 절은 배경/온톨로지 명칭 정의로만 유지. - -**이전 버전의 이 절("State 프리미티브는 만들지 않는다")은 틀렸음 — 사용자가 -검증 라운드에서 직접 정정.** 정확한 모델: - -- **Store는 "source 집합체"이자 state를 만들어주는 존재.** 실제 값이 존재하고 - 변경될 수 있는 단일 지점은 source(v1의 "값의 근원"에 해당) — store는 이런 - source들의 모음. -- **State는 source(또는 다른 state)를 받아 캐싱만 하는 존재, 자기 고유의 - 독립적 value 개념이 없다.** 단일 값에 대한 state 생성은 store가 자동으로 - 해주지만, 그 결과를 다시 분기하고 싶으면(하나의 파생 스트림에서 여러 - 소비자가 각자 다른 추가 compute를 얹고 싶은 경우) `state(state)`처럼 기존 - state의 결과를 받아 새 state를 만드는 조합이 필요. -- **[정정, 2026-08-06 후속 세션] store에서 값을 얻는 연산(`store.key`)은 - Source를 직접 반환한다 — 더 이상 별도 State 인스턴스를 감싸서 반환하지 - 않음.** 상세는 아래 "Source가 State를 만족함" 절 참고. 이 항목의 원래 - 버전("항상 새 state 인스턴스를 반환")은 틀림 — Store가 별도 wrapper - 없이 자기 안에 만들어둔 Source를 그대로 돌려주는 쪽으로 재정리됨. -- 이건 quad2-try(폐기된 이전 시도)의 `Pipe` copy-on-write 절충안을 대체하는 - 방향으로 좁혀짐 — 별도 `Pipe` 타입을 만들어 소유권/버전 가드를 넣는 대신 - State 자체가 "파이핑 결합체"이고 `state(state)`로 분기하면 될 걸로 보임 - (`Pipe` 후보는 사실상 폐기 쪽으로 기움). 상세는 `base/bind-system-plan.md`의 - "Store/State/Source 온톨로지" 절 참고 — **이 절 이후 2~4차 라운드에 걸쳐 - 전부 확정됨, 더 이상 진행 중인 스레드 아님.** - -## 일반 원칙 — 독립 존재 가능한 프리미티브 vs 원천에 종속된 파생 데이터 (2026-08-06 후속 세션) - -위 "State는 자기 고유의 독립적 value 개념이 없다"는 관찰을 일반 원칙으로 -확장(사용자 관찰): quad의 개념들은 두 부류로 갈린다. - -- **독립 존재 가능한 프리미티브** — Source, Ref, Store, Modifier. 다른 - 무언가 없이 그 자체로 `Type(args)` 팩토리 함수로 만들어짐(`Source(default)`/ - `Ref(default)`/`Store({defaults})`/`Modifier()`, 위 "생성자 - 스타일 확정" 참고 — `Modifier()`는 빈 인스턴스, 실제 필드는 - `mod:UICorner(8)`류 체이닝으로 그 위에 얹음). -- **원천에 종속된 파생 데이터** — State, Observer. 자기 혼자 존재할 수 - 없고 항상 특정 원천(Source/다른 State)에 의존 — 그래서 이 둘은 자유 - 함수 생성자가 없고, 항상 원천에 대한 메소드 호출로만 얻어진다 - (`store.key`/`state:Compute(fn)`/`state:With(...)` → State, - `state:Observer(fn)` → Observer). "클래스 같은 독립 타입"이라기보다 - "State를 관측·핸들링하는 데이터"에 가까움. - -이게 `base/bind-system-plan.md`의 `state:Observer(fn)`가 메소드고 -`Observer(state, fn)`라는 자유 함수가 없는 더 근본적인 이유 — 단순히 -"읽기 편해서"가 아니라 Observer 자체가 State처럼 원천 없인 존재할 수 -없는 카테고리라서. 앞으로 새 개념을 추가할 때도 이 두 부류 중 어디에 -속하는지가 생성자 모양(자유 함수 팩토리 vs 원천에 대한 메소드)을 -결정하는 기준으로 쓸 수 있음. - -**세 번째 카테고리 — Handler는 둘 중 어디에도 안 낌(2026-08-08 두 번째 -세션, 명시화).** `Handler`(`isHandlable`/`priority`/`process` 3종 계약 — -`process`가 자기 retract 클로저를 반환, 2026-08-13 다섯 번째 세션 정정, -`base/dispatch-core-plan.md` "핸들러 계약" 절)는 위 분류가 다루는 -"quad 사용자가 직접 다루는 리액티브 값"이 아니라 **그 자체로는 구현체가 -없는 순수 타입 계약**이라 애초에 이 분류표의 대상이 아님 — Source/Ref처럼 -`Type(args)` 자유 함수로 인스턴스를 만들 수도 없고(계약을 만족하는 값은 -`PropertyHandler`/`TagHandler`/`Dispatch/StoreBind.luau`의 `NoneHandler`처럼 -**구현하는 쪽**이 리터럴 테이블로 직접 채워 넣는 것), State/Observer처럼 -어떤 원천에 종속된 파생물도 아님(애초에 "원천"이라는 개념 자체가 안 맞음). -Handler는 quad 사용자가 아니라 **백엔드/핸들러 구현자가 채우는 확장 -지점**이라는 완전히 다른 축의 개념이라, 여기 분류를 "왜 Handler가 -빠졌는지" 궁금해할 필요 없음 — 프리미티브 분류가 불완전한 게 아니라 -Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispatch`) 자체가 -왜 프리미티브가 아니라 탑레벨 싱글톤인지는 `base/dispatch-core-plan.md`의 -"Dispatch는 프리미티브가 아니다" 절 참고. - -과거 "미해결로 남은 것"으로 적었던 두 항목도 모두 해소됨: `:Compute`의 -캐싱/무효화 전략은 push-invalidate(신호만)/pull-recompute(`Get()` 시점)로 -확정(`base/bind-system-plan.md` "전파 모델" 절), `store "key"` 커링의 타입 -추론 문제는 `store.key`(dot-access)를 1급 경로로 확정하며 해소(같은 문서 -"타입 추론 문제" 절, 3차 라운드). - -## Source가 State를 만족함 — 구조적 서브타입, RefSource 개념 폐기 (2026-08-06 후속 세션) - -**배경**: `store.key`가 매번 새 State를 감싸 반환하던 이전 모델의 타입 -문제(레코드 타입 `{key: State}`가 읽기/쓰기 비대칭이라 Luau -타이핑이 안 맞음, 위 "Source가 State를 만족함" 논의에서 도출)를 풀다가 -사용자가 제안한 더 근본적인 재구성. `RefSource`(store 슬롯을 가리키는 -전용 타입)를 따로 만드는 중간안도 검토했으나, 최종적으로 **Source 자체가 -State를 만족하도록 만들고, RefSource라는 별도 타입은 폐기**하는 쪽으로 -수렴. - -**확정 방향**: -- **`Source`가 구조적으로 `State`를 만족(단방향 호환)** — State - 자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable extends - Readable`와 같은 모양). Source는 State가 주는 모든 것(`:Get()`, - `:With(...)`, `:Compute(fn)`) 위에 `:Set(value)`/`:Emit()`을 - 추가로 가짐([정정, 2026-08-07] `.value`는 State/Source에서 제외되고 - `Get()`으로 통일됨, `.value` 표기는 Ref 전용으로 좁혀짐 — `base/ - bind-system-plan.md` "`:With`/`:Compute` — self 인자도 lazy 핸들로 - 통일" 절 참고). -- **`:With`/`:Compute`는 Source에서도 항상 `State`를 반환** — Source - 자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는 - 것과 동치. 구현은 metatable `__index` 델리게이션(Source의 메소드 - 테이블이 State의 메소드 테이블로 폴백)으로 충분 — `Modifier`의 제네릭 - `__index` 트릭(`base/modifier-plan.md`)과 같은 패턴이라 로직 중복이 - 생기지 않음. -- **`RefSource` 같은 별도 타입은 불필요, `Store({defaults})`가 - 내부적으로 `{[key] = Source(default), ...}`나 다름없게 됨.** - `defaults`는 **선택**(안 줘도 됨, 순수 편의용) — `store.key`는 이미 - 만들어져 있는 키면 그 Source를 그대로 돌려주지만, **아직 안 만들어진 - 키면 그 자리에서 `Source(defaults의 해당 값 또는 nil)`을 만들어 저장한 - 뒤 돌려줌**([정정, 2026-08-07] eager 생성만으로 충분하다고 서술했던 - 이전 버전은 부정확 — Luau 타입은 런타임에 강제되지 않으므로 - `Store<>()`처럼 defaults 없이 만든 뒤 `.Key:Set(v)`를 부르는 - 경우, `__index`가 "없으면 그 자리에서 만들어 저장"까지 해주지 않으면 - `.Key`가 `nil`이라 크래시남 — 그래서 Store 생성 시점의 eager 생성(각 - `defaults` 키마다 미리 만들어둠, 이건 여전히 필요)과 `store.key` 접근 - 시점의 lazy 생성(아직 없는 키를 그 자리에서 만들어 저장, 이후 재접근은 - 재생성 없이 그대로 반환)이 **둘 다** 필요함). `defaults` 테이블 자체는 - 라이브 백킹 스토리지로 쓰이지 않고 "아직 안 만들어진 Source를 만들 때 - 참고하는 초기값 템플릿"으로 반복 참조될 뿐이라, Store 생성 후 원본 - `defaults` 테이블을 밖에서 바꿔도 문제없음(UB 아님 — 이 항목도 - `bind-system-plan.md`에 남아있던 "defaults 테이블 직접 mutate는 UB"라는 - 옛 서술과 충돌해 2026-08-07에 같이 정정함, 아래 참고). 별도 `__values`류 - 그림자 실값 저장소도 불필요 — Source 객체 자체가 저장소 역할을 함. 이 - 모델은 이전에 검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다 - (래퍼 생성/캐싱 단계 자체가 사라짐). v1이 모든 값을 Store 하나에 - 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게 쓰는 게 편해서"였다는 - 게 사용자의 회고적 재평가 — 지금은 타입이 핵심 제약이라 그 전제 자체가 - 더 이상 안 맞고, 이번 정리로 Store는 "이름 붙은 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 생성할 때 이 무인자 형태를 씀. -- **이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 OOP 상속과는 - 다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류 - 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 - 구현 델리게이션 포함)이라 그 금지와 충돌하지 않음. -- **동적 키 폴백(`store "key"`)은 이제 `State`가 아니라 `Source`를 - 반환**하는 것으로 자연히 갱신됨(위 "타입 추론 문제" 절과 연동). - -**[해소됨, 2026-08-13 첫 실측 라운드]** 핵심 질문(Source가 State를 구조적으로 -만족하는 제네릭 메소드 체이닝)은 `08-type-source-satisfies-state.luau`(`luau-test/review-required/`)로 -실측 통과 확인됨 — 아래 우려대로 "두 제네릭 타입 별칭이 서로를 참조하는 -상호 재귀"는 실제로 위험했지만, 그 아래 제안한 단방향 의존(`State`가 -`Source`를 참조 안 함) 회피책이 그대로 맞아떨어짐. **다만 좁은 잔여 -케이스 하나는 남음**: `State`가 **자기 자신**을 다른 타입 인자로 -재귀 참조하면(`Recursive type being used with different parameters`) -막힘 — 이건 아래 논의 대상이던 "두 타입 간 상호 재귀"와는 다른 문제로, -**[2026-08-13 열세 번째 세션 결론] Luau의 현 한계로 확정**되어 -`base/typing-limits.md` 1번이 담당함(구 `question.md` 0-Y는 해소). -당시 검토됐던 "구울 때 인라이닝"(T별 코드 생성) 방향은 **채택 안 함** — -제네릭 자체를 없애버려 나중에 Luau가 고쳐져도 수혜를 못 받기 때문. -아래는 그 판단에 이른 원래 추론 과정(구분 기준 등)이라 계속 -유효한 배경 — `Source`의 `:Compute` 시그니처가 자기 자신(`Source`)과 -`State`를 동시에 참조하는 제네릭 메소드라, Luau 솔버가 재귀 타입 -조합에서 막히지 않는지가 원래 질문이었음. 구분해서 볼 것: -- **자기 자신을 가리키는 self 타이핑**(`{ Compute: (self: Source, ...) -> State }` - 같은 패턴)은 Luau에서 극히 흔하고 대체로 안전 — 모든 메소드 테이블 - 클래스가 쓰는 패턴이라 이것 자체가 위험 신호는 아님. -- **진짜 위험한 건 두 제네릭 타입 별칭이 서로를 참조하는 상호 재귀** - (`Source` 정의가 `State`를 참조하고, `State`도 거꾸로 - `Source`를 참조하는 경우) — 이게 Luau 솔버가 알려진 대로 취약한 - 패턴. **`State`가 `Source`를 전혀 참조하지 않도록 먼저 독립적으로 - 정의하고, `Source`만 `State`를 참조하는 단방향 의존으로 두면** - 이 위험한 패턴 자체를 피할 수 있어 보임 — 다만 이것도 추론이라 실제 - Luau로 확인 전엔 확정 아님. -- 사용자는 `&`(교차 타입) 조합보다 **타입을 손으로 펼쳐 쓰는(flatten) - 쪽을 선호**(엔지니어링 비용을 감수하더라도 솔버 안정성 우선) — 이건 - 런타임 구현의 델리게이션(위 항목)과는 별개 축이라 서로 충돌 안 함: - 타입은 펼쳐 쓰고 구현은 공유하는 조합이 가능함. -- `ROADMAP.md` M0의 "Store/State propagation" 스파이크 항목에 이 구체적 - 케이스(Source가 State를 만족하는 제네릭 메소드 체이닝)를 포함해서 - 검증할 것. - -**이름 주의 — [해소됨, 2026-08-12 스무 번째 세션]**: `Source`/`State`라는 -이름이 한때 용어 정리 대상(특히 `State`)이었으나, **`State`는 현재 이름 -그대로 유지로 최종 확정됨**(`Computed`/`Derived`/`Pipe` 전부 기각 — 근거는 -`bind-system-plan.md` "네이밍 — `Compute`가 `-ed`가 아닌 이유" 절과 -`question.md` 1번). 더 이상 가칭이 아님. - -## Store 값 설정 문법 — `myStore.key = value` 폐기, `source:Set(value)`로 전환 (2026-08-06 후속 세션, 정정) - -**이전 버전("v1 인체공학 유지, `__newindex` 기반 `myStore.key = value` -그대로")은 폐기됨.** 아래 "Source가 State를 만족함" 절의 타입 설계와 -맞물려 재검토된 결과: - -1. **타입 대칭성**: `store.key`가 이제 `Source`를 직접 반환하는 - 평범한 레코드 필드(`{key: Source}`)로 타이핑되는데, 레코드 - 필드는 읽기/쓰기 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립함. - `store.key = value`(raw `T` 대입)를 유지하면 읽기(`Source`)/쓰기(`T`) - 타입이 갈려 mismatch가 남음 — `store.key:Set(value)`로 통일하면 필드 - 타입이 항상 `Source`로 대칭적이라 문제 자체가 안 생김(사용자 지적). -2. **의미론적 정직성**: `=` 대입 문법은 관례상 "그 자리에서 즉시 확정되는 - 부작용 없는 값 쓰기"를 암시하는데, quad의 실제 동작은 **lazy** — - `Set`은 무효화 신호만 쏘고, 실제 재계산은 나중에 누군가 관측(`Get()`)할 - 때만 일어남("Emit으로 필요한 사람 있어? 하고 물어보고, 있어야 진짜 - 계산 시작"). 이건 `=`가 암시하는 "즉시 커밋"과 정서가 안 맞고, 메소드 - 호출(`:Set()`)이 "이건 프로세스를 트리거하는 연산"이라는 걸 더 정직하게 - 신호함(사용자 확정 논거). -3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙(`base/ - architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게 - 아니라 기존 원칙의 정상적인 적용. - -**남는 것**: `myStore "key"`(문자열 커링)는 이미 3차 라운드에서 동적 키 -전용 미타입 폴백으로 격하돼 있었으므로 이번 정정과 무관하게 그대로 유지. -`:` 체이닝 원칙도 `:Set()` 자체가 그 사례라 유지. - -`base/architecture.md`의 "복사 구현 지양, 팩토리 함수로 대체" 원칙과 함께 -읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 -"불변 빌더"를 흉내낸 것이었지, `:` 체이닝 문법 자체나 커링 문법 자체가 -아니었음. - -## Source 값을 직접 mutate한 뒤 전파 — `:Emit()` (2026-08-06 후속 세션, 호출부 정정) - -**결정**: Source가 들고 있는 값을 새 값으로 교체하지 않고 제자리에서 -mutate한 뒤, `:Emit()`으로 무효화 신호만 별도로 쏘는 것을 **Source -원천(store가 직접 들고 있는 값)에 한해 허용**한다. - -**[정정, 같은 세션 후반]** 원래 `Store:Emit(key)`(Store에 key를 넘겨 -호출)로 적혀있었으나, 아래 "Source가 State를 만족함" 절에서 `store.key` -자체가 Source를 직접 반환하는 것으로 바뀌면서 `Emit`도 Source의 평범한 -메소드로 이동 — `store.key:Emit()`(key 인자 불필요, 이미 손에 든 Source -핸들에 바로 호출). `Store:Emit(key)`라는 별도 경로는 유지할 이유가 -없어져 폐기(같은 걸 하는 두 번째 경로를 남기지 않는다는 이번 세션 전반의 -원칙과 일치 — `store.key = value` → `store.key:Set(value)` 정리와 같은 결). - -**존재 이유(우선순위순)**: -1. **clone이 아예 불가능한 값이 있음.** userdata나 외부 라이브러리 - 객체(엔진 Instance 등)는 `table.clone`으로 새 값을 만들 수 없음 — - 이런 값은 "새 값을 만들어 Set"이라는 대안 자체가 없으므로, in-place - mutation + `Emit`이 변경을 전파하는 유일한 수단. -2. Lua의 불변 업데이트가 verbose함(JS의 `{...t, x=1}` 같은 문법이 없어 - `table.clone` 후 필드 덮어쓰기 + 재대입 필요) — 이걸 줄여주는 부차적 - 이득도 있지만, 이게 주된 이유는 아님(1번이 진짜 이유). - -**왜 새 구멍이 아닌가**: `Get()`은 원래도 라이브 테이블 레퍼런스를 -돌려주므로, 그 레퍼런스를 mutate하는 것 자체는 `Emit` 유무와 무관하게 -Lua에서 항상 가능한 일. `Emit`이 없으면 그 mutation은 "조용히 반영 안 -되는"(dependent가 재계산 안 됨, UI가 stale한 채 멈춤) 상태로 남을 뿐이라 -오히려 `Emit` 없는 쪽이 더 나쁜 버그 클래스 — `Emit`은 이미 가능한 -mutation에 정식 신호를 붙여주는 것뿐. - -**남는 캐비엇(문서에 반드시 명시)**: `Get()`으로 이전에 그 테이블을 -읽어서 어딘가(로컬 변수, 다른 코드가 들고 있는 참조)에 캐시해둔 게 -있다면, mutation 순간 그것도 같이 바뀐다 — 새 테이블이 아니라 같은 -레퍼런스라서. **`Get()` 결과를 나중 비교(`==`)나 diff 캐시 용도로 들고 -있으면 안 됨 — 항상 다시 `Get()`할 것.** - -**하드 경계 — Source 원천에만 허용, 중간/파생 State에는 없음.** `:With`/ -`:Compute`로 만들어진 파생 State에는 `Emit`이라는 개념 자체가 없다 — -허용하면 "이 State의 현재 값이 뭘 근거로 계산됐는가"를 아무도 설명할 수 -없게 되어(quad-debug가 추적하려는 "무엇이 무엇을 계산했는가" 그래프가 -깨짐) 디버깅이 사실상 불가능해짐. State의 값은 항상 "선언된 Compute -함수를 실제로 실행한 결과"여야 한다는 불변식이 깨지면 안 됨. 무거운 -파생 객체를 재사용하고 싶은 경우(Compute의 결과 자체가 무거운 userdata인 -경우)를 위한 별도 메커니즘은 `base/bind-system-plan.md`의 "`:Compute(fn)`의 -선택적 두 번째 인자 — `previous`" 절 참고 — 이건 `Emit`과 다른 메커니즘. - -**따름정리 — `Store`의 `T`는 Modifier가 될 수 없음.** Modifier는 -정적 flatten으로 dispatch와 완전히 별개인 단계에서 처리되고 -(`base/modifier-plan.md`) — Store/State/dispatch 경로엔 애초에 -Modifier용 processor가 없음. **[정정, 2026-08-09 세션]** `State` -조합은 "UB, 가능하면 타입 차단"이 아니라 **명시적 `error`로 확정** -(`modifier-plan.md` 7번) — `isModifier` predicate를 `Source:Set()`/ -Store 생성 시 eager `Source(default)`/State의 `:Compute` 결과 캐싱 -지점에서 확인해 런타임에 직접 막음, 타입 차단은 되면 좋은 보너스일 -뿐 유일한 방어선이 아님. **[2026-08-06 후속 세션 추가]** Source가 -State를 구조적으로 만족하게 되면서 이 제약은 `Source`(Store를 -거치지 않는 독립 `Source(someModifier)`)에도 동일하게 적용됨을 명시 — -Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processor -부재)가 그대로 적용되고, 별도로 다시 논증할 필요 없음. 위 "하드 경계"와 -같은 이유로, `Emit`이 Modifier의 정적 flatten과 충돌할 걱정 자체가 -성립하지 않음(둘이 만날 지점이 없음). - -## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 확정 - -`useEffect`처럼 여러 store 값을 디펜던시로 묶어 파생값을 계산하고 싶다는 -요구가 있었음(v1의 `myStore "a,b"` 콤마-조인 문자열 방식은 폐기 대상 — -`reference/quad-v1-architecture.md`의 "문자열 DSL" 문제점 참고). **v1의 -`:Add`/`:With`/`:Tween`처럼 값을 직접 가공하는 이름 붙은(named) 체이닝 연산은 -만들지 않음** — 대신 일반 함수를 받아 처리. (주의: 아래의 v2 `:With(...)`는 -이름만 같을 뿐 v1의 `:With`와는 다른 연산임 — v1은 "함수/테이블에서 값을 -가져오는" 가공 연산이었고, v2는 그냥 "여러 State를 의존성으로 모으는" 수집 -연산.) 최종 형태는 `:With(...)`로 의존성을 모으고 -`:Compute(fn)`으로 파생 State를 만드는 것으로 확정 — `Store.Combine({a,b}, -fn)`류 포지셔널 인자 방식은 기각됨. 정확한 lazy 인자 규칙(self/with 값 둘 다 -State 핸들로 넘기고 `:Get()`을 실제로 읽을 때만 계산)은 `base/bind-system-plan.md`의 "Store/State/Source 온톨로지" 절 참고. - -**여러 소스를 한 번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하려면 -`Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아 -Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는 -store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브 -`base/blocker-plan.md`가 다룸(State 개발과 같은 -마일스톤, `ROADMAP.md` M3에서 함께 구현). lexical `Batch(fn)`으로 풀려던 -초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 — -`archive/batch-rejected.md` 참고. diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index c702b1d..062d0cb 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -53,8 +53,11 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 이미 있는 pluggable Handler로 그대로 커버됨. `UICorner`/`UIPadding`/ `UIScale` 같은 특수 키를 인식하는 Handler(`isHandlable`이 그 키를 매칭)가 -"이름 붙은 자식을 찾거나 만들고 프로퍼티 세팅"을 `process(inst, k, v, index)`에 -구현 — v1의 하드코딩 if/elseif 대신 정식 핸들러 계약(`isHandlable`/ +"이름 붙은 자식을 찾거나 만들고 그 자식의 프로퍼티를 세팅"을 +`process(inst, k, v, index)`에 구현(**[2026-08-14 세션]** 그 마지막 +"세팅"은 직접 대입이 아니라 `Dispatch.process(child, prop, ..., 1)`로 +되돌려주는 위임으로 확정 — 아래 "Tween 지원" 절) +— v1의 하드코딩 if/elseif 대신 정식 핸들러 계약(`isHandlable`/ `priority`/`process`, 2026-08-13 다섯 번째 세션 전까진 `retract`가 별도 필드였음)을 따르는 것만 다름. `modifier-plan.md`가 이미 예시로 든 `mod:UICorner(8)`은 이 특수 키를 flatten해서 props에 @@ -105,13 +108,108 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로 API는 아니지만 공짜도 아니므로, 잦은 토글이 예상되는 값을 이 숏핸드에 직접 물리는 건 문서화 시점에 캐비엇으로 명시할 것(지금은 메모만). -## store-bind — 이 숏핸드도 지원, Tween만큼 무겁게 안 가도 됨 +## Tween 지원 — 자식 프로퍼티를 `Dispatch.process`로 다시 흘려보내면 공짜 (2026-08-14 세션 확정) + +**[역전] "트윈처럼 애니메이션까지 지원할 필요는 없음"이라던 아래 store-bind +절의 서술은 폐기.** 그 판단은 Tween이 아직 *독립 Dispatch 핸들러*였던 +시절(우선순위를 다투는 특수 bind key, `archive/tween-special-bind-key-reversed.md`) +기준이라 "이 숏핸드도 그 경쟁에 끼워 넣어야 하나"가 비용이었는데, +2026-08-10 재설계로 Tween이 **값-레벨 래퍼 `Tween` + PropertyHandler +내부 분기**가 되면서(`base/tween-plan.md`) 그 비용이 통째로 사라짐 — +이제는 숏핸드가 **자식 프로퍼티 세팅을 자기 손으로 하지 않고 Dispatch에 +되돌려주기만 하면** Tween이 저절로 따라옴. + +**메커니즘 — 인스턴스 관리 후 `process`로 위임**: + +```lua +-- 개념 스케치. Handler 계약은 base/dispatch-core-plan.md가 정본 +function UICornerHandler.process(inst, k, v, index) + if v == nil then + -- 기존 규칙 그대로: 만들어둔 자식이 있으면 지움(아래 "v가 nil인 경우" 절) + destroyManagedChild(inst, k) + return function() end + end + local child = ensureManagedChild(inst, k) -- 없으면 Instance.new + Parent, 있으면 재사용 + Dispatch.process(child, "CornerRadius", mapTweenValue(v, toUDim), 1) + return function(hint) + if hint == nil then destroyManagedChild(inst, k) end + end +end +``` + +- **`process` 도중에 대상 `inst`를 바꾸는 것은 UB가 아님(사용자 확정)** — + 키가 바뀔 수 있는 것과 정확히 같음. `chains`가 `(inst,k)` 쌍으로 + 인덱싱되므로 `(inst, "UICorner")` → `(child, "CornerRadius")` 위임은 + Dispatch 입장에서 `Attribute` 그룹이 다른 키로 위임하는 것과 구조적으로 + 동일한 일이고, 새 체인이라 인덱스는 `1`부터. 일반 규칙은 + `base/dispatch-core-plan.md`의 "인덱스의 의미" 절에 같이 명문화해뒀음. +- **Tween 해석 코드를 여기 복제하지 않는 게 핵심 이득** — `Tween`를 + 실제로 읽는 코드는 여전히 `PropertyHandler` 하나뿐이라는 + `base/tween-plan.md`의 불변식이 유지됨. 3-상태 릴레이션 슬롯 + (`{Tween, Value} | true | nil`), `Tween.Cancel`/`Tween.Finish` override + 정책, "첫 세팅은 애니메이션 없이 즉시" 규칙까지 전부 `(child, prop)` + 자리에서 그대로 재사용됨 — 이 문서가 따로 정할 게 없음. +- **타입 대수도 그대로** — 숏핸드 키의 값 타입이 `number`였다면 이제 + `number | Tween | State>`가 됨 + (`T' = T | Tween` 치환, `tween-plan.md` "타입 대수" 절). StoreBind가 + State 레이어를 먼저 다 풀어내므로 이 Handler가 실제로 보는 `v`는 + `number` 아니면 `Tween` 둘 중 하나. + +**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.** 숏핸드는 +"스칼라를 받아 자식 프로퍼티 타입으로 감싸는" 변환을 갖고 있음(`UICorner = 8` +→ `CornerRadius = UDim.new(0, 8)`, 열린 질문 절의 룩업 테이블 `wrap=fn`). +`v`가 `Tween`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만** +적용해야 함: + +```lua +-- Tween는 immutable 값 객체라 clone 후 Value만 교체(Brand 재설정은 Tween()이 함) +local function mapTweenValue(v, wrap) + if isTween(v) then + local opts = table.clone(v) + opts.Value = wrap(v.Value) + return Tween(opts) + end + return wrap(v) +end +``` + +- `UIScale`처럼 `wrap`이 항등(스칼라를 그대로 `Scale`에 씀)인 키는 이 + 헬퍼를 거쳐도 결과가 같으므로 분기 없이 일관되게 씀. +- `UIPadding`처럼 **자식의 프로퍼티 여러 개**(`PaddingTop`/`Bottom`/ + `Left`/`Right`)에 같은 값을 쓰는 키는 각 프로퍼티마다 `Dispatch.process`를 + 따로 부름 — 각자 독립된 `(child, prop)` 체인이 되고, PropertyHandler의 + 트윈 슬롯도 프로퍼티별로 따로 잡혀서 자연스럽게 4개가 같이 애니메이션됨. +- **`Tween` 값 자체는 `quad-base`, 이 숏핸드 Handler는 `quad-roblox`** — + `isTween`/`Tween()`을 base에서 가져다 쓰는 것뿐이라 패키지 경계 + (`tween-plan.md` "패키지 경계" 절)와 안 부딪힘. + +**캐비엇 — 자식이 새로 만들어진 사이클에서는 트윈이 안 걸린다(의도된 동작).** +PropertyHandler의 "첫 세팅은 애니메이션 없이 즉시"(`prev == nil`) 규칙이 +`(child, prop)` 기준이므로, `UICorner`가 `nil`↔숫자를 오가며 자식이 파괴/ +재생성되면 그 직후 첫 값은 트윈 없이 스냅됨. 이건 버그가 아니라 그 규칙이 +막으려는 것(기본값에서 목표값으로 날아오는 진입 애니메이션)과 정확히 같은 +상황 — 계속 애니메이션되길 원하면 자식이 살아있도록 `nil`로 내리지 말고 +값만 바꿀 것. + +**자식을 없앨 때의 정리 책임은 이 Handler에 있음** — `v`가 `nil`이 되거나 +retractor가 `nil` 힌트로 불려 자식을 파괴할 때, 실행 중인 엔진 Tween이 +남아있을 수 있으므로 `Dispatch.retractFrom(child, prop, 1)`을 같이 +부르는 게 정석(자식 Instance를 `Destroy`하면 엔진 트윈도 같이 죽고 +`chains`도 weak-keyed라 결국 GC되지만, "즉시" 끊는 건 명시적 호출뿐). +`retractor` 안에서 **다른 키**에 대한 `retractFrom`을 부르는 건 허용된 +경로임(`base/dispatch-core-plan.md`의 retract 계약 — 금지된 건 같은 +`(inst,k)`에 대한 재진입). + +## store-bind — 이 숏핸드도 지원 v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했음 (`myStore "key"` 체이닝으로 다른 프로퍼티와 동일하게 취급됨) — quad-v2도 -이 능력을 유지한다. 트윈처럼 애니메이션까지 지원할 필요는 없음(API 표면만 -복잡해짐) — 그냥 값이 바뀌면 `CornerRadius`/`Padding`/`Scale` 프로퍼티를 -다시 세팅하는 정도로 충분. 구현 비용도 낮음: 각 Handler가 `process`에서 +이 능력을 유지한다. **[정정, 2026-08-14 세션]** 이 절의 원 서술은 "트윈처럼 +애니메이션까지 지원할 필요는 없음(API 표면만 복잡해짐) — 그냥 값이 바뀌면 +프로퍼티를 다시 세팅하는 정도로 충분"이었으나, 위 "Tween 지원" 절에서 +뒤집혔음(자식 프로퍼티를 `Dispatch.process`로 되돌려주면 Tween이 공짜로 +따라오므로 "안 하는 게 더 비싸지는" 상황이 됨). 구현 비용은 여전히 낮음: +각 Handler가 `process`에서 "이전에 자기가 찾거나 만든 자식 Instance"를 얻어야 하는데, 이건 이미 base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저장소 (`Relate:SetStrong(inst,k,...)`, `base/relate-plan.md`/`base/dispatch-core-plan.md` @@ -138,7 +236,9 @@ Tween 상태를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 ## 남은 열린 질문 (단순화 후보, 사소함) - UICorner/UIPadding/UIScale 3개 거의 동일한 형태의 Handler를 각각 만들지, - `{key -> {ChildClassName, ChildDefaultName, Property, wrap=fn}}` 룩업 - 테이블로 구동되는 단일 `Handlers/InstanceShorthand.luau`로 통합할지 — + `{key -> {ChildClassName, ChildDefaultName, Properties, wrap=fn}}` 룩업 + 테이블로 구동되는 단일 `Handlers/InstanceShorthand.luau`로 통합할지 + (`Properties`가 단수가 아니라 목록인 이유는 `UIPadding`이 자식 프로퍼티 + 4개에 같은 값을 쓰기 때문 — 위 "Tween 지원" 절) — `research/pre-implementation-audit.md` 3-2번 참고, 강제 사항 아님, 구현 시점에 결정할 정도의 사소한 개선 후보. diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index d2e53a4..dc06d53 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -74,14 +74,14 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 | | `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 | | `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 | -| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `store-semantics.md` "검증 필요", ROADMAP M0-2 | +| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 | | `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 | -| `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`]** (A) `bindLifetime`/`unbindLifetime`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 `canBound`, `bindLifetime`의 `value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 폐기됨(게이트는 `if canExecute(v) then error(...) end` 하나, `canExecute`는 `value` 단독 1-인자, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` | +| `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`]** (A) `bindLifetime`/`unbindLifetime`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 `canBound`, `bindLifetime`의 `value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 폐기됨(게이트는 `if canExecute(v) then error(...) end` 하나, `canExecute`는 `value` 단독 1-인자, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` | | `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 | | `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<> "name"] = value`(구 `Attribute<>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | | `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) | -| `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `bind-system-plan.md` "[보강, 2026-08-09 열한 번째 세션]" 절 | -| `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `bind-system-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | +| `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` 필드를 만족하는지 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | | `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` 실사례 | diff --git a/.claude/luau-test/done/08-type-source-satisfies-state.luau b/.claude/luau-test/done/08-type-source-satisfies-state.luau index 290e8fc..0ee7ac4 100644 --- a/.claude/luau-test/done/08-type-source-satisfies-state.luau +++ b/.claude/luau-test/done/08-type-source-satisfies-state.luau @@ -4,7 +4,7 @@ State 참조가 섞인 제네릭 :Compute) 설계가 Luau 타입 솔버에서 안전하게 추론되는지 — 실제 실행이 아니라 타입 체크(luau-analyze) 대상. - 배경: .claude/base/store-semantics.md "검증 필요(확정 아님, M0 스파이크 + 배경: .claude/base/source-state-plan.md "검증 필요(확정 아님, M0 스파이크 대상)" 절, ROADMAP.md M0 2번째 항목. 핵심 우려: State가 거꾸로 Source를 참조하는 "상호 재귀"는 Luau diff --git a/.claude/luau-test/done/11-modifier-illegal-value-error.luau b/.claude/luau-test/done/11-modifier-illegal-value-error.luau index 9d96a2a..5deda4a 100644 --- a/.claude/luau-test/done/11-modifier-illegal-value-error.luau +++ b/.claude/luau-test/done/11-modifier-illegal-value-error.luau @@ -75,7 +75,7 @@ local function isModifier(v) return type(v) == "table" and v[ModifierBrand] == true end local function isState(v) - -- Source가 State를 구조적으로 만족(store-semantics.md) — 여기선 둘 다 + -- Source가 State를 구조적으로 만족(source-state-plan.md) — 여기선 둘 다 -- ".__isStateLike" 태그로 단순화해서 흉내 return type(v) == "table" and v.__isStateLike == true end diff --git a/.claude/luau-test/rewrite-required/15-type-compute-trailing-deps-typepack.luau b/.claude/luau-test/rewrite-required/15-type-compute-trailing-deps-typepack.luau index fbdfbf0..9db66e5 100644 --- a/.claude/luau-test/rewrite-required/15-type-compute-trailing-deps-typepack.luau +++ b/.claude/luau-test/rewrite-required/15-type-compute-trailing-deps-typepack.luau @@ -140,7 +140,7 @@ print(resultA, resultB, resultD) `a:Get()`/`b:Get()`가 정확한 타입으로 좁혀지는지)가 전부 에러 없이 통과하는가 — 통과하면 `fn(self, previous?, ...deps)` 순서가 base 문서 확정대로 최종 시그니처로 채택됨. - 5. 결과에 따라 `base/bind-system-plan.md` "trailing deps를 fn에 lazy + 5. 결과에 따라 `base/source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절에 실측 결과로 반영할 것: - B/D 통과, C 실패(예상대로) → 지금 base 문서에 적힌 `fn(self, previous?, ...deps)` 시그니처 그대로 최종 확정. diff --git a/.claude/question.md b/.claude/question.md index b7533ea..c67f293 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -211,8 +211,6 @@ 합성 시점 1회 체크로 error를 내는 게 이 문서 다른 결정들과 결이 같지만, "Merged는 뒤가 이긴다"를 의도된 override로 볼 여지도 있어 사용자 확인 필요 — `base/attribute-plan.md` "열린 질문" 절. -- `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 - 착수를 막지 않음. - **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고. 채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를 넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은 diff --git a/.claude/reference/comparison-charm.md b/.claude/reference/comparison-charm.md index 1a690d8..bb26949 100644 --- a/.claude/reference/comparison-charm.md +++ b/.claude/reference/comparison-charm.md @@ -33,7 +33,7 @@ init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) 대입 문법과 같은 트레이드오프.** `atom(initialValue, equals?)` (`init.luau:519-527`)가 인자 개수로 read/write를 분기하는 방식 — `store.key = value`를 버리고 `store.key:Set(value)`로 간 이유 - (`base/store-semantics.md:208-233`, 읽기/쓰기 타입 비대칭)와 같은 문제. + (`base/store-plan.md` "Store 값 설정 문법" 절, 읽기/쓰기 타입 비대칭)와 같은 문제. charm 스스로도 README(185-196행)에서 `atom()`을 `signal()`(진짜 get/set 쌍) 위에 얹은 편의 sugar로 취급 — charm 안에서도 "진짜 1급 형태는 아니다"로 다뤄지는 걸 참고. @@ -57,7 +57,7 @@ init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) 스킵" 기본값을 도입하고 싶어질 때, charm처럼 **모든 노드에 암묵적으로** 거는 방식은 `Blocker`가 이미 명시한 "특정 게이트 지점에서만 opt-in" 원칙(`base/blocker-plan.md:65-68`)과 "Source는 스스로를 자동 변형하지 - 않는다"는 `store-semantics.md` 기조에 둘 다 어긋남 — 반면교사로 남겨둘 것. + 않는다"는 `base/source-state-plan.md` 기조에 둘 다 어긋남 — 반면교사로 남겨둘 것. ## 참고할만한 부분 @@ -76,7 +76,7 @@ init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) getter에 **이전 계산 결과**를 인자로 넘겨줌(`init.luau:538`, `(previousValue: T?) -> T`, README 276-287행, `computed.test. luau:84-104`가 홀수 업데이트를 스킵하는 걸로 실제 검증) — quad의 - `store-semantics.md:280-284`가 이미 띄워둔 "`:Compute(fn)`에 선택적 + `base/source-state-plan.md`가 이미 띄워둔 "`:Compute(fn)`에 선택적 두 번째 `previous` 인자" 안과 거의 동일한 모양. 새로 수입할 아이디어가 아니라 **이미 검토 중인 안이 실제로 동작한다는 정황 증거**로 인용 가치 있음. @@ -86,7 +86,7 @@ init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) (`patch.luau:91-131`)이 immutable 재구축(레벨마다 `table.clone`, 순수 signal용)과 in-place mutate+`:Emit()`류 변형(반응형 프록시용) 둘 다 제공 — quad가 이미 다른 이유로 갖고 있는 clone-vs-mutate+`Emit` 분리 - (`base/store-semantics.md:240-284`)와 우연히 같은 모양. `patch. + (`base/source-state-plan.md`의 `:Emit()` 절)와 우연히 같은 모양. `patch. luau:32-57`(`stringifySparseArray`)는 실전에서 놓치기 쉬운 페이로드 함정을 문서화함 — RemoteEvent/JSON 직렬화가 성긴 배열의 trailing hole을 조용히 드롭해서, 보낼 땐 문자열 키로 재인코딩하고 받을 땐 숫자 키로 @@ -128,7 +128,7 @@ previous-in-getter). 지금 당장 base 문서를 고칠 만한 발견은 없음 computed.test.luau:84-104` · `packages/charm/test/observe.test.luau:92-196` · `packages/charm-sync/src/patch.luau:10,19-30,32-57,59-89,91-131` · `packages/charm-sync/src/server.luau:27-32,124-133,192-207,209-250` · -`README.md:185-196,262-287` · `base/store-semantics.md:208-233,240-284` · +`README.md:185-196,262-287` · `base/store-plan.md` · `base/source-state-plan.md` · `base/blocker-plan.md:25-44,65-68` · `base/lifecycle-pattern.md`(GC-native 원칙) · `archive/batch-rejected.md` · `base/bind-system-plan.md:180-266` (None 센티널) · `research/additional-primitives-plan.md`(Blocker/키 기반 diff --git a/.claude/reference/comparison-fusion-vide.md b/.claude/reference/comparison-fusion-vide.md index 4d45c73..7a038b3 100644 --- a/.claude/reference/comparison-fusion-vide.md +++ b/.claude/reference/comparison-fusion-vide.md @@ -59,7 +59,7 @@ Store/Slot/Tween/bind-dispatch 설계 결정에 근거로 인용될 때만 열 | 축 | Fusion | Vide | quad-v2 시사점 | |---|---|---|---| -| 전파 모델 | push+pull 하이브리드, eager 집합만 즉시 재계산, 생성순 정렬로 글리치 방지 | 순수 push, 즉시 동기 재평가, 다이아몬드 중복 재평가 미해결(저자 인정) | ⚠️ **[정정] 아래 서술은 리서치 당시(2026-08-03 이전) 검토 방향이며 이후 뒤집힘 — 최종 확정은 `base/bind-system-plan.md`의 "전파 모델 확정" 절 참고**(push-invalidate는 신호만 쏘고 값은 안 실음, 재계산은 `Get()` 시점 pull-recompute로만, Fusion식 eager 노드·생성순 정렬은 아예 채택 안 함 — quad엔 그런 다단계 즉시 재계산이 필요한 소비자가 없다는 판단). 당시 스냅샷 원문: "Store는 값 자체에 항상 eager 발화, retract(구 cleanup)가 key/value를 먼저 확인" 요구사항은 Vide의 push 모델 + eval-전-retract 패턴에 더 가까움. 단 Vide의 naive BFS 대신 Fusion의 생성순 정렬 글리치 방지 규율은 채택할 것. | +| 전파 모델 | push+pull 하이브리드, eager 집합만 즉시 재계산, 생성순 정렬로 글리치 방지 | 순수 push, 즉시 동기 재평가, 다이아몬드 중복 재평가 미해결(저자 인정) | ⚠️ **[정정] 아래 서술은 리서치 당시(2026-08-03 이전) 검토 방향이며 이후 뒤집힘 — 최종 확정은 `base/source-state-plan.md`의 "전파 모델 확정" 절 참고**(push-invalidate는 신호만 쏘고 값은 안 실음, 재계산은 `Get()` 시점 pull-recompute로만, Fusion식 eager 노드·생성순 정렬은 아예 채택 안 함 — quad엔 그런 다단계 즉시 재계산이 필요한 소비자가 없다는 판단). 당시 스냅샷 원문: "Store는 값 자체에 항상 eager 발화, retract(구 cleanup)가 key/value를 먼저 확인" 요구사항은 Vide의 push 모델 + eval-전-retract 패턴에 더 가까움. 단 Vide의 naive BFS 대신 Fusion의 생성순 정렬 글리치 방지 규율은 채택할 것. | | 정리/스코프 | 배열+메타테이블, dependency-agnostic, bind 시점에만 lifetime soft-check | dependency edge와 구조적 owner를 분리한 2중 관계, destroy는 owned만 cascade, 활성 스코프 destroy 하드 가드 | 둘 다 GC 비의존 eager 수동 정리 — rbvm의 Connected+GC 관용구와 정반대 축. quad의 Slot은 Vide처럼 "마운트 소유권"과 "반응 의존성"을 별개 관계로 분리하는 게 안전해 보임(`base/lifecycle-pattern.md`의 rbvm 패턴과는 다른 층위 — rbvm은 인스턴스 파괴 감지, 이건 Slot 내부 소유권 모델). | | 키/값 디스패치 개방성 | SpecialKey 모양은 열려있으나 우선순위 4단계 하드고정 | action()은 등록 없는 태그 인식 방식이지만 key/value 버림, 콜백+우선순위만 | quad는 Fusion의 "디스패처가 key+value+target을 다 받는" 풍부함과 Vide의 "등록 없이 태그로 인식" 인체공학을 합치되, 우선순위 축은 열린 숫자 공간으로 일반화해야 함(`base/bind-system-plan.md`). | @@ -67,7 +67,7 @@ Store/Slot/Tween/bind-dispatch 설계 결정에 근거로 인용될 때만 열 - Vide의 암묵적(ambient stack) 의존성 추적 vs Fusion의 명시적 `use()` 축은 push/pull 축과 독립적인 별개 결정. **[정정] 리서치 당시(2026-08-03 이전)엔 - "quad는 아직 미정"이었으나, `base/bind-system-plan.md`의 "여러 Store 값을 + "quad는 아직 미정"이었으나, `base/source-state-plan.md`의 "여러 값을 묶어 파생값 만들기 — `:With` + `:Compute`" 절에서 이미 명시적 모델로 확정됨** — Vide식 암묵적 ambient stack 추적은 "함수 실행 중과 끝 사이를 확인하고 부작용이 필요"한 방식이라 Lua에서 깔끔하지 않다는 이유로 기각, diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 3001886..a146e29 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -27,7 +27,7 @@ 1. **초기화** — `RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`) 2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 `new` + 자주 쓰는 ~25개 클래스 정적 필드(`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는 항상 읽기 전용 (`bind-system-plan.md`, `store-semantics.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()`으로 바뀜) 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`) @@ -35,7 +35,7 @@ 9. **이벤트** — self(Instance) 안 받음, 문자열 키(`Frame { MouseButton1Click = fn }`) (`bind-system-plan.md`) 10. **생명주기** — GC 위임(수동 정리 불필요), Destroy 이후 대상 재사용 금지 (`lifecycle-pattern.md`) 11. **Ref 기초** — 외부 관리 Instance 참조/마이그레이션용, `Ref(default):Callback(fn)`을 children 배열 숫자 슬롯에 직접 놓기 + 배열 위치로 자식 전/후 표현, "프로퍼티보다도 먼저" 필요할 때만 `PreRef`(2026-08-07 세 번째 세션, `phase` 옵션 폐기) (`architecture.md`, `bind-system-plan.md`) -12. **파생값 최소 예시** — `:With(...)` + `:Compute(fn)` 기본형 (`bind-system-plan.md`, `store-semantics.md`) +12. **파생값 최소 예시** — `:With(...)` + `:Compute(fn)` 기본형 (`base/source-state-plan.md`) 13. **Tween 기초** — ~~`[Tween(key, ...)] = storeValue`~~ **[2026-08-13 정정] 구모델(폐기, `archive/tween-special-bind-key-reversed.md`) — 실제로는 `Tween(opts) -> Tween` 값-레벨 래퍼**, 취소 시 현재 보간값에서 자연스럽게 이어짐 (`base/tween-plan.md`) 14. **UI 숏핸드(quad-roblox 한정)** — `UICorner`/`UIPadding`/`UIPaddingOffset`/`UIScale` 인라인 키 (`base/ui-shorthand-plan.md`) @@ -75,7 +75,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 ### component-composition-plan.md / module-lifecycle-plan.md - 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티 바인딩(**[정정, 2026-08-09 열한 번째 세션] "State만"이 아님 — 단순 원본 토글(`Frame{Visible=source}`)은 Source 직접 바인딩이 정상 경로, 여러 값에서 파생된 계산 결과일 때만 자연히 State가 됨, `component-composition-plan.md` 5번 절 참고**) / `props.Modifier`/`props.Ref` named parameter 경계 전달(**`props.Modifier or None`/`props.Ref or None` 필수 관용구 — 안 쓰면 nil-hole 버그, 2026-08-07 열 번째 세션 확정**) / `InitRoblox(Module)` 팩토리 초기화 - api: State(파생, 읽기전용) vs Source(원본, 쓰기가능) 경계 요약(→심화) / Slot 반환 컴포넌트는 Modifier/Ref 파라미터 미선언 / `Modifier.Overridden(mod1, mod2, ...)` 유틸(구 `Merge`, `props.Modifier` 단일 슬롯용 특수 상황으로 한정 소개 — 아래 modifier-plan.md 절 참고) / Bind는 유일 슬롯(재호출 no-op, 충돌 에러, →심화) / `:With`/`:Compute`로 파생 State 생성 시그니처 / 모듈 싱글톤 스코프 -- 심화: v1 `Extend` 자동 store 소유 폐지 이유(React 벤치마킹) / Source가 State를 구조적으로 만족하는 서브타입 설계(2026-08-06 후속 세션 — `StoreSource` 프록시 중간안은 폐기되고 이걸로 대체됨, `store-semantics.md` 참고) / named-parameter 경계 방식 채택 이유(Compose/Fusion/Vide/v1 선례 수렴) / 다중 루트 반환 개념 제거 근거 / 팩토리 초기화 패턴 채택 이유(RBVM `InitNamespace` 반례) / Store 책임 분리(base가 `LifetimeHandle` 소유) / v1 named 체이닝 연산 폐기 +- 심화: v1 `Extend` 자동 store 소유 폐지 이유(React 벤치마킹) / Source가 State를 구조적으로 만족하는 서브타입 설계(2026-08-06 후속 세션 — `StoreSource` 프록시 중간안은 폐기되고 이걸로 대체됨, `base/source-state-plan.md` 참고) / named-parameter 경계 방식 채택 이유(Compose/Fusion/Vide/v1 선례 수렴) / 다중 루트 반환 개념 제거 근거 / 팩토리 초기화 패턴 채택 이유(RBVM `InitNamespace` 반례) / Store 책임 분리(base가 `LifetimeHandle` 소유) / v1 named 체이닝 연산 폐기 - skip: Compose/Fusion/Vide/v1 프레임워크 비교 원자료 / provider/processor 네이밍 미정 등 열린 질문 메모 ### lifecycle-pattern.md / purity-and-effects-plan.md @@ -106,9 +106,9 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 콘텐츠 작성 시점에 결정. - skip: 세션 날짜/확정 이력, 문서 승격/정정 안내 -### store-semantics.md / tween-plan.md / ui-shorthand-plan.md +### store-plan.md / source-state-plan.md / tween-plan.md / ui-shorthand-plan.md - 초심자: Store 생성+`myStore.key:Set(value)` 문법 / `store.key`로 State 얻기 개념 / Tween 기본 바인드 키+취소 기본 동작 / UI 숏핸드 인라인 키 기본 예시(`Frame { UIPaddingOffset = 50 }`) -- api: `:With`+`:Compute` 시그니처(→심화) / `source:Emit()` 존재+"Get() 결과 캐시 금지" 캐비엇(버그 유발 포인트라 api에도 명시 가치 있음, →심화; 2026-08-06 후속 세션에서 `Store:Emit(key)`→`source:Emit()`로 호출부 변경, `store-semantics.md` 참고) / ~~Tween 핸들러가 Instance 직접 받음(Ref 불필요)~~ **[2026-08-13 정정] 구모델(폐기) — 실제로는 `Tween(opts)` 값-레벨 래퍼가 Property 자리에 놓이고 `PropertyHandler`가 `isTween`으로 분기** / retract는 Destroy 시 호출 안 됨(→심화) / UI 숏핸드 키 목록 레퍼런스 표 / Modifier와 순수 인라인 키 동등성 +- api: `:With`+`:Compute` 시그니처(→심화) / `source:Emit()` 존재+"Get() 결과 캐시 금지" 캐비엇(버그 유발 포인트라 api에도 명시 가치 있음, →심화; 2026-08-06 후속 세션에서 `Store:Emit(key)`→`source:Emit()`로 호출부 변경, `base/source-state-plan.md` 참고) / ~~Tween 핸들러가 Instance 직접 받음(Ref 불필요)~~ **[2026-08-13 정정] 구모델(폐기) — 실제로는 `Tween(opts)` 값-레벨 래퍼가 Property 자리에 놓이고 `PropertyHandler`가 `isTween`으로 분기** / retract는 Destroy 시 호출 안 됨(→심화) / UI 숏핸드 키 목록 레퍼런스 표 / Modifier와 순수 인라인 키 동등성 - 심화: Source·Store·State·Observer 온톨로지(독립 프리미티브 vs 파생 데이터 원칙, 생성자 모양 근거) / `Emit`이 Source 전용인 이유(디버깅 그래프 무결성) / `Store`의 T가 Modifier 불가인 이유 / ~~Tween을 반응 그래프 밖 특수 bind key로 둔 이유(Fusion 반면교사)~~ **[2026-08-13 정정] 이 근거 자체가 폐기된 구모델 서술 — 현재는 Tween이 반응 그래프 "밖"이 아니라 Property 값 타입 치환(`T|Tween`)으로 자연스럽게 들어와 있음, `base/tween-plan.md` 참고** / RoundSize 포팅 불필요 vs UICorner/UIPadding/UIScale 필요 이유 / "작고 opt-in 아닌 편의 기능은 코어 포함" 원칙 - 열린 질문(문서화 보류): tween-plan.md의 오버라이드/삭제후재시작/끝점이동 옵션 키 이름 미정 / ui-shorthand의 RoundSize 완전 드롭 여부 - skip: 세션 정정 이력, v1 소스 조사 경위 @@ -133,10 +133,10 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 8. 왜 "다중 루트 반환" 개념을 없앴는가 — `component-composition-plan.md` 9. 왜 Slot은 단일 마운트 소유권을 강제하는가(v1/Fusion/Vide 대비) — `slot-plan.md`, `comparison-fusion-vide.md` 10. ~~왜 Tween은 반응 그래프 밖에 있는가~~ **[2026-08-13 정정] 위 §2 심화 항목과 같은 stale 표현 — 실제로는 Property 값 타입 치환(`T|Tween`)으로 그래프 안에 자연스럽게 있음** — `base/tween-plan.md` -11. 왜 `:Emit()`은 Source 전용이고 파생 State엔 없는가(호출부는 `source:Emit()`, 2026-08-06 후속 세션에서 `Store:Emit(key)`→이 형태로 정리) — `store-semantics.md` -12. 독립 프리미티브 vs 파생 데이터 — 생성자 모양을 결정하는 원칙 — `store-semantics.md` +11. 왜 `:Emit()`은 Source 전용이고 파생 State엔 없는가(호출부는 `source:Emit()`, 2026-08-06 후속 세션에서 `Store:Emit(key)`→이 형태로 정리) — `base/source-state-plan.md` +12. 독립 프리미티브 vs 파생 데이터 — 생성자 모양을 결정하는 원칙 — `base/source-state-plan.md` 14. 왜 컴포넌트는 전역 store를 직접 참조하면 안 되는가(이식성) — `purity-and-effects-plan.md` -15. 왜 Source가 State를 구조적으로 만족하는가(Svelte Writable/Readable과 같은 서브타입 모양, `RefSource`/`StoreSource` 중간안이 왜 기각됐는가) — `store-semantics.md`, `component-composition-plan.md` +15. 왜 Source가 State를 구조적으로 만족하는가(Svelte Writable/Readable과 같은 서브타입 모양, `RefSource`/`StoreSource` 중간안이 왜 기각됐는가) — `base/source-state-plan.md`, `component-composition-plan.md` 16. **State 파생 체인 동작 원리** — emit이 아래로 전파되고, `Get()` 요청이 위로 거슬러 올라가 재계산된 뒤 다시 아래로 내려오는 흐름을 명확히 설명(Blocker/Effect 둘 다 이 흐름 위에서 동작하므로 선행 이해로 필요) @@ -218,7 +218,7 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: "PreRef는 '취소'라는 개념이 없다" 항목 참고. 7. **왜 `Compute(fn, ...)`는 여러 의존성을 편하게 받고 `Effect`/`Observer`는 - 안 받는가** (2026-08-11 세션 원자료, `bind-system-plan.md` "`:Compute(fn, + 안 받는가** (2026-08-11 세션 원자료, `source-state-plan.md` "`:Compute(fn, ...)` — 추가 의존성을 trailing args로 직접 받는 sugar" 절) — 겉보기엔 비일관적인 API 표면(하나는 React `useMemo`식 trailing deps sugar를 받고, 다른 둘은 명시적 `:With` 호출을 강제)이 실은 "sugar가 새 노드 생성 비용을 diff --git a/.claude/research/documentation-plan.md b/.claude/research/documentation-plan.md index 47f525c..8492df3 100644 --- a/.claude/research/documentation-plan.md +++ b/.claude/research/documentation-plan.md @@ -113,7 +113,7 @@ UICorner/UIPadding/UIScale 숏핸드가 만드는 자식)는 `_`나 `QUAD_` 같 ## 2. Store 부작용을 게임 시스템에서 깔끔하게 쓰는 패턴 문서 **배경**: quad의 Store는 부작용 허용이 기본 설계(`base/architecture.md`, -`base/store-semantics.md`에서 확정)인데, 실제 게임 개발(스킬, 쿨타임, +`base/store-plan.md`에서 확정)인데, 실제 게임 개발(스킬, 쿨타임, 재화 같은 도메인 상태를 담당하는 store, 부분별 모듈화 등)에서 이 자유도를 깔끔한 패턴으로 쓰는 법이 아직 문서화 안 됨 — 사용자 원 발언: "원래 의도했던 편한 부작용 허용을 좀더 더럽지 않은 패턴으로 수행하는 방법도 @@ -166,7 +166,7 @@ UICorner/UIPadding/UIScale 숏핸드가 만드는 자식)는 `_`나 `QUAD_` 같 있으므로 여기선 소재 제목과 위치만 남김: - **왜 State 체인을 Modifier처럼 플래튼하지 않는가** — 결정문은 - `base/bind-system-plan.md` "왜 State 체인을 Modifier처럼 플래튼하지 + `base/source-state-plan.md` "왜 State 체인을 Modifier처럼 플래튼하지 않는가" 절. State의 정의가 "캐싱하는 존재"라 다이아몬드 파생에서 캐시 공유를 잃으면 `previous` 메커니즘이 막으려던 중복 계산 문제가 재발한다는 게 핵심 논거. diff --git a/.claude/research/framework-comparison-findings.md b/.claude/research/framework-comparison-findings.md index e52f642..164fc4e 100644 --- a/.claude/research/framework-comparison-findings.md +++ b/.claude/research/framework-comparison-findings.md @@ -46,7 +46,7 @@ - ~~Store dot-access가 매 접근마다 새 State를 할당~~ — **[해소됨, 2026-08-06 세 번째 세션]** 이 항목이 직접 트리거가 되어 Source/State - 관계 자체를 재구성(`store-semantics.md` "Source가 State를 만족함" 절) — + 관계 자체를 재구성(`base/source-state-plan.md` "Source가 State를 만족함" 절) — Store가 이제 생성 시 만들어둔 Source를 그대로 반환해 wrapper 할당 자체가 없어짐, 구현 단계 최적화가 아니라 설계로 완전히 없앰(캐싱/풀링보다도 쌈). diff --git a/.claude/research/operator-sugar-plan.md b/.claude/research/operator-sugar-plan.md index 2ca9876..132cd76 100644 --- a/.claude/research/operator-sugar-plan.md +++ b/.claude/research/operator-sugar-plan.md @@ -83,7 +83,7 @@ price:Apply(addTaxAndShipping) 1. **재사용 가능한 커링 팩토리는 `:Compute`로는 안전하게 못 만든다 — 진짜 버그 가능성.** quad는 Vide식 암묵적 자동 추적을 이미 기각했음 - (`bind-system-plan.md` "암묵적 자동 추적 기각") — 의존성은 오직 + (`base/source-state-plan.md`의 "여러 값을 묶어 파생값 만들기" 절) — 의존성은 오직 `:With`/`:Compute`의 **그 호출문 자체**에 나열된 trailing args로만 등록됨. 그래서 `local addTax = Sum(tax, shipping)`처럼 한 번 만들어 재사용하고 싶은 값을 `price:Compute(addTax)`처럼 바로 꽂으면, @@ -93,14 +93,14 @@ price:Apply(addTaxAndShipping) `price:Compute(addTax, tax, shipping)`처럼 이미 `Sum(...)`에 넘긴 deps를 호출부에서 또 나열해야 하는데, 이게 바로 2026-08-11 세션에서 "trailing deps를 fn 위치 인자로 노출"하게 만든 그 중복/드리프트 - 위험(`bind-system-plan.md` 해당 절)과 완전히 같은 클래스의 문제 — + 위험(`base/source-state-plan.md` 해당 절)과 완전히 같은 클래스의 문제 — 재사용 가능한 이름을 만드는 의미 자체가 없어짐. **`:Apply`는 이 문제가 원천적으로 없음**: factory가 내부에서 `self:Compute(fn, tax, shipping)`을 직접 호출해 자기가 캡처한 deps를 스스로 다시 넘기므로(호출자가 재입력하는 게 아니라 factory 자신이 한 번 캡처한 값을 그대로 전달), 중복 없이 안전하게 재사용됨 — `price:Apply(addTax)`, `otherPrice:Apply(addTax)` 둘 다 안전. -2. **기존 문서 관용구와 일치.** `bind-system-plan.md`의 `:Apply` 절이 +2. **기존 문서 관용구와 일치.** `base/source-state-plan.md`의 `:Apply` 절이 이미 `state:Apply(makeFormatter("ko-KR"))`를 "커링 팩토리 + `:Apply`"의 정석 예시로 들어둠 — `Operator.*`/`Animate`가 이 관용구를 따르는 게 자연스러움. `Animate`가 `:Compute`를 골랐던 건 오히려 이 기존 @@ -346,7 +346,7 @@ offset이 밀리고 당겨지는 케이스(이건 이미 "그냥 확인된 것" 이게 이 항목을 단순 슈가로 못 만드는 이유이자, 백로그에서 따로 더 파야 하는 지점: -- quad는 **암묵적 자동 추적을 기각**했고(`base/bind-system-plan.md`), +- quad는 **암묵적 자동 추적을 기각**했고(`base/source-state-plan.md`), 의존성은 `:With`로 **정적으로** 선언하게 돼 있음. 게다가 "`:With`의 동적 의존성 미지원"은 2026-08-12 열여덟 번째 세션에 **의도된 트레이드오프로 확정**됨(`research/framework-comparison-findings.md`) — diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index af2f5ef..ef9b54f 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -3,7 +3,8 @@ **상태**: research — 사용자 상의 필요. 2026-08-06 세션에서 신설. `.claude/base/` 전체가 "확정"으로 표시돼 있지만, 실제 `ROADMAP.md` M0 착수를 앞두고 구현자 시점에서 다시 크리티컬하게 훑어본 결과. 방법론: `base/` + 근접 -`research/`(tween-plan, ui-shorthand-plan, existing-instance-bind-plan)를 +`research/`(tween-plan, ui-shorthand-plan, existing-instance-bind-plan — +앞의 둘은 이후 `base/`로 승격, 마지막은 2026-08-14에 기각되어 `archive/`)를 4개 클러스터로 나눠 서브에이전트로 병렬 정독시키고, 각각 세 가지 렌즈로 리뷰하게 했음 — @@ -310,7 +311,7 @@ base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전. **위치**: `ROADMAP.md` M0 vs M3 `"store.key dot-access 타입 추론 확인"`. **문제**: M0의 정의 자체가 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 -없는 것"을 검증하는 단계다. `base/store-semantics.md`가 요청한 M0 항목( +없는 것"을 검증하는 단계다. `base/source-state-plan.md`가 요청한 M0 항목( "Source가 State를 만족하는 제네릭 메소드 체이닝"의 솔버 안정성)은 이미 반영됐지만, 이건 `:Compute` 같은 제네릭 메소드 체이닝만 다루고 `{key: Source}` 같은 **레코드 필드로서의 dot-access 타이핑**(읽기/쓰기 @@ -373,7 +374,7 @@ Luau의 현 한계로 확정되어 `base/typing-limits.md` 1번이 담당**함 아니라 narrow edge case)라 아래 원래 제안(M0 스파이크에 폴백 한 줄 박아두기)은 더 이상 적용 대상 없음. 원래 서술은 배경 기록으로 남김: -**위치**: `base/store-semantics.md` "Source가 State를 만족함" 절 — +**위치**: `base/source-state-plan.md` "Source가 State를 만족함" 절 — "검증 필요(확정 아님, M0 스파이크 대상)... 다만 이것도 추론이라 실제 Luau로 확인 전엔 확정 아님." @@ -381,7 +382,7 @@ Luau로 확인 전엔 확정 아님." 실패했을 때 뭘 하는지가 문서 어디에도 없다.** 이 타입 구조(Source⊂State 서브타입)는 `store.key`의 반환 타입, `:Set()` 문법, `:Emit()`의 위치, dot-access 타입추론, `RefSource` 폐기 결정까지 전부 이 위에 얹혀 있어서, -Luau 솔버가 막히면 되돌릴 범위가 `store-semantics.md`의 절반 이상에 걸침. +Luau 솔버가 막히면 되돌릴 범위가 `source-state-plan.md`의 절반 이상에 걸침. **제안**: M0 스파이크 계획에 "실패 시 폴백은 RefSource 부활 vs 다른 대안" 한 줄이라도 미리 박아두면, 실패했을 때 다시 사용자 자문을 구하느라 멈추는 @@ -392,7 +393,7 @@ Luau 솔버가 막히면 되돌릴 범위가 `store-semantics.md`의 절반 이 **[대부분 해소, 2026-08-09 세션]** "가능하면 타입 차단, 안 되면 UB로 후퇴"라는 원래 걱정 자체가 무의미해짐 — `State`를 이제 `isModifier` predicate 기반 명시적 `error`로 막기로 확정 -(`base/modifier-plan.md` 7번 정정, `base/store-semantics.md` "따름정리" +(`base/modifier-plan.md` 7번 정정, `base/source-state-plan.md` "따름정리" 절)했으므로, 타입 차단은 성공하든 실패하든 런타임 에러라는 안전망이 항상 있음 — 아래 "제안"이 우려했던 "조용히 UB로 후퇴" 시나리오 자체가 발생하지 않음. 타입 차단이 Luau에서 실제로 가능한지는 여전히 미검증이지만, @@ -403,7 +404,7 @@ Luau 솔버가 막히면 되돌릴 범위가 `store-semantics.md`의 절반 이 **문제**: "가능하면 타입 시스템으로 아예 못 넣게 막을 것"이라 확정했지만, Luau 제네릭은 "T가 특정 타입이면 거부"하는 부정 제약을 기본 지원하지 -않는다. `store-semantics.md`는 이보다 단순한 `Source satisfies +않는다. `source-state-plan.md`는 이보다 단순한 `Source satisfies State` 조합조차 M0 스파이크 대상(솔버가 죽을 수 있음)으로 잡아뒀는데, 이보다 어려운 문제(제네릭 타입 파라미터 배제 제약)인 `State` 차단은 어디에도 검증 대상으로 언급되지 않는다. 실제로 안 되면 "UB, @@ -432,9 +433,15 @@ State` 조합조차 M0 스파이크 대상(솔버가 죽을 수 있음)으로 고려하라고 명시해뒀으니, 그 시점에 이 질문도 같이 열어 "래퍼 없음"이 구조적으로도 최종 확정인지 한 번 더 확인할 것. M1 스캐폴딩 전에. -### 2-4. existing-instance-bind가 Slot의 "엄격한 단일 마운트 소유권" 불변식과 근본적으로 긴장 +### 2-4. existing-instance-bind가 Slot의 "엄격한 단일 마운트 소유권" 불변식과 근본적으로 긴장 — **[해소됨, 2026-08-14 세션: 기능 자체가 기각]** -**위치**: `research/existing-instance-bind-plan.md` 전체 vs `base/ +> **[해소]** 이 긴장은 "재바인드를 어떻게 안전하게 할 것인가"를 풀어서가 +> 아니라 **그 기능이 통째로 기각**되어 없어졌음 +> (`archive/existing-instance-bind-rejected.md`) — 아래 분석은 그 기각 +> 판단을 뒷받침한 근거 중 하나로 보존. Slot 소유권 모델에 "흡수(adopt)" +> 개념을 추가하는 방향도 같이 폐기. + +**위치**: `archive/existing-instance-bind-rejected.md` 전체 vs `base/ slot-plan.md` "핵심 제약: 소유권 귀속과 단일 마운트". **문제**: 문서가 스스로 언급한 긴장(Modifier flatten의 clone 비용)과는 @@ -451,10 +458,10 @@ Slot의 "own"한 대상인지 아닌지가 완전히 미정. 문서는 "핸들 API"를 얹어야 할 때 Slot의 "own한 것만 CRUD 대상" 불변식 자체를 건드려야 할 수 있음. -**제안**: 착수 안 해도 되지만, "이 기능이 실제로 필요해지면 Slot의 -소유권 모델에 '흡수(adopt)' 개념을 추가해야 할 수도 있다"는 캐비엇을 -`existing-instance-bind-plan.md`에 한 줄 추가해둘 것 — 기존 "Modifier -flatten과 긴장" 캐비엇 옆에 병기. +**당시 제안(이제 불필요)**: "이 기능이 실제로 필요해지면 Slot의 +소유권 모델에 '흡수(adopt)' 개념을 추가해야 할 수도 있다"는 캐비엇을 그 +문서에 병기해둘 것 — **2026-08-14 기각으로 대체됨**(캐비엇 대신 기각 +사유가 `archive/existing-instance-bind-rejected.md` 상단 배너에 있음). ### 2-5. `Modifier.Overridden`(구 `Merge`) 시 서로 다른 클래스의 Modifier가 섞이는 게 허용되는지 — **런타임은 해소, 타입 레벨은 여전히 미정** @@ -466,7 +473,7 @@ flatten과 긴장" 캐비엇 옆에 병기. 계층을 모름 — 순수 데이터 merge 레이어"(1번 절) 원칙도 이미 있었으므로, **런타임 레벨에서는 대상 클래스가 다른 Modifier끼리 `Overridden`해도 막을 이유가 없음**(필드명만 보고 그대로 덮어쓸 뿐 — Luau 타입은 런타임에 -강제되지 않는다는 점은 `store-semantics.md`에도 이미 명시된 전제). +강제되지 않는다는 점은 `store-plan.md`에도 이미 명시된 전제). **여전히 미정인 것 — 타입 레벨**: Modifier가 target 클래스별 제네릭 타입(`Modifier` 등)이라면, `Modifier.Overridden(mod1: Modifier, @@ -606,10 +613,10 @@ M11 착수 시. 감싸야 하는 준비 비용이 오히려 더 크다는 게 사용자 반박 논거. `previous`는 `self`(입력)가 아니라 이 `:Compute` 호출 하나가 만든 결과 State 노드 자신에 귀속되므로 팬아웃 시에도 충돌 없음 — 상세는 -`base/bind-system-plan.md`의 "previous" 절 참고. 아래는 원래 발견 +`base/source-state-plan.md`의 "previous" 절 참고. 아래는 원래 발견 당시 기록. -**위치**: `base/bind-system-plan.md` "`:Compute(fn)`의 선택적 두 번째 +**위치**: `base/source-state-plan.md` "`:Compute(fn)`의 선택적 두 번째 인자 — `previous`" 절. **문제**: quad는 "렌더 함수가 계속 재실행되지 않고, `Compute`에 전달한 @@ -651,7 +658,7 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 ### 이미 고침 (이번 세션) - `architecture.md`의 "Store/State/Source 온톨로지 확정 요약" 절이 - `store-semantics.md`의 최신 재구성(Source가 State를 만족, `store.key`가 + `source-state-plan.md`의 최신 재구성(Source가 State를 만족, `store.key`가 Source를 직접 반환, `store.key:Set()`)을 못 따라가고 있던 것 — 이 감사 세션 도중 발견해 직접 정정(커밋 `4b839b0`에서 별도로 이미 반영됨을 뒤늦게 확인 — 같은 문제를 두 세션이 독립적으로 발견한 셈). @@ -679,7 +686,7 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 들어오는 것도, `State`처럼 Modifier가 State/Source *값*으로 담기는 것도 전부 `Brand` 기반 `isX` predicate로 런타임에 즉시 `error`(`base/modifier-plan.md` 4번/7번 절 정정, `base/ - store-semantics.md` "따름정리" 절). 더 이상 "한쪽만 방어" 비대칭이 + source-state-plan.md` "따름정리" 절). 더 이상 "한쪽만 방어" 비대칭이 아님 — 남은 차이는 `State` 쪽에 "되면 좋은 보너스"로 타입 차단을 추가 시도해볼 여지가 있다는 것뿐(위 2-2번, 미검증이지만 더 이상 필수 방어선이 아니라 우선순위 낮음). diff --git a/.claude/session/2026-08-14-07-store-source-split-shorthand-tween.md b/.claude/session/2026-08-14-07-store-source-split-shorthand-tween.md new file mode 100644 index 0000000..4e450cc --- /dev/null +++ b/.claude/session/2026-08-14-07-store-source-split-shorthand-tween.md @@ -0,0 +1,137 @@ +# 2026-08-14 일곱 번째 세션 — UI 숏핸드 Tween 지원, existing-instance-bind 기각, `bind-system-plan.md` 3단계 분할 + +사용자가 한 메시지에 세 건을 지시했고, 세 번째(문서 분할)에 대해서는 +"이 분리가 합당한가"를 먼저 판단해 달라고 요청함. + +## 1. `ui-shorthand-plan.md`에 Tween 지원 추가 + +**사용자 제안 원문 요지**: "ui-shorthand-plan 은 이제 Tween 을 지원하게 +되어야할듯. 이건 process 상 똑같이 활용하면 되는 부분으로 보임 → 프로세스 +중 inst 를 바꾸는것이 UB 는 아님. 키도 바뀔 수 있는거 처럼, 따라서 +인스턴스 메니징 생성 후 이것을 process 처리하는것으로 해결." + +확인 결과 지적이 정확했고, 오히려 **기존 서술이 stale**이었음: +`ui-shorthand-plan.md`의 store-bind 절이 "트윈처럼 애니메이션까지 지원할 +필요는 없음(API 표면만 복잡해짐)"이라고 못박아뒀는데, 그 판단의 전제는 +Tween이 **독립 Dispatch 핸들러**(우선순위를 다투는 특수 bind key)였던 +시절이었음. 2026-08-10 세션에 Tween이 값-레벨 래퍼 `Tween` + +`PropertyHandler` 내부 분기로 재설계되면서(`base/tween-plan.md`, +`archive/tween-special-bind-key-reversed.md`) 그 비용이 통째로 사라졌는데 +이 문서만 안 따라와 있었던 것 — 즉 "새 기능 추가"가 아니라 **역전 반영**. + +확정된 메커니즘: + +- 숏핸드 Handler의 `process`가 자식(`_quad_corner`류)을 찾거나 만든 뒤, + 프로퍼티를 **직접 대입하지 않고** `Dispatch.process(child, "CornerRadius", + ..., 1)`로 되돌려줌. +- `chains`가 `(inst,k)` 쌍으로 인덱싱되므로 "다른 `inst`로 위임"은 Dispatch + 입장에서 "다른 `k`로 위임"(Attribute 그룹이 이미 하는 것)과 **구조적으로 + 완전히 같은 일** — 사용자가 말한 "키도 바뀔 수 있는 것처럼"이 정확히 + 이것. 이 일반 규칙을 `base/dispatch-core-plan.md`의 "인덱스의 의미" 절에 + 새 불릿으로 명문화(위임 대상 자식의 **수명 책임은 위임한 핸들러**에 + 있다는 단서 포함). +- 이걸로 Tween 해석 코드가 `PropertyHandler` 하나에만 존재한다는 + `tween-plan.md`의 불변식이 유지됨 — 3-상태 릴레이션 슬롯, `Tween.Cancel`/ + `Tween.Finish` override 정책, "첫 세팅은 애니메이션 없이 즉시"가 전부 + `(child, prop)` 자리에서 그대로 재사용. + +**이 세션에 새로 발견해 채운 부품 하나** — 숏핸드는 스칼라를 자식 프로퍼티 +타입으로 감싸는 `wrap`(`UICorner = 8` → `CornerRadius = UDim.new(0,8)`)을 +갖고 있는데, `v`가 `Tween`면 그 변환을 **`Tween`을 벗기지 않고 +`.Value`에만** 적용해야 함. `table.clone` 후 `Value`만 갈아끼워 `Tween(opts)`로 +재브랜딩하는 `mapTweenValue(v, wrap)` 헬퍼로 해결(그 자체가 새 메커니즘은 +아니고, `Tween`가 immutable 값 객체라는 기존 성질의 사용). + +부수적으로 정리한 것: +- `UIPadding`처럼 자식 프로퍼티 **여러 개**에 같은 값을 쓰는 키는 프로퍼티마다 + `Dispatch.process`를 따로 부름 → 각자 독립 체인, 트윈 슬롯도 따로. + 열린 질문 절의 룩업 테이블 스케치도 `Property` → `Properties`(목록)로 정정. +- 캐비엇: 자식이 파괴/재생성되는 사이클에서는 `prev == nil`(첫 세팅) 규칙 + 때문에 트윈이 안 걸리고 스냅됨 — 버그가 아니라 그 규칙이 막으려는 진입 + 애니메이션과 정확히 같은 상황이라 의도된 동작으로 문서화. +- **ROADMAP에 UI 숏핸드 항목 자체가 없던 갭 발견** — `ui-shorthand-plan.md`는 + "M10 전후로 구현" 이라고 이미 지정해뒀는데 체크리스트엔 한 줄도 없었음. + M10 끝에 구현 포인트 6개와 함께 신규 추가. + +## 2. `existing-instance-bind-plan.md` 기각 → `archive/` + +**사용자 판단**: "이게 가능하다 하면 offset source 나 length source 등을 +밀고 당기고 하는 많은 부가적 작용을 가능케 하고, 버그를 일으키기에 치명적 +표면을 많이 노출시킴." + +오래 "미지원으로 확정하지는 않고 열린 가능성으로 유지"였던 항목 — +`archive/existing-instance-bind-rejected.md`로 이전하고 기각 사유 배너를 +달았음. 배너에 같이 적어둔 것: quad가 만들지 않은 Instance의 자식 구성 +변화까지 추적하려면 Instance 가상화가 필요한데, 그건 +`research/framework-comparison-findings.md`가 use-after-destroy 안전망을 +기각할 때 쓴 것과 같은 이유(rbvm 같은 전문 라이브러리의 영역)로 스코프 밖. + +**파생 정리(체크리스트 2번 — 배너가 부정하는 본문을 같은 커밋에서 고칠 것)**: +경로만 바꾸면 되는 게 아니라 "열려 있음"을 전제로 쓰인 문장이 여러 곳에 +있었음. +- `base/architecture.md` — "아직 미정(research/로 분리됨)" 절에 남아있던 + **유일한 항목**이 이거였음 → 절 자체를 "이제 아키텍처를 미정으로 남기는 + 항목은 없음"으로 갱신. 소스 트리 절의 "여전히 research/에 남아있고"도 정정. +- `base/ref-plan.md` — PreRef fire를 `flatten`에 얹지 않기로 한 근거가 "재바인드 + 때문에 flatten이 여러 번 재호출될 수 있어서"였음 → 그 위험은 사라졌지만 + 결론(기각)은 유지되므로 근거를 "flatten은 `inst`를 모르는 순수 변환이라"로 + 교체. +- `base/relate-plan.md` — "quad 밖 Instance를 `Relate` 키로 쓰는 건 UB" 따름 + 정리는 유지하되, 그 UB를 실제로 건드릴 뻔한 유일한 경로가 닫혔음을 명시. +- `base/modifier-plan.md`(Default→실값 flatten 미정 문제), `base/dispatch-core-plan.md` + (인덱스 체인의 "미래 재바인드에 유리" 부수 효과 서술 — quad-debug/자기 + 인스턴스 재발행 근거만 남기고 재프레이밍), `research/pre-implementation-audit.md` + 2-4(Slot 단일 마운트 소유권과의 긴장 → "기능이 기각되어 해소"), + `question.md`(항목 제거) + `archive/question-resolved.md`(해소 기록), + `ROADMAP.md` 백로그(항목 제거), `CLAUDE.md` 2곳. + +## 3. `bind-system-plan.md` 3단계 분할 + `store-semantics.md` 흡수 + +**사용자 지시**: "bind 쪽에서 state 를 분리해야한다고 생각함. +store-semantics.md bind-system-plan.md 둘에서 정보를 추합해서 문서정리를 +수행해. Store가 Store를 저장 가능한가 → 이런것 같은건, store-plan.md +문서로 합쳐 신설. 이젠 Store 는 source 를 여럿 담고, 없으면 만들어주는 +도구일 뿐이라, 쉽게 분리가능. state/source 는 아주 가까운 요소라 +source-state-plan.md 로 저장하면 될것 같음. 이 분리가 합당한거 같아?" + +**판단: 합당함.** 근거 셋 — +1. 두 문서가 실제로 같은 주제를 반씩 나눠 갖고 **서로를 "상세는 저쪽 참고"로 + 가리키는 핑퐁**이 여러 곳에 있었음(온톨로지, `store.key`가 Source를 반환, + `:Set()` 전환, Slot 생존 확인이 각각 양쪽에 반씩). 어느 쪽이 정본인지 + 문장마다 달라서 읽는 사람이 매번 두 파일을 왕복해야 했음. +2. Store가 "이름 붙은 Source 모음, 그 이상 아님"으로 좁혀진 게 2026-08-06에 + 이미 확정 — 사용자 말대로 **분리 가능해진 지 오래**였고, 남은 Store 고유 + 내용(부작용 정책, `defaults` 템플릿, eager/lazy 생성, dot-access 타이핑, + `type function`, "Store가 Store를 담는가")은 반응형 코어와 의존 관계가 거의 + 없음. +3. `Source`⊇`State`는 구조적 서브타입이라 **한 파일이 맞음** — 쪼개면 + `:With`/`:Compute` 반환 타입 설명이 두 파일에 중복됨. + +**한 가지 캐비엇을 같이 보고함**: 분할 후 `bind-system-plan.md`에 남는 건 +인스턴스 생성/이벤트 네이밍 인체공학 + 팩토리 주입 + 색인뿐이라(1238줄 → +203줄) 파일 이름이 내용보다 넓어짐. 리네임은 참조 churn이 커서 **이번엔 안 +하고** 제목만 "인스턴스 생성·이벤트 네이밍 인체공학 + 분할 색인"으로 바꿔둠. + +결과 줄 수: `bind-system-plan.md` 1238→203, `store-semantics.md` 346→삭제, +`source-state-plan.md` 1148 신설, `store-plan.md` 224 신설(합 1584→1575, +사실상 순수 이동). + +### 참조 스윕 + +`store-semantics.md`가 **`doc-check.py`의 `OURS` 패턴에 안 걸리는 이름** +(`-plan`/`-reversed` 등으로 안 끝남)이라, 삭제해도 ERROR가 아니라 +"외부 문서명일 수 있음" WARN으로만 잡히는 걸 발견 — 그래서 ERROR 목록만 +믿지 말고 `grep`으로 전수(라이브 문서 40여 곳)를 직접 훑어 고침. 절 참조는 +스크립트를 하나 짜서 "옛 파일에 없고 새 파일에 있는 절 제목"을 자동으로 +재지정하고, 의역 인용 9건만 손으로 정리. + +최종 `doc-check.py`: **ERROR 0, WARN 85**(작업 전 101 — 분할로 새로 생긴 +것을 다 닫고, 겸사겸사 기존 의역 WARN 몇 개도 같이 정리해서 오히려 줄었음). + +### 동시 세션 주의 + +`.claude/worktrees/debounce-throttle-plan/`에 다른 세션의 워크트리가 +`.claude/` 전체 복사본을 갖고 있음 — 이번 분할이 그쪽 사본에는 반영되지 +않았으므로, 그 세션이 메인에 병합할 때 `store-semantics.md`/ +`bind-system-plan.md`를 가리키는 참조가 되살아날 수 있음. 병합 시 +`doc-check.py`를 반드시 다시 돌릴 것. diff --git a/CLAUDE.md b/CLAUDE.md index 6965826..1ac0e8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 이전에 시도했다 폐기한 v2 재작성 시도(`.claude/initreq/quad2-try`)도 리서치 완료 — OOP 상속/커스텀 파서/Slot 스텁/`Pipe` copy-on-write 절충안은 확인된 -죽은 접근이라 반복 조사 금지(`base/bind-system-plan.md` 참고). +죽은 접근이라 반복 조사 금지(`base/bind-system-plan.md` "확정된 것" 절 참고). ## 계획 문서 구조 @@ -42,13 +42,14 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 base 문서가 근거로 인용하는 온디맨드 참고 자료(v1 내부 동작 스냅샷, Fusion/Vide 비교 리서치) — 항상 읽을 필요는 없고 인용될 때만 열어볼 것. - `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. - `existing-instance-bind-plan.md`/`debug-tooling-plan.md`/ + `debug-tooling-plan.md`/ `documentation-plan.md`/`documentation-content-map.md`/ `framework-comparison-findings.md`/`additional-primitives-plan.md`(2026-08-09 세 번째 세션에 마지막 열린 항목까지 전부 해소, 이제 배경 자료용)/ `pre-implementation-audit.md`/`v1-compat-plan.md` — 전부 후순위(`tween-plan.md`는 2026-08-12 세션에 마지막 열린 항목까지 - 전부 해소돼 `base/`로 승격, 더 이상 여기 없음). 최신 목록·우선순위는 + 전부 해소돼 `base/`로 승격, 이미 생성된 인스턴스 재바인드는 + 2026-08-14 세션에 기각돼 `archive/existing-instance-bind-rejected.md`로 이전, 더 이상 여기 없음). 최신 목록·우선순위는 `.claude/README.md`가 소스, 여기서 개수 반복 안 함(과거에 "두 개뿐"이라 적어놨다가 새 문서 추가될 때마다 안 갱신되는 패턴이 반복돼서 아예 안 세기로 함). @@ -230,8 +231,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 `canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지 방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/ `Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위). -3. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만 - 필요, 구현 착수를 막지 않음. +3. **[2026-08-14 세션에 해소]** 오래 열려 있던 "이미 생성된 인스턴스 + 재바인드"는 **기각**되어 `archive/existing-instance-bind-rejected.md`로 + 이전됨 — 더 이상 상의할 스코프 항목이 아님. 4. **[백로그]** 범용 렌더 디버깅 도구 `quad-mock`(Tween mock 등 동적 동작 지원, M0 mock 테스트 하네스와는 별개), 런타임 디버깅 플러그인 `quad-debug`(Studio 플러그인, 실물 Instance→코드 위치 역추적 — 채널 @@ -946,7 +948,7 @@ Slot/Attribute)의 private `Relate` 상태 저장소가 대거 줄어듦(process 반환하는 클로저가 upvalue로 직접 캡처하므로 process→retract 사이 단발성 handoff용 저장이 불필요해짐 — `Relate`는 여러 위치/사이클을 가로지르는 누적 상태에만 남음). `bind-system-plan.md`/`tag-plan.md`/ -`attribute-plan.md`/`slot-plan.md`/`architecture.md`/`store-semantics.md`/ +`attribute-plan.md`/`slot-plan.md`/`architecture.md`/store-semantics.md(현 `store-plan.md`/`source-state-plan.md`)/ `modifier-plan.md` 전부 반영, `archive/checkpoint-handler-pattern-reversed.md` 신설. @@ -1234,3 +1236,33 @@ PreRef pre-pass 한 스윕에서 `isPostRef`도 같이 소진해 `postRefList` 잠시 보류했다가, 그 세션이 정리된 뒤 이 항목을 원래 자리(세 번째)에서 지금 자리(여섯 번째)로 옮기고 번호를 재조정 — 동시 편집 충돌 시 "내용은 안 섞여도 순서/번호가 꼬일 수 있다"는 사례로 남김. + +**2026-08-14 일곱 번째 세션 — UI 숏핸드 Tween 지원, existing-instance-bind +기각, `bind-system-plan.md` 3단계 분할(`store-plan`/`source-state-plan` 신설)** +(`session/2026-08-14-07-store-source-split-shorthand-tween.md`) +사용자가 한 메시지로 세 건 지시. (1) **UI 숏핸드 Tween 지원** — 새 기능 +추가가 아니라 **역전 반영**이었음(`ui-shorthand-plan.md`가 "트윈까지 지원할 필요 없음"이라 못박아둔 것은 Tween이 아직 독립 Dispatch 핸들러이던 시절 판단인데 2026-08-10 +값-레벨 래퍼 재설계를 안 따라와 있었음). 확정 메커니즘은 사용자 제안 그대로 +— 숏핸드가 자식을 만들거나 찾은 뒤 프로퍼티를 **직접 대입하지 않고** +`Dispatch.process(child, prop, ..., 1)`로 위임하면 Tween이 공짜로 따라옴 +(해석 코드는 `PropertyHandler` 하나에만 남는 불변식 유지). "process 중 +`inst`를 바꾸는 것은 키를 바꾸는 것과 같은 층위라 UB 아님"을 +`dispatch-core-plan.md`에 일반 규칙으로 명문화(위임한 자식의 수명 책임은 +위임한 핸들러). 새로 필요한 부품은 스칼라→프로퍼티 `wrap`을 `Tween.Value` +에만 적용되도록 들어올리는 헬퍼 하나뿐. **ROADMAP M10에 UI 숏핸드 항목이 +통째로 빠져 있던 갭도 발견·보강.** (2) **`existing-instance-bind` 기각** — +"열린 가능성"에서 미지원 확정으로, `archive/existing-instance-bind-rejected.md` +(사유: Length/Offset 등 quad가 만든 트리를 전제한 부기를 바깥에서 밀고 +당기는 버그 표면이 치명적으로 넓어짐). "열려 있음"을 전제로 쓰인 본문 +문장 7곳(특히 `architecture.md`의 "아직 미정" 절 — 유일 항목이었음, +`ref-plan.md`의 flatten 기각 근거)까지 같은 커밋에서 정정. +(3) **문서 분할** — 사용자가 합당성 판단을 먼저 요청했고, 두 문서(`bind-system-plan.md` + store-semantics.md)가 같은 +주제를 반씩 나눠 갖고 서로를 "상세는 저쪽" 핑퐁하던 게 실재해 **합당하다고 +판단 후 수행**: `base/store-plan.md`(Store=이름 붙은 Source 모음)와 +`base/source-state-plan.md`(반응형 코어) 신설, store-semantics.md는 완전 +흡수되어 삭제, `bind-system-plan.md`는 1238→203줄(인스턴스 생성·이벤트 +네이밍 + 분할 색인만). 캐비엇으로 "남은 내용보다 파일 이름이 넓어졌지만 +리네임 churn이 커서 이번엔 제목만 변경"을 보고. **교훈** — `doc-check.py`의 +`OURS` 패턴이 `-plan`류 접미사 기준이라 store-semantics.md 같은 이름은 +삭제해도 ERROR가 아니라 WARN으로만 잡힘, 그래서 ERROR 목록만 믿지 말고 +grep 전수를 같이 돌려야 함. 최종 ERROR 0 / WARN 85(작업 전 101). diff --git a/ROADMAP.md b/ROADMAP.md index cb499b6..878961e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,7 +34,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute(self: Source, ...) -> State`류, self 타이핑 + State 참조 혼합)이 Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션, - `base/store-semantics.md` "Source가 State를 만족함" 절 — `State`가 + `base/source-state-plan.md` "Source가 State를 만족함" 절 — `State`가 `Source`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는 피할 수 있어 보이나 실제 검증 전엔 확정 아님) - [ ] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로 @@ -240,7 +240,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 타이핑" 절) — 실제 문법이 통과하는지는 `luau-test`의 `16-type-store-key-typefunction.luau`로 실측 필요 - [ ] `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar - (2026-08-11 세션, `base/bind-system-plan.md` "`:Compute(fn, ...)`" + (2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`" 절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드 자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M3 스파이크에서 확인. `Effect`/`Observer`는 대칭 sugar 없이 `:With` 명시 @@ -249,7 +249,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 previous?, dep1, ..., depN)` — 순서는 Luau 값 레벨 `...`가 파라미터 리스트 맨 끝이어야 하는 것과 같은 이유로 `previous?`가 deps 팩 **앞**에 와야 함, 2026-08-11 후속 세션 제안 → 같은 날 세 번째 - 세션에 순서 정정, `base/bind-system-plan.md` "trailing deps를 fn에 + 세션에 순서 정정, `base/source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절) — 방향/순서는 확정, `luau-test`의 `15-type-compute-trailing-deps-typepack.luau`로 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 @@ -257,7 +257,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) -- [ ] `state:Apply(factory)`(`base/bind-system-plan.md` "`state:Apply(factory)`" +- [ ] `state:Apply(factory)`(`base/source-state-plan.md` "`state:Apply(factory)`" 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로 부르는 순수 설탕, `factory: (State) -> U): U`로 열린 타입. Source도 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함 @@ -273,8 +273,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션) - [ ] Observer/Effect 이중 바인딩 금지 — `canExecute(value)` 게이트로 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도 - 내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/ - bind-system-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 + 내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/source-state-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션 신설, 2026-08-09 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정 — 진짜 독립 경로는 둘뿐). **[역전, 2026-08-14 다섯 번째 세션] 별도 predicate `canBound(handle)` @@ -681,6 +680,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 불필요(클로저가 `v`를 직접 캡처). `HANDLER_PRIORITY_FALLBACK`으로 등록 — 2026-08-12 열한 번째 / 2026-08-13 네·다섯·열네 번째 세션, `base/tag-plan.md`) +- [ ] **[2026-08-14 세션에 누락 발견, 신규]** `quad-roblox/Handlers/ + InstanceShorthand.luau` — UI 편의 숏핸드 `UICorner`/`UIPadding` + (+`UIPaddingOffset`)/`UIScale`(`base/ui-shorthand-plan.md`). 이 + 마일스톤 전후로 구현하기로 그 문서가 이미 지정해뒀는데 체크리스트에 + 항목 자체가 없었음. 구현 포인트: (a) 재사용 대상은 quad가 만든 고정 + 이름(`_quad_corner`류) 자식으로 한정, (b) `v == nil`이면 그 자식 제거, + (c) **자식 프로퍼티는 직접 대입하지 말고 `Dispatch.process(child, + prop, wrapped, 1)`로 위임** — 이걸로 Tween이 공짜로 따라옴(해석은 + `PropertyHandler` 하나에만 남음), (d) 스칼라→프로퍼티 타입 `wrap`은 + `Tween`의 `.Value`에만 적용되도록 들어올릴 것, (e) `UIPadding`은 + 자식 프로퍼티 4개에 각각 위임, (f) 자식을 없앨 때 `retractFrom(child, + prop, 1)`도 같이. M11(Tween) 이후에 하면 (c)~(d)를 바로 검증 가능 ## M11 — Tween @@ -728,8 +739,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## 백로그 (스코프 밖 — 필요성이 실제로 드러나면 그때 설계) -- [ ] `research/existing-instance-bind-plan.md` — Modifier 정적 flatten과 - 긴장 관계 있음, 재검토 시 그 문서부터 다시 볼 것 - [ ] 범용 렌더 디버깅 도구로서의 quad-mock(Tween mock 등 동적 동작 포함, M1의 quad-base 테스트용 mock과는 별개) - [ ] `quad-debug`/`quad-debug-roblox-plugin` — 실물 Instance→코드 위치