diff --git a/.claude/README.md b/.claude/README.md index 3759b50..fc9fad3 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -31,7 +31,7 @@ | `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` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.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에 재사용하면 충돌) | +| `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를 저장 가능한가" 절도 정정 | | `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/bind-system-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 생존) | | `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 우선순위를 쓰는 이유) | @@ -65,7 +65,7 @@ | `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 못 고치는 트레이드오프(의도된 설계) 정리. **[2026-08-12 열여덟 번째 세션]** "고칠 만한 것" 절에 남아있던 마지막 두 항목(use-after-destroy 검증 안전망 부재, `:With` 동적 의존성 미지원)도 사용자가 "고칠 필요 없음"으로 최종 판단해 3번 절(못 고치는 트레이드오프)로 이전 — 2번 절은 이제 해소된 항목만 남음 | 하 — 배경 자료, 더 이상 사용자 판단 대기 상태 아님 | | `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 | -| `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리, `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 | +| `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리, `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | ## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 23c123c..873adfb 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -443,6 +443,11 @@ function Dispatch.process(inst, k, v) local h = Dispatch.getHandler(inst, k, v) if h then local list = chains:GetStrong(inst, k) or {} + for _, existing in list do + if existing == h then + error("Dispatch: handler already active for this (inst,k) — re-entrant dispatch (e.g. State>) is not supported") + end + end table.insert(list, h) -- 항상 꼬리에 추가 chains:SetStrong(inst, k, list) h.process(inst, k, v) @@ -527,6 +532,29 @@ nil`(and/or 삼항 관용구)이었으나, `v`가 `false`일 때(정당한 boole 만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖, 2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과 같은 결로 UB 취급. +- **[2026-08-13 세션 발견·수정] "각 핸들러는 최대 한 번씩만 그 키에서 + 호출됨" 전제는 순환뿐 아니라 `State>`(store가 emit하는 값 + 자체가 또 State/Source인 경우)에서도 깨짐 — 단 순환과 달리 + 스택오버플로로 걸러지지 않고 **조용히 체인을 파손시키는 실제 버그로 + 재현됨**. `store.key = a`(State), `a:Get() = b`(State)일 때: (1) + `Dispatch.process(inst,k,a)`가 StoreBind를 매치해 `list={SB}` 생성, + `Observer` 즉시 1회 실행이 `Dispatch.process(inst,k,b)`로 재귀; (2) `b`도 + State라 StoreBind가 **같은 핸들러 객체로 또 매치**돼 `list={SB,SB}`가 + 되고, 안쪽 Observer가 `relate:SetStrong(inst,k,·)`로 바깥 Observer의 + 참조를 덮어써 바깥 것이 `bindLifetime`엔 살아있는데 `relate`에서 못 + 찾는 유령 구독으로 새고; (3) 안쪽 Observer의 즉시 실행이 부르는 + `retractUnder(inst,k,SB,·)`는 위 457행 루프가 **첫 번째**로 매치되는 + `SB`(바깥 것의 인덱스)를 `cutoff`로 잡아버려, 그 다음 인덱스(안쪽 + 자기 자신)를 대신 retract — **안쪽 State의 구독이 등록 직후 자기 + 자신에 의해 끊겨 이후 `b:Set(...)`이 조용히 무시됨.** Attribute의 + `rawNew(name)` 위임은 별개의 `(inst, key)` 체인으로 옮겨가는 것뿐이라 + 이 문제를 원천적으로 피하지 못함(위임된 값 자체가 `State>`이면 + `AttributeKey` 체인 안에서 동일하게 재현). **고정**: 위 `Dispatch.process` + pseudocode에 "같은 `(inst,k)`에 이미 같은 핸들러 객체가 push돼 있으면 + 즉시 error" 가드를 추가 — 순환과 같은 전제(핸들러당 최대 1회)를 지키는 + 가장 저비용 지점이 여기(push 직전)라서. `State>`가 정당한 값 + 모양인지 자체는 별도 논의(아래 "Store가 Store를 저장 가능한가" 참고) — + 이 가드는 그 논의와 무관하게 체인 파손을 조용히 넘기지 않게만 함. - **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에 중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰 미래의 재바인드는 `Dispatch.retractUnder(inst, k, nil, newV); @@ -808,9 +836,13 @@ parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을 다른 핸들러와 동일한 `isHandlable`/`priority`/`process`/`retract` 계약을 따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨. -이러면 store 값 자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) -상관없이 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 -가능한가"와 직결. +이러면 store 값 자체가 대부분의 타입(원시값, 인스턴스 등)에 대해 동일한 +재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 +직결. **[2026-08-13 세션 정정]** 단 "심지어 다른 store"는 낙관적으로 +틀린 서술이었음 — 실제로 값이 또 State/Source면(`State>`) 같은 +핸들러가 같은 `(inst,k)`에 두 번 push되어 체인이 파손됨(위 "확정된 +디스패치 모델" 절의 2026-08-13 발견 항목 참고), 이제 `Dispatch.process`의 +중복 핸들러 가드가 이 경우 error로 막음. **"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로 확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만 @@ -877,6 +909,14 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의 보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음. +**[2026-08-13 세션, 스코프 명확화]** 이 절은 "Store *필드*가 Store/State를 +담는가"(예: `store.a = otherStore`) 얘기이고, "State가 *emit하는 값*이 +State/Source인가"(`State>`, 예: `store.key`에 대입된 값 자체가 +State)는 다른 축 — 후자는 위 "확정된 디스패치 모델" 절에서 실제 체인 +파손 버그로 확인됨. 이 절의 "별도로 신경 쓰지 않음"은 전자에만 해당하고, +후자는 이제 `Dispatch.process`가 명시적으로 error하도록 막혀 있어 "신경 +안 씀 = 조용히 UB"가 아니라 "신경 안 씀 = 즉시 실패"로 취급됨. + ## Ref — 도입 확정, 단 용도는 재정의됨 **중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을 diff --git a/.claude/luau-test/04-dispatch-chain-retractUnder.luau b/.claude/luau-test/04-dispatch-chain-retractUnder.luau index 9bf5128..f556269 100644 --- a/.claude/luau-test/04-dispatch-chain-retractUnder.luau +++ b/.claude/luau-test/04-dispatch-chain-retractUnder.luau @@ -26,6 +26,24 @@ `bindLifetime`/`unbindLifetime` 조합(`.claude/base/lifecycle-pattern.md`)을 쓴다. `retract`가 할 일이 "구독 해제"라는 본질은 같아서 체인/ retractUnder 로직 검증엔 영향 없음. + + **[2026-08-13 세션 정정]** 바로 위 문단의 마지막 문장은 3단계~4단계 + (store-in-store, 즉 `State>`에 대응하는 시나리오)에 대해서는 + 틀렸음이 드러남 — 이 스파이크의 `retract`는 print만 하는 완전 no-op이라 + "자기 자신을 스스로 retract하는" 버그가 실제로 일어나도 아무 부작용 + 없이 넘어가버려서, 겉보기엔(체인 길이만 보면) 3~4단계가 정상으로 + 보임. 실제로는 같은 싱글톤 `StoreBindA` 핸들러가 같은 `(inst,k)`에 + 두 번 push되고(`list={StoreBindA,StoreBindA}`), `retractUnder`의 + 457행 "첫 매치 cutoff" 로직이 안쪽 재계산 시점에도 항상 **바깥쪽** + 인덱스를 cutoff로 잡아버려 안쪽 자기 자신이 잘못 retract되는 게 + `.claude/base/bind-system-plan.md` "확정된 디스패치 모델" 절 + 2026-08-13 항목에서 손으로 트레이싱해 확인됨 — 실제 `bindLifetime` + 기반 구현이었다면 안쪽 State 구독이 등록 직후 끊겨 이후 갱신이 + 조용히 무시됐을 것. 이 파일이 "다단 체인이 정상"이라고 결론 내린 건 + 이 스텁의 사각지대 때문이었지 실제로 검증된 게 아니었음 — 그래서 + 아래에 같은 `(inst,k)`에 같은 핸들러가 중복 push되면 즉시 error하는 + 가드(같은 세션에 base 문서 pseudocode에도 추가됨)를 넣고, 3단계에서 + 이 가드가 실제로 걸리는지 확인하는 걸로 대체. ]] local StoreTag = {} @@ -84,6 +102,21 @@ function Dispatch.process(inst, k, v) return end local list = chainFor(inst, k) + -- [2026-08-13 세션 신설] 같은 (inst,k)에 같은 핸들러 객체가 이미 체인에 + -- 있으면 error — State>류 재진입 디스패치를 조용히 깨지게 + -- 두지 않고 즉시 실패시킴 (base/bind-system-plan.md 동시 반영) + for _, existing in list do + if existing == h then + error( + string.format( + "Dispatch: handler '%s' already active for %s.%s — re-entrant dispatch (e.g. State>) is not supported", + h.name, + tostring(inst), + tostring(k) + ) + ) + end + end table.insert(list, h) print(string.format(" [chain push] %s.%s <- %s (체인 길이=%d)", tostring(inst), tostring(k), h.name, #list)) h.process(inst, k, v) @@ -176,15 +209,26 @@ storeA:set("world") print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개여야 정상)") print() -print("=== 3단계: StoreA 값을 store로 다시 바꿔서(다단 체인 유도) 스트레스 테스트 ===") +print("=== 3단계 [2026-08-13 세션 재작성]: StoreA 값을 store로 다시 바꿔서") +print(" (State>에 대응) 재진입 디스패치를 유도 — 이제 가드가") +print(" 즉시 error해야 정상 ===") local storeB = makeStore("nested") -storeA:set(storeB) -print(" 현재 체인 길이:", #chainFor("Frame1", "Text")) +local ok, err = pcall(function() + storeA:set(storeB) +end) +print(" error로 막혔는가:", not ok) +print(" 에러 메시지:", tostring(err)) +print( + " 현재 체인 길이:", + #chainFor("Frame1", "Text"), + "(가드가 push 전에 error하므로 1이어야 정상 — StoreBindA만 남고 오염 없음)" +) print() -print("=== 4단계: 안쪽 StoreB 값을 바꿔서, retractUnder(keep=StoreBindA 자신)가") -print(" 바깥 A는 안 건드리고 그 밑(B 이후)만 정리하는지 확인 ===") -storeB:set("nested-changed") +print("=== 4단계: 가드가 걸린 뒤에도 같은 자리에 정상 값으로 다시 바인드하면") +print(" 문제없이 복구되는가(에러가 체인을 영구 오염시키지 않는가) ===") +storeA:set(42) +print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개로 정상 복구되어야 함)") --[[ 확인 포인트 (이게 이 파일의 핵심 목적): @@ -192,10 +236,18 @@ storeB:set("nested-changed") 돌아오는가 — retractUnder가 이전 PropertyHandler를 정리하고 새로 push했는가, 아니면 계속 누적돼서 체인이 무한정 길어지는가? (누적되면 버그 — 체인이 GC 안 되는 메모리 누수이자 논리 오류) - 2. 3단계~4단계에서 A(StoreBindA) 자신은 살아남고, 그 밑(구 PropertyHandler - 또는 구 중첩 핸들러)만 정리되는가 — "A가 자길 엉뚱하게 retract하는" - 버그(CLAUDE.md가 기각한 1차 설계의 실패 모드)가 재현되지 않는가? - 3. 체인 길이가 각 단계마다 예상한 값과 정확히 일치하는가(주석에 적어둔 - 기대값과 실제 print 결과를 비교). + 2. **[2026-08-13 세션 정정]** 3단계는 원래 "A(StoreBindA) 자신은 살아남고 + 그 밑만 정리되는가"를 확인하려 했으나, 이 스파이크의 `retract`가 + print만 하는 no-op이라 실제 자기-retract 버그(같은 싱글톤 핸들러가 + 같은 (inst,k)에 중복 push되고, retractUnder의 첫-매치 cutoff가 안쪽 + 재계산 시점에 바깥쪽 인덱스를 잡아 안쪽이 스스로를 retract하는 것 — + 상세 트레이싱은 `.claude/base/bind-system-plan.md` "확정된 디스패치 + 모델" 절 2026-08-13 항목)를 이 스텁으로는 절대 못 잡는다는 게 드러남. + 그래서 이제 3단계는 "같은 핸들러 중복 push를 Dispatch.process가 + push 전에 잡아 즉시 error하는가"를 확인 — `ok`가 `false`고 체인 + 길이가 오염 없이 1로 남는 게 정상. + 3. 4단계: 가드가 발동한 뒤에도 같은 (inst,k)에 정상적으로 새 값을 + 바인드할 수 있는가(에러가 chains 자료구조를 영구히 깨뜨리지 + 않는가) — 체인 길이가 다시 2(A, Property)로 정상 복구돼야 함. 4. 스택 오버플로 없이 전부 정상 종료되는가. ]] diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index a2b515c..294b998 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -49,7 +49,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `bind-system-plan.md` "props 순회 순서", ROADMAP M0-4 | | `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | | `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `bind-system-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | -| `04-dispatch-chain-retractUnder.luau` | `Dispatch` 체인 + `retractUnder`가 다단(A→B→C) 재-dispatch에서 정확한지 | `bind-system-plan.md` "Dispatch 체인", 2026-08-08 세 번째 세션 | +| `04-dispatch-chain-retractUnder.luau` | `Dispatch` 체인 + `retractUnder`가 다단(A→B→C) 재-dispatch에서 정확한지, **[2026-08-13 재작성]** 같은 `(inst,k)`에 같은 핸들러가 중복 push되는 경우(`State>`류) `Dispatch.process`의 신규 가드가 즉시 error하고 이후 정상 복구되는지 | `bind-system-plan.md` "Dispatch 체인" + "확정된 디스패치 모델" 2026-08-13 항목, 2026-08-08 세 번째 세션 / 2026-08-13 세션 | | `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 착수 시 실측 확인" | @@ -154,6 +154,19 @@ Luau로 재현해본 적이 없었던 갭 — `Slot`의 `kSlotMap`/`slotOwner` G "retract 완전 no-op" 재정정 등)는 단순 분기/타입 정리라 스파이크 불필요로 판단, 추가 안 함. +**10차 (2026-08-13)**: 사용자가 Haskell Monad/Applicative 리서치 중 +`retractUnder`의 꼬리부터-cutoff 로직을 직접 되짚다가 "같은 키에서 핸들러가 +재사용되면 문제 아닌가"를 제기 — 손으로 트레이싱해 `State>`(store가 +emit하는 값 자체가 또 State/Source)가 실제로 체인을 파손시킴을 확인 +(`bind-system-plan.md` "확정된 디스패치 모델" 절 신규 항목). 기존 `04`가 +정확히 이 시나리오(store-in-store)를 이미 스트레스 테스트로 다루고 있었지만, +`retract`가 print만 하는 no-op 스텁이라 자기-retract 버그의 실제 증상(구독이 +조용히 끊기는 것)을 절대 드러낼 수 없었다는 사각지대도 같이 발견 — +`Dispatch.process`에 중복 핸들러 가드(push 전 체인에 같은 객체가 있으면 +error)를 추가하고, `04`의 3~4단계를 "가드가 실제로 걸리는지 + 걸린 뒤에도 +정상 복구되는지" 확인으로 재작성. `operator-sugar-plan.md`엔 별개로 nil 대체 +콤비네이터 `Alternative` 후보를 신설(카탈로그에 이전엔 없었음). + ## 결과 확인 후 할 일 각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면 @@ -171,6 +184,10 @@ Luau로 재현해본 적이 없었던 갭 — `Slot`의 `kSlotMap`/`slotOwner` G gcconn-trick-verification.md`), 재확인 불필요. A-2(재-bindLifetime 허용 여부)가 실패하면 `canBound`/`unbindLifetime` 설계 자체를 재검토해야 함 — **이건 아직 미확인, 공식 `10` 파일로 꼭 돌려볼 것.** +- `04`는 3단계에서 `error로 막혔는가: true`, 4단계에서 체인 길이가 2로 + 복구되는 게 기대값 — 만약 3단계가 error 없이 통과해버리면(`ok == true`) + 가드 pseudocode 자체가 어딘가 안 맞는 것이니 바로 알려줄 것(체인 파손이 + 다시 조용히 넘어가는 회귀). - `11`은 전부 PASS가 기대값 — FAIL이 하나라도 있으면 어느 케이스인지 그대로 알려줄 것(특히 "변환 함수가 반환한 값" 케이스는 놓치기 쉬운 경로라 실제 구현에서도 잘 짜였는지 중요한 신호). diff --git a/.claude/question.md b/.claude/question.md index 2f19105..4800c9a 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -240,8 +240,10 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** `Clamp`/`Min`/`Max`는 선례가 강해 추가 후보로, Debounce/Throttle은 업계에 흔하지만 `Blocker`와는 다른 시간 기반 메커니즘이라 이 카탈로그가 아니라 quad-roblox 쪽 별도 프리미티브로 다룰지 판단이 필요한 별개 질문으로 - 분리됨. 상세는 `research/operator-sugar-plan.md`. 구현 자체는 맨 마지막 - 우선순위(순수 슈가, 없어도 무방) — 여전함. + 분리됨. **[2026-08-13 세션 신설]** `Alternative`(nil 대체값, coalesce/`??`/ + 엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정 + 규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`. + 구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함. - `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 착수를 막지 않음. - **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** — diff --git a/.claude/research/operator-sugar-plan.md b/.claude/research/operator-sugar-plan.md index 998b760..4e10cbd 100644 --- a/.claude/research/operator-sugar-plan.md +++ b/.claude/research/operator-sugar-plan.md @@ -209,6 +209,16 @@ introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어 `useClamp`/`useMax`/`useMin`, Ramda `R.clamp` 등 리액티브 파생값 콤비네이터로서의 선례가 뚜렷함. 기존 "산술" 뭉치보다 오히려 이쪽이 선례가 강해 별도 그룹으로 추가할 후보. +- **[2026-08-13 세션 신설]** `Alternative`(nil 대체값 — Haskell + `Alternative`/`<|>`, 흔히 coalesce/`??`/엘비스 연산자라고도 부름) — + `State:Apply(Alternative(default))`처럼 값이 nil이면 기본값으로 + 치환하는 콤비네이터. 지금까지 카탈로그에 없던 게 확인됨(전수 grep 결과). + 확정 규칙(모든 `Operator.*`는 `factory(self) -> State` + `:Apply`)과 + 그대로 맞음 — `default`가 상수면 deps 없이 클로저 캡처만으로 충분, + `default`가 State면 trailing arg로 구독(위 `Sum` 패턴과 동일). 업계 + 선례로 RxJS `defaultIfEmpty`, Kotlin 엘비스 연산자(`?:`), VueUse + 대부분 유틸의 기본값 인자 등 흔한 연산이라 포함 근거는 있음 — 이름/최종 + 포함 여부는 다른 항목과 동률로 사용자 판단 대기. - **[2026-08-12 외부 리서치로 신설, 별도 검토 필요 — Operator 카탈로그와 성격이 다름]** Debounce/Throttle — RxJS `debounceTime`/`throttleTime`, VueUse `useDebounce`/`useThrottle` 등 업계 전반에서 가장 흔한 리액티브 diff --git a/.claude/session/2026-08-13-02-haskell-comparison-dispatch-reentrant-bug.md b/.claude/session/2026-08-13-02-haskell-comparison-dispatch-reentrant-bug.md new file mode 100644 index 0000000..868543d --- /dev/null +++ b/.claude/session/2026-08-13-02-haskell-comparison-dispatch-reentrant-bug.md @@ -0,0 +1,147 @@ +# 2026-08-13 두 번째 세션 — Haskell Monad/Applicative 비교 리서치, `State>` 재진입 디스패치 버그 발견·수정 + +## 배경 + +사용자가 "커링/레이지 이벨루에이션 말고 Haskell에서 가져올 만한 것(모나드, +애플리커티브 펑터 등)이 있는지"를 물으며 시작. 순수 탐색 질문이라 먼저 +Explore 에이전트로 quad의 현재 반응형 코어(State/Source, `:Compute`, +`:With`, `:Apply`, `Operator.*` 카탈로그, Tag/Attribute/Modifier의 +Merged/Overridden 병합, Slot의 store-bind 재구독, HKT 언급 여부, +`framework-comparison-findings.md`의 Haskell 비교 여부)를 그라운딩된 사실로 +먼저 조사시킴. + +## 1부 — Haskell 비교 조사 결과 + +- **Functor(`fmap`)/Applicative(`pure`+`<*>`)는 이미 사실상 가져와 있음** — + `:Compute(fn)`이 fmap, `:With(...).Compute(fn)` + trailing-args sugar가 + applicative 결합. `pure`에 해당하는 값 승격도 `T | State` 유니온 + 패턴(`CanAnimate` 등)으로 이미 코퍼스 전반에 자연스럽게 녹아있음. + 진행 중인 `Operator.*` 콤비네이터 카탈로그도 사실상 애플리커티브 스타일 + 결합자 모음이라 별도로 가져올 게 아니라 이미 백로그. +- **Semigroup/Monoid도 이미 있음** — `Tag.Merged`(가환)/`Modifier.Overridden` + (비가환)/`Attribute.Merged`가 정적 결합 함수로 이미 구현됨, Haskell + 용어만 안 썼을 뿐. +- **Monad bind/join이 진짜 흥미로운 후보** — "State 값 자체가 또 다른 + State/컬렉션을 가리키고 그게 바뀌면 재구독"하는 패턴이 `StoreBind`, + `Slot:Single`, `NoneHandler`의 재귀 재디스패치(`process(inst,k,nil)`) + 세 군데서 각자 따로 재구현돼 있음 — 이름도 없고 일반화도 안 됨. 중요한 + 점: 이건 "동적 `:With` 의도적 비지원"과 충돌하지 않음 — 그 금지 대상은 + "의존성 *집합*이 런타임에 바뀌는 것"이고, join/bind는 의존성이 항상 + 1개(바깥 State)인 채로 그 값의 *정체성*만 바뀌는 것이라 별개 축. 일반화 + 후보로 남겨둠(착수는 안 함, 사용자가 원하면 research/로 승격 가능). +- **Traversable/sequence는 빈 자리** — `Store.Combine(array, fn)`이 + positional 정적 인자 버전으로 기각된 적은 있지만, 런타임 길이 배열을 + 하나의 State로 합치는 Promise.all류 유틸은 논의 자체가 없었음. Slot이 + 이미 배열 다루는 자체 메커니즘(`:List`)을 갖고 있어 실제 필요성은 불확실 + — 실사용 사례 나오면 재검토할 후보. +- **Alternative/`<|>`(fallback)는 가치 낮다고 처음엔 판단** — `if a then a + else b end`로 충분하다고 봤으나, 후속 대화에서 사용자가 이의 제기(아래 + 2부). +- **do-notation/모나드 트랜스포머/Arrow는 스킵 권장** — Luau가 higher-kinded + type(타입 생성자 다형성)을 지원 안 함(코퍼스 전체 확인 결과 이 개념 + 자체가 등장한 적 없음), 그래서 Haskell처럼 하나의 `Functor f => f a` + 인터페이스로 State/Slot/Ref를 통일 못 하고 지금처럼 타입별 개별 구현 + (구조적 타이핑, `store.key`의 type function 합성과 같은 접근) 유지가 맞음. + +## 2부 — `Alternative` 재검토, `State>` 재진입 버그 발견 + +사용자가 `Alternative`를 다시 꺼냄: `State:Apply(Alternative(1))` +꼴로 nil 대체값을 표현하면 편할 것 같다는 지적 + 두 가지 확인 요청: + +1. `Operator` 카탈로그에 이미 있는지 +2. **`State>`가 UB로 명시돼 있는지, 그리고 `retractUnder`가 꼬리부터 + 정리하는 구조인데 같은 키에서 "이미 사용된 핸들러가 재사용"되면 문제 + 아닌지** — Attribute의 `rawNew(name)` 위임은 별도 키로 가니 괜찮을 + 것 같지만, 순수 `State>`는 문제가 실제로 날 것 같다는 가설. + "이거 에러 안 나면 치명적일 수 있는데, 해시맵으로 막는 비용은 낮다"는 + 결론까지 제시. + +Explore(opus) 에이전트로 `bind-system-plan.md`의 `Dispatch`/`retractUnder`/ +`StoreBind` pseudocode를 정확히 추적해 검증: + +**질문 1 답**: `operator-sugar-plan.md` 카탈로그에 nil 대체 콤비네이터 +없음 확인. `Clamp`/`Min`/`Max` 항목과 같은 형식으로 `Alternative(default)` +후보를 신설(카탈로그 확정 규칙 `factory(self)->State`+`:Apply`에 그대로 +맞음, 업계 선례로 RxJS `defaultIfEmpty`/Kotlin 엘비스 연산자 등 근거 있음). + +**질문 2 답 — 사용자 가설 전부 확인됨, 실제 버그로 재현**: +`store.key = a`(State), `a:Get() = b`(State)일 때 pseudocode를 손으로 +대입: +1. `Dispatch.process(inst,k,a)` → StoreBind 매치 → `list={SB}` → Observer + 즉시 실행 → `Dispatch.process(inst,k,b)` 재귀. +2. `b`도 State라 **같은 StoreBind 싱글톤 객체**가 또 매치 → `list={SB,SB}` + — 같은 핸들러가 한 체인에 두 번. 안쪽 Observer가 + `relate:SetStrong(inst,k,·)`로 바깥 Observer 참조를 덮어써 바깥 것이 + 유령 구독으로 샘. +3. 안쪽 Observer 즉시 실행이 부르는 `retractUnder(inst,k,SB,·)`는 첫 + 매치를 찾는 루프(457행) 때문에 **항상 바깥쪽 인덱스**를 cutoff로 + 잡음 → 실제 retract 대상은 다음 인덱스(안쪽 자기 자신) → **안쪽 + State 구독이 등록 직후 스스로 끊김** → 이후 `b:Set(...)`이 조용히 + 무시되는 정지 버그. +4. 코퍼스 어디에도 UB로 명시된 적 없었고(오히려 `bind-system-plan.md: + 811-813`이 "다른 store여도 상관없이 처리 가능"이라 낙관적으로 + 틀리게 서술), 막는 가드도 전혀 없었음(순환 UB 방어의 전제 — + "각 핸들러는 최대 한 번씩만 그 키에서 호출됨" — 를 스택오버플로 없이 + 조용히 깨는 경로였음). +5. Attribute의 `rawNew(name)` 위임은 별개 `(inst,key)` 체인으로 옮겨가는 + 것뿐이라 이 버그를 원천적으로 피하지 못함 — 위임된 값 자체가 + `State>`면 `AttributeKey` 체인 안에서 동일 재현. + +## 3부 — 수정 반영 (같은 세션에 즉시) + +- `Dispatch.process` pseudocode(`bind-system-plan.md`)에 "같은 `(inst,k)`에 + 같은 핸들러 객체가 이미 체인에 있으면 즉시 error" 가드 추가(push 직전, + 가장 저비용 지점). +- "확정된 디스패치 모델" 절의 "순환은 UB" 불릿 바로 뒤에 이 발견 전문을 + 신규 불릿으로 추가. +- 811-813행("심지어 다른 store"까지 낙관적으로 커버 가능하다던 서술)과 + "Store가 Store를 저장 가능한가" 절(Store *필드* 얘기와 State가 emit하는 + 값 얘기가 다른 축이라는 스코프 혼동이 있었음)을 정정. +- **`operator-sugar-plan.md`에 `Alternative` 후보 신설** (2부 질문1 답). + +## 4부 — 기존 스파이크 `04`의 사각지대 발견 + +`luau-test/04-dispatch-chain-retractUnder.luau`를 다시 읽어보니, 이 파일이 +**이미 정확히 이 시나리오(storeA에 storeB를 다시 대입하는 "다단 체인 +스트레스 테스트")를 다루고 있었음**을 발견. 그런데 손으로 재트레이싱해보니 +이 스파이크의 `retract`가 print만 하는 완전 no-op이라(실제 구독 해제 +로직이 없음), 안쪽 State가 "자기 자신을 스스로 retract"하는 버그가 실제로 +일어나도 아무 부작용 없이 넘어가버려 겉보기엔(체인 길이만 보면) 3~4단계가 +정상으로 통과하는 것처럼 보였음 — 즉 이 스파이크는 정확한 시나리오를 +갖고 있었지만 검증 방식의 결함으로 이 버그를 절대 못 잡는 구조였음. +파일 자신의 주석("retract가 할 일이 '구독 해제'라는 본질은 같아서 로직 +검증엔 영향 없음")이 바로 이 지점에서 틀렸던 것. + +**수정**: 3단계를 "가드가 실제로 error하는가"(`pcall`로 감싸 `ok==false` +확인) + 4단계를 "가드 발동 후에도 같은 자리에 정상 재바인드가 되는가"(체인 +길이 2로 복구)로 재작성. `Dispatch.process`에도 같은 중복 핸들러 가드를 +이식. 파일 상단 주석과 확인 포인트 목록도 이 정정을 반영해 다시 씀. +`luau-test/README.md`의 04 행 요약, 갱신 이력(10차), "결과 확인 후 할 일" +체크리스트에도 추가 — 만약 실측 시 3단계가 error 없이 통과하면(`ok==true`) +가드 pseudocode 자체의 회귀이니 바로 알리라고 명시. + +`luau` CLI가 이 환경엔 없어 실제 실행 검증은 못 함 — 손 트레이싱만 완료, +사용자가 실측할 때 확인 필요. + +## 핸드오버 시점 코퍼스 동기화 + +- `.claude/README.md`의 `bind-system-plan.md`/`operator-sugar-plan.md` 행에 + 이 세션 요약 추가. +- `.claude/question.md`의 `Operator` 항목에 `Alternative` 후보 언급 추가. + `State>` 건은 같은 세션에 발견+수정까지 끝나 "열린 질문"이 + 아니므로 question.md에 별도 항목 안 만듦. +- 코퍼스 전체 grep으로 "심지어 다른 store" 류 낙관적 서술의 다른 사본, + `Dispatch.process` pseudocode의 다른 인라인 사본이 더 있는지 확인 — + `bind-system-plan.md`와 `luau-test/03`/`04`뿐이었고, `03`은 스스로 + "다단 체인(retractUnder)까지는 다루지 않음"이라고 이미 스코프를 좁혀둔 + 파일이라 영향 없음(가드 이식 불필요, 확인만). + +## 남은 것 + +- Monad bind/join 일반화(위 1부)는 착수 안 함 — 원하면 `research/`에 정식 + 후보로 새로 남길 것. +- `Alternative`/`Clamp`/`Min`/`Max` 등 `Operator` 카탈로그 최종 이름·포함 + 여부는 여전히 사용자 몫, 우선순위 최하(순수 슈가). +- `.claude/luau-test/` 전체 스파이크(20개, `04`는 이번에 시나리오 + 재작성됨)는 여전히 사용자가 `luau`로 안 돌려봄 — M0 착수 전 최우선 + 게이트 그대로 유지. diff --git a/CLAUDE.md b/CLAUDE.md index 8e2f9cb..0ad21bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -761,3 +761,26 @@ A-1/A-2/B/C는 미해소로 명확히 구분). GC 강제 트리거 기법을 색인 레이어만 밀렸던 것) + `attribute-plan.md` 행의 실제 오류(폐기된 중간 단계 서술) 수정 + 새 소유권/참조카운트 알고리즘(Tag/Attribute/Slot)과 `Slot:Splice` 산술을 커버하는 `19`/`20` 스파이크 추가(총 20개). + +**2026-08-13 두 번째 세션 — Haskell Monad/Applicative 비교, `State>` +재진입 디스패치 버그 발견·수정** +(`session/2026-08-13-02-haskell-comparison-dispatch-reentrant-bug.md`) +"커링/레이지 이벨루에이션 말고 Haskell에서 가져올 것"을 조사 — Functor/ +Applicative/Semigroup은 `:Compute`/`:With`+trailing-args/`Merged`·`Overridden`로 +이미 사실상 가져와 있음 확인, Monad bind/join은 `StoreBind`/`Slot:Single`/ +`NoneHandler`가 각자 따로 재구현 중인 미일반화 후보로 식별(착수 안 함), +Traversable/sequence는 진짜 빈 자리로 식별(백로그), do-notation류는 Luau에 +HKT가 없어 스킵 권장. 후속으로 사용자가 `Alternative`(nil 대체값)를 +`Operator` 카탈로그에 넣자고 제안하며 `retractUnder`의 꼬리부터-cutoff +로직을 직접 되짚어 "같은 키에서 핸들러가 재사용되면 문제 아닌가" 제기 — +손 트레이싱으로 `State>`(store가 emit하는 값 자체가 또 +State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 +`retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 +파손 버그(구독이 등록 직후 스스로 끊김)로 확인됨 — 코퍼스 어디에도 UB로 +명시된 적 없었고 막는 가드도 전혀 없었던 진짜 갭. `Dispatch.process`에 +중복 핸들러 즉시 error 가드 추가, 낙관적으로 틀렸던 "다른 store여도 +상관없이 처리 가능" 서술 정정. 부수적으로 `luau-test/04`가 이미 이 +시나리오를 스트레스 테스트로 갖고 있었지만 `retract`가 no-op 스텁이라 +이 버그를 절대 못 잡는 사각지대였음을 발견해 3~4단계를 "가드가 실제로 +걸리는지" 검증으로 재작성. `operator-sugar-plan.md`에 `Alternative` 후보 +신설, `.claude/README.md`/`question.md` 동기화.