diff --git a/.claude/README.md b/.claude/README.md index b53ed88..4903ac3 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -51,11 +51,12 @@ | `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) | | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `Relate` 기반 인스턴스별 멱등 Init 가드(순서 의존성 해소) | -| `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** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화 | +| `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** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정 | | `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 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `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)과 함께 개발. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) | +| `debounce-throttle-plan.md` | **[2026-08-14 신설, 2026-08-19 전부 해소돼 `research/`에서 승격]** 시간 기반 전파 게이트 `Debounce`/`Throttle` — 사용자 요청("`Blocker`와 유사하게")으로 신설. 요지: (1) `Blocker`가 이미 쓰는 게이트 노드의 **릴리스 트리거만 타이머로 바꾼 것**이라 새 전파 메커니즘이 아님, (2) 무효화 채널만 만지므로 laziness 안 깨짐, (3) **Debounce/Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐** — 공개 생성자는 둘, 구현은 하나, (4) 알고리즘은 quad-base + 주입 op 2개 `setTimeout(func, delay) -> Timeout`/`clearTimeout`(Roblox `task.delay`/`task.cancel`로 배선 — **인자 순서 반대라 주의**), `Timeout`은 `{ __type_timeout: true, _native: any }`. **[2026-08-19 마지막 라운드]** 의미론은 **(A) emit-gate**(`Blocker`와 동일, `:Get()`은 항상 최신값)로 확정 — 검토했던 값-지연 안은 laziness와 상충해 철회. 제어 핸들은 개별은 `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak 레지스트리)로 확정, `Time`/`MaxTime`은 `number \| State`(스케줄 시점에만 폴링) 허용. 이름은 `Debounce`/`Throttle` 유지 + Roblox 관용 "debounce"와 다르다는 문서 경고. **결과적으로 quad-base에 새 코어 메커니즘을 안 더하는 순수 슈가로 귀결**(`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힘) — 우선순위는 `Operator.*`와 같은 급으로 재평가됨. **부수 성과**: 이 설계 중 `source-state-plan.md`의 무효화 dedup 서술이 `Observer` 계약과 모순되는 게 발견돼 base 전면 정정(`archive/invalidate-dedup-propagation-reversed.md`) | | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음(그 dedup 경로의 process/retract 대칭은 **미확인**, M3 착수 전 확인) | | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) | @@ -87,8 +88,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 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State|T>` → `State` 평탄화 항목 신설(백로그) — `State>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | -| `debounce-throttle-plan.md` | **[2026-08-14 신설]** 시간 기반 전파 게이트 `Debounce`/`Throttle` — 사용자 요청("`Blocker`와 유사하게")으로 신설. 요지: (1) `Blocker`가 이미 쓰는 게이트 노드의 **릴리스 트리거만 타이머로 바꾼 것**이라 새 전파 메커니즘이 아님 — `Blocker` 구현(M3) 시점에 게이트를 공용으로 빼두는 게 쌈, (2) 무효화 채널만 만지므로 laziness 안 깨짐, (3) **Debounce/Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐** — 공개 생성자는 둘, 구현은 하나(초안의 lodash식 `maxWait` 공식엔 trailing 통과 직후 이중 발화 버그가 있었음), (4) 알고리즘은 quad-base + 주입 op 2개 `setTimeout(func, delay) -> Timeout`/`clearTimeout`(Roblox `task.delay`/`task.cancel`로 배선 — **인자 순서 반대라 주의**). `os.clock()`은 Luau 표준 라이브러리라 주입 대상 아님(단 절대 시각이 아니라 **diff 전용**), 취소 없는 엔진도 래핑+유효 플래그로 대응 가능, `Timeout`은 `{ __type_timeout: true, _native: any }`. **부수 성과**: 이 설계 중 `source-state-plan.md`의 무효화 dedup 서술이 `Observer` 계약과 모순되는 게 발견돼 base 전면 정정(`archive/invalidate-dedup-propagation-reversed.md`) | 하 — M0 안 막음(코어 계약 변경 없음), 다만 순수 슈가가 아니라 실제 기능 갭이라 `operator-sugar-plan.md`보다는 위. 의존은 M3(State)+백엔드 주입. 남은 열린 질문: 이름(Roblox 관용 debounce와 충돌)/값 지연 의미론/제어 핸들 `Flush`/`Time=0` 허용 | +| `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리(**[2026-08-19]** 그 질문은 `base/debounce-throttle-plan.md`로 전부 해소·승격 완료), `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State|T>` → `State` 평탄화 항목 신설(백로그) — `State>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | | `quad-recursive-acronym.md` | **[2026-08-14 신설]** GNU/WINE류로 `Quad`를 재귀 약어화하는 카피 브레인스토밍 — 설계 결정도 착수 게이팅도 아니고 나중에 README.md 헤딩 등에 쓸 캐치프레이즈 후보 모음. 자학 개그 방향(기각)과 지연평가/재귀·커링/펑터/클로저를 자랑하는 방향(채택 후보, 미확정) 정리 | 하 — 카피 소재, 설계 상의 필요 없음. 사용자가 최종 문구 고르면 반영 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | | `doc-include-plan.md` | **[2026-08-14 신설]** 문서 stale 감소용 include 도구 `doc-include.py`(가칭) — 원본 파일에 `` 류 마커로 요약 구간을 표시해두면 인용하는 문서가 그 구간을 기계적으로 추출해 붙여넣게 하는 도구. `doc-check.py`(사후 탐지)와 짝을 이루는 사전 차단 장치. AsciiDoc tagged include/markdown-magic이 선례, build vs buy 검토 후 Python 표준 라이브러리로 직접 제작(~100줄) 채택. 파일럿은 `.claude/session-summary.md` ← `.claude/session/*.md` 요약 마커부터(CLAUDE.md 분할로 목적지가 "통째로 생성되는 파일"이 돼 단방향 생성으로 단순화됨) | 하 — M0/설계 게이트와 무관한 메타 도구. **[2026-08-16 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) | diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 38281ae..72c1616 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -128,6 +128,32 @@ pre-implementation-qa-round3.md`가 원본) — 트레이싱 중 `attachSlot`이 절이 소스. 논의 원문(최초 분석·사용자 정정·확인 질문과 답변 전문)은 `qa-request/pre-implementation-qa-round3.md`. +## [해소됨, 2026-08-19] `PopOnly` → `Detach` 리네임 + 공개 표면 위치 확정 + +`:List` reconcile의 "파괴하지 말고 자리만 비우라" 반환 센티널은 2026-08-18 +가칭 `PopOnly`로 도입되며 사용자가 "이름은 변경될 수 있음, 더 생각해봐야함" +이라 남긴 채 `question.md` 용어 정리 항목에 열려 있었음. 사용자가 후속 +세션에서 이름 아이디어를 요청 → 여러 후보(`Bench`/`Stash`/`Hold`/`Detach` +등)를 검토한 뒤 `Detach`로 좁혔고, 이어서 "이걸 어디에 던져줘야 하나"(공개 +표면 위치)까지 같이 확정됨. 원문은 +`session/2026-08-19-02-detach-naming-and-placement.md`. + +- **이름 — `Detach`.** 근거 둘: (1) 이미 있는 `Extract`(호출자가 직접 + 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데, `Detach`는 "화면(부모 + 계층)에서만 떼어낼 뿐 관리 주체는 여전히 reconcile"이라는 뜻이라 + `Extract`의 "소유권을 통째로 호출자에게 넘긴다"와 자연스럽게 구분됨. + (2) `nil`(파괴)과의 대비도 더 직접적으로 드러남. +- **공개 표면 위치 — 패키지 최상위 export.** `Slot`이 함수(팩토리)라 + `Slot.Detach`처럼 붙이려면 callable-table+메타테이블이 새로 필요한데, + sentinel 상수 하나 때문에 그 구조를 들이는 건 "실제로 관측된 문제에만 + 구조를 쓴다" 원칙에 안 맞음(사용자 지적). 대신 `None`이 이미 쓰는 패턴을 + 그대로 따름 — `None`도 여러 곳(Slot 요소, Attribute, offsetSource)에서 + 쓰이지만 공개 표면은 패키지 최상위(`quad-base/src/init.luau` 재노출)이고 + 실제 정의는 관련 로직 옆(`Dispatch/None.luau`)에 있음. `Detach`도 같은 + 패턴 — 정의는 Slot 관련 파일 옆에 두고 `init.luau`에서 최상위로 재노출. +- 지금 유효한 설계는 `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절이 + 소스. + --- # 확인/결정 필요 목록 diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index e0f186c..a361415 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -25,8 +25,9 @@ State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것. 지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기 `invalid` 상태와 무관하게 **항상** 아래로 전파하고(`base/source-state-plan.md` "전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된 -게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`research/ -debounce-throttle-plan.md`)가 추가되면 같은 자리에 들어옴. 이걸 못 박아 +게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`base/ +debounce-throttle-plan.md`, 설계 확정·구현은 아직)가 추가되면 같은 자리에 +들어옴. 이걸 못 박아 두는 이유: 과거에 "이미 `invalid`면 전파를 멈춘다"는 서술이 base에 있었고, 그건 사실상 `Blocker`가 하는 일을 모든 State에 암묵적으로 심는 것이라 `Blocker`의 존재 의의를 반쯤 지워버렸음(역전 경위는 diff --git a/.claude/research/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md similarity index 68% rename from .claude/research/debounce-throttle-plan.md rename to .claude/base/debounce-throttle-plan.md index fd8feb7..ee84c8e 100644 --- a/.claude/research/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -1,10 +1,18 @@ # Debounce / Throttle — 시간 기반 전파 게이트 -**상태**: research — **[2026-08-14 신설]** 사용자 요청("`Blocker`와 유사하게 -Debounce/Throttle를 만들어야 한다")으로 신설. 여기 적힌 건 **에이전트가 -먼저 전부 정의해본 초안**이고, 확정된 건 하나도 없음 — 사용자가 이 문서를 -읽고 판단하는 게 다음 단계. 열린 결정은 맨 아래 "사용자 판단 대기" 절에 -번호로 모아뒀음. +**상태**: base — **[2026-08-19 세션, 전부 해소되어 `research/`에서 승격]** +12절 "사용자 판단 대기"에 남아있던 마지막 항목(이름/의미론/제어 핸들/ +`Time = 0`)까지 전부 결론이 나서 열린 결정이 없음 — 논의 원문은 +`session/2026-08-19-03-debounce-throttle-final-close.md`. 이 라운드에서 +드러난 것: 제어 핸들 설계까지 확정되고 나니 이 프리미티브는 **quad-base에 +새 코어 메커니즘을 추가하지 않는 순수 슈가**로 귀결됨(기존 `Blocker`의 +게이트 개념 + `Ref` + 주입 op 2개 위에 전부 얹힘) — 13절이 이를 반영해 +갱신됨. + +> **[2026-08-14 신설]** 사용자 요청("`Blocker`와 유사하게 Debounce/Throttle를 +> 만들어야 한다")으로 `research/`에 신설. 여기 적힌 건 **에이전트가 먼저 +> 전부 정의해본 초안**이었음 — 이후 네 라운드 리뷰를 거쳐 아래 배너들대로 +> 전부 확정됨. > **[2026-08-14 1차 리뷰 반영]** 사용자가 스로틀의 trailing 동작을 짚어준 > 뒤 세 가지가 바뀜: (1) **Q5(패키지 경계) 해소** — quad-base + 엔진별 @@ -33,6 +41,24 @@ Debounce/Throttle를 만들어야 한다")으로 신설. 여기 적힌 건 **에 > **`base/source-state-plan.md`의 무효화 dedup 문장이 확정된 `Observer` > 계약과 모순**되고 `base/architecture.md`와도 어긋난다는 게 드러나, > base 정정 항목(Q10)이 새로 생김. +> +> **[2026-08-19 4차 리뷰 반영, 최종]** 남아있던 Q1/Q2/Q4/Q8을 전부 닫음. +> (1) **Q1 이름** — `Debounce`/`Throttle` 유지 확정, Roblox 관용 "debounce" +> (재진입 방지 불리언)와 다르다는 경고를 사용자 문서 첫 줄에 못박기로. +> (2) **Q2 의미론 — (A) emit-gate로 확정, (B) value-hold는 철회**. +> `:Get()`이 값을 지연시키려면 게이트가 "창이 열리기 전 캐시가 확실히 +> valid하다"를 보장해야 하는데, invalid로 남은 채 아무도 안 읽다가 새 창이 +> 열리는 경우(드물지 않음 — 컴포넌트가 한동안 안 읽다가 다시 읽는 경우 등) +> 그 보장이 깨져 "held value" 계약이 조용히 무너짐 — 이걸 고치려면 창이 +> 열리는 순간 upstream을 강제로 pull해야 하는데, 그건 정확히 Throttle이 +> 막으려는 그 비싼 연산을 게이트 자신이 강제로 돌리는 셈이라 laziness와 +> 상충. **결과적으로 Q7도 자동 소멸**(A는 `Blocker`와 완전히 같은 메커니즘이라 +> `blocker-plan.md`의 기존 "`Get()`은 라이브 레퍼런스" 문구가 그대로 맞고 +> 명확화가 따로 필요 없음). (3) **Q4 제어 핸들 — 넣기로 확정**, 다만 모양은 +> 초안 S1/S2 둘 다 아니고 세 번째 형태로 수렴 — 5-4절 "제어 핸들" 신설 +> 참고. (4) **Q8 `Time = 0`** — 허용, "defer될 수 있음"만 문서화, 금지/에러 +> 안 함. 부수로 **Time/MaxTime을 `number | State`로 확장**하는 것도 +> 이 라운드에 같이 확정(5-2절) — 원문은 위 세션 파일. `research/operator-sugar-plan.md`가 "`Operator.*` 카탈로그 밖의 별도 설계 질문"으로 분리해뒀던 항목이 이 문서의 출발점(그 문서 "열린 질문 — @@ -320,42 +346,47 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정" 근거는 순회 비용 최적화였지만 지금은 실제 중복 *계산*이라서). --- -## 4. 의미론 — 지연되는 건 "전파"인가 "값"인가 +## 4. 의미론 — 지연되는 건 "전파"인가 "값"인가 — **(A) emit-gate로 확정 (2026-08-19)** -두 갈래가 있고, 이게 이 문서에서 두 번째로 큰 결정. +두 갈래를 놓고 두 라운드 동안 (B)를 권장안으로 들고 있었으나, **[2026-08-19] +(A)로 확정하고 (B)는 철회**. 아래는 최종 결론과, 왜 (B)가 무너졌는지의 기록. -### (A) emit-gate — `Blocker`와 완전히 동일 +### (A) emit-gate — `Blocker`와 완전히 동일 — **채택** 게이트는 자기 `invalid`를 즉시 세우되 아래로 전파만 미룸. 창이 열려 있는 -동안 누가 `debounced:Get()`하면 **최신값**이 나옴. +동안 누가 `debounced:Get()`하면 **최신값**이 나옴 — `Blocker`의 gated +state와 글자 그대로 같은 동작. -### (B) value-hold — 값 자체가 지연됨 (권장) +### ~~(B) value-hold — 값 자체가 지연됨~~ — **철회됨, laziness와 상충** -게이트는 창이 닫혀 있는 동안 **자기를 invalid로 만들지 않음**. 즉 캐시된 -직전 값을 계속 들고 있고, `debounced:Get()`은 **지연된 값**을 반환. 타이머가 -커밋할 때 비로소 invalid + 전파. +게이트가 창이 닫혀 있는 동안 자기를 invalid로 만들지 않고 캐시된 직전 +값을 들고 있다가, `debounced:Get()`이 **지연된 값**을 반환하게 하려던 +안. 업계 선례(VueUse `useDebounce`/RxJS `debounceTime`)와의 일치, `Blocker`와의 +역할 분리를 근거로 두 라운드 동안 권장안이었음. -**(B)를 권하는 이유**: +**왜 무너졌는가**: 이 계약("`:Get()`은 항상 창 열리기 전의 held value")을 +지키려면 게이트가 **창이 열리는 시점에 자기 캐시가 확실히 valid함**을 +보장해야 하는데, 실제로는 안 그런 경로가 있음 — 직전 커밋에서 +`invalid = true`로 세팅된 채 아무도 `:Get()`을 안 부르고 있다가(다운스트림이 +한동안 안 읽는 경우, 예: 언마운트됐다 재마운트) 그 상태에서 새 창이 +열리면, "창 안에선 invalid 세팅 안 함"이라는 (B)의 규칙이 이미 세팅돼 +있던 `invalid=true`를 못 되돌려서 `:Get()`이 곧바로 최신값을 계산해버림 — +"held value" 계약이 조용히 깨짐. 이걸 고치려면 창이 열리는 순간 게이트가 +upstream을 강제로 pull해 캐시를 스냅샷 떠야 하는데, **그건 정확히 +Throttle이 막으려던 그 비싼 연산을 게이트 자신이 매 창마다 강제로 돌리는 +것**이라 laziness가 깨짐 — Throttle의 주 용례(랙 걸리는 연산 게이팅)에서 +치명적. -1. **업계 의미론과 일치.** VueUse `useDebounce`는 반환된 ref의 *값 자체*가 - 늦게 따라오고, RxJS `debounceTime`도 값의 방출을 미룸. "debounce된 - 상태"를 다른 곳에서 읽었을 때 debounce 안 된 값이 나오면 놀람. -2. **`Blocker`와 역할이 깔끔히 갈림.** `Blocker`는 "지금 중간 상태니까 - 구독자에게 알리지 않을 뿐, 진실은 이미 새 값"이고, Debounce는 "이 - 노드의 값은 정의상 늦게 따라오는 값"임. 후자를 (A)로 만들면 두 도구가 - 같은 자리에서 미묘하게만 다른 애매한 물건이 됨. -3. **확정된 원칙과 충돌하지 않음.** `base/blocker-plan.md`가 인용하는 - "`Get()`은 라이브 레퍼런스를 준다" 원칙은 `base/source-state-plan.md`에서 - 실제로는 **레퍼런스 의미론**(테이블을 복사본이 아니라 라이브 참조로 - 준다, 그래서 `Get()` 결과를 캐시해두고 `==` 비교하면 안 됨)에 대한 - 것이지 "항상 상류 최신값"이라는 뜻이 아님. 게이트 노드의 *자기* 값이 - 지연된 값이라면 `:Get()`은 여전히 그 노드의 라이브 값을 주는 것. - → 다만 blocker-plan의 그 한 줄이 후자로 읽힐 여지가 있어, 확정되면 - 그 문장에 한 줄 명확화를 넣는 게 좋음(열린 질문 Q7). +부수로, (B)를 지지했던 업계 선례(VueUse/RxJS)도 재검토하면 그대로 옮겨올 +근거가 약함 — 둘 다 push/eager 모델(Vue reactivity의 effect는 즉시 도는 +push, RxJS는 애초에 eager push 스트림)이라 "값이 지연된다"가 공짜로 +성립하는 세계고, quad처럼 **pull-lazy가 원칙**인 곳엔 그대로 안 맞는 +선례였음. -**(B)의 캐비엇 하나**: 노드가 아직 한 번도 계산된 적 없으면(캐시 없음) -붙잡고 있을 "이전 값"이 없어서 첫 `:Get()`은 그냥 최신값을 계산함. -"지연은 두 번째 값부터"라는 뜻 — 자연스럽고 무해하지만 문서에 명시할 것. +**(A) 채택의 부수 효과 — Q7 소멸**: (A)는 `Blocker`의 gated state와 +완전히 같은 메커니즘이라, `base/blocker-plan.md`가 이미 쓰고 있는 +"`Get()`은 라이브 레퍼런스를 준다" 문구가 그대로 맞고 별도 명확화가 +필요 없음(옛 Q7이 걱정했던 모순은 (B)를 택했을 때만 생기는 문제였음). --- @@ -383,24 +414,35 @@ local sampled = mousePos:Apply(Throttle{Time = 1 / 30}) ```lua type DebounceOptions = { - Time: number, -- 필수. 창 길이(초) — 신호마다 리셋됨 - Leading: boolean?, -- 기본 false. 버스트의 첫 신호를 즉시 통과시킬지 - Trailing: boolean?, -- 기본 true. 조용해진 뒤 한 번 통과시킬지 - MaxTime: number?, -- 기본 nil. 신호가 안 끊겨도 최대 이 간격마다 강제 통과 + Time: number | State, -- 필수. 창 길이(초) — 신호마다 리셋됨 + Leading: boolean?, -- 기본 false. 버스트의 첫 신호를 즉시 통과시킬지 + Trailing: boolean?, -- 기본 true. 조용해진 뒤 한 번 통과시킬지 + MaxTime: (number | State)?, -- 기본 nil. 신호가 안 끊겨도 최대 이 간격마다 강제 통과 + Handle: Ref?, -- 기본 nil. 이 인스턴스 전용 제어 핸들(5-4절) } type ThrottleOptions = { - Time: number, -- 필수. 창 길이(초) — 신호가 리셋하지 못함 - Leading: boolean?, -- 기본 true. 창 밖 첫 신호를 즉시 통과시킬지 - Trailing: boolean?, -- 기본 true. 창 안에 눌러둔 게 있으면 창 끝에 통과시킬지 + Time: number | State, -- 필수. 창 길이(초) — 신호가 리셋하지 못함 + Leading: boolean?, -- 기본 true. 창 밖 첫 신호를 즉시 통과시킬지 + Trailing: boolean?, -- 기본 true. 창 안에 눌러둔 게 있으면 창 끝에 통과시킬지 + Handle: Ref?, -- 기본 nil. 이 인스턴스 전용 제어 핸들(5-4절) } ``` -- **모든 필드는 plain 값만** — `State`를 못 받음. `base/tween-plan.md`의 - "옵션 값 모양" 절이 `Tween{...}`에 대해 확정한 것과 같은 규칙, 같은 - 이유(옵션 안에 두 번째 반응 경로를 만들지 않음). `Time`을 반응형으로 - 바꾸고 싶으면 `state:Apply(...)` 자체를 다시 만드는 상위 구조가 필요한데, - 그건 이 프리미티브가 아니라 `State>` 영역. +- **[2026-08-19 확정] `Time`/`MaxTime`은 `number | State` 허용** — + `Leading`/`Trailing`은 여전히 plain만(정적 정책값이라 반응형일 이유가 + 없음). `base/tween-plan.md`의 "옵션 값 모양" 절이 든 "옵션 안에 두 번째 + 반응 경로를 만들지 않음" 근거는 여기 안 부딪힘 — Debounce/Throttle은 + `Time`을 **구독하지 않고**, `setTimeout`을 실제로 호출하는 그 순간에만 + `:Get()`으로 값을 읽는 폴링이라 새 무효화 채널이 안 생김(`Animate`가 + 트윈 시작 시점에만 duration을 읽는 것과 같은 결). + - **이미 스케줄된 타이머엔 반영 안 됨** — `setTimeout(fn, delay)`로 + 한 번 예약된 delay는 못 바꾸므로, `Time`을 바꿔도 **다음 창부터만** + 적용됨(진행 중인 창은 그대로 끝까지 감). 7절 의사코드의 `openWindow`/ + `cap` 스케줄 지점이 유일한 읽기 지점. + - `Time`을 State로 만들고 싶으면 `state:Apply(...)` 상위 구조 + (`State>`)가 필요하다던 옛 서술은 무효 — 그런 상위 구조 없이 + 바로 지원됨. - `Leading = true, Trailing = false` → "버스트 시작에 한 번만" - `Leading = false, Trailing = true` → 기본값, 일반적인 debounce - 둘 다 `false`는 아무 일도 안 하는 설정 → **즉시 error** 권장(`Slot:Add`의 @@ -432,7 +474,81 @@ type ThrottleOptions = { 끊이지 않으면 영원히 발화 안 함"이 디바운스의 정의라 그 안전장치가 필요하지만, 스로틀은 원래 주기적으로 발화하므로 무의미함. -→ 열린 질문 Q3(이 개정안 확인). +**[2026-08-19 확정]** 이 개정안 그대로 채택 — 이후 라운드들이 전부 이 +`Reset` 한 비트 모델을 전제로 논의를 진행했고 별도 이의 없이 유지됨(구 +Q3). + +## 5-4. 제어 핸들 — `Flush`/`Cancel`, 개별은 `Ref`로 · 전체는 팩토리로 (2026-08-19 확정, 구 Q4) + +**결론**: 핸들을 넣는다. 초안이 제시했던 두 모양(S1: 핸들 없음, S2: +`Blocker`와 똑같은 "재사용 가능한 외부 객체") 둘 다 아니고, **세 번째 +모양으로 수렴**했다 — `Debounce`/`Throttle`가 `Blocker`와 근본적으로 다른 +지점(이전 실행이 다음 실행에 영향을 주는 상태 기계라, `Blocker`처럼 +여러 파이프라인에 자유롭게 공유해도 안전한 물건이 아님)을 짚은 사용자 +지적에서 나왔다. + +### 왜 State 자신에 메소드를 붙이지 않는가 + +가장 먼저 검토했던 대안(게이트가 반환하는 State 자체에 `:Flush()`/ +`:Cancel()`을 직접 붙임)은 기각됨 — 그러면 "디바운스로 만들어진 State"와 +"일반 State"가 구조적으로 다른 타입이 되어(메소드 유무로 타입이 갈림), +State 계층에 조용히 서브타입 분기가 생긴다. quad가 피해온 OOP식 확장과 +같은 종류의 문제라 State 자신은 손대지 않기로 함. + +### 왜 `Blocker`처럼 "먼저 만들어 공유하는 외부 객체"도 아닌가 + +`Blocker()`는 의도적으로 **여러 배치에 재사용 가능한 외부 객체** — +`blocker:On()`/`Off()`가 그 블로커에 배선된 모든 gated state에 동시 +적용되는 게 정확히 원하는 동작. 그런데 Debounce/Throttle을 그대로 +따라하면(외부 `Debouncer{...}` 객체 하나를 여러 `state:Debounce(d)`에 +공유) **사용자가 지적한 문제가 그대로 재현됨** — 여러 파이프라인이 같은 +내부 타이머/pending 상태를 공유하게 되어, "누구의 신호가 창을 리셋하고 +누구의 값이 커밋되는가"가 불명확해진다. 5-1절이 이미 "재사용해도 +상태를 공유하지 않음, `:Apply`가 호출될 때마다 새 게이트"로 확정해둔 +것과도 정면으로 어긋남. + +### 채택된 모양 — 개별은 `Ref` 아웃파라미터, 전체는 팩토리 자체 + +`:Apply()`의 기존 계약(`Operator` 관용구, "팩토리가 State 하나만 돌려준다")은 +안 건드리고, 옵션 필드로 핸들을 곁다리로 받는다 — `base/ref-plan.md`의 +`Ref`("채워지길 기다리는 빈 박스를 먼저 만들어 넘기고, 나중에 채워지면 +`:Callback()`/`.Value`로 받는" 이미 확정된 패턴)를 그대로 재사용: + +```lua +export type GateHandle = { + Flush: () -> (), -- 이 인스턴스만 즉시 커밋 + Cancel: () -> (), -- 이 인스턴스만 pending을 버림(전파 없음) +} + +local h = Ref() +local debounced = state:Apply(Debounce{Time = 0.3, Handle = h}) +-- 게이트가 실제로 만들어지는 시점(팩토리 호출 시)에 h가 채워짐: +-- h.Value == { Flush = fn, Cancel = fn } -- 이 게이트 인스턴스 하나만 제어 +h.Value:Flush() + +-- 이 인스턴스 하나만 즉시 커밋(pending이면 창 끝을 기다리지 않고 지금 통과) +-- :Cancel()은 반대로 pending을 그냥 버림(통과 없이 타이머만 정리) +``` + +- **개별 제어**: 위처럼 `Handle = Ref()`로 특정 `:Apply()` 호출 하나만 + 겨냥. +- **전체 브로드캐스트**: 팩토리 자신(`Debounce{...}`가 돌려주는 객체)에도 + `:Flush()`/`:Cancel()`을 붙임 — 그 팩토리로 만들어진 **모든** 게이트 + 인스턴스에 한 번에 적용(저장 버튼 하나로 여러 debounce된 입력을 동시 + 커밋하는 식의 용례). 새 객체 종류를 만드는 게 아니라 이미 `Debounce{...}`가 + 돌려주던 팩토리 값에 메소드 두 개를 더하는 것뿐. + - **팩토리는 자기가 만든 게이트를 weak 레지스트리로만 추적** — strong + 참조로 붙잡으면 다운스트림이 전부 죽어도 게이트가 팩토리에 살아있다는 + 이유로 GC가 안 돼 `base/lifecycle-pattern.md`의 "정리(`retract`)는 + 기본적으로 GC에 위임" 절과 충돌함. weak 등록이면 그 문제가 없음(코퍼스가 + gcconn/gchold 구분에서 이미 쓰는 것과 같은 종류의 장치). + - `Flush`/`Cancel`은 게이트 인스턴스가 이미 갖고 있는 커밋/취소 내부 + 함수(7절의 `onWindowEnd`/타이머 정리 로직)를 그대로 호출 — 새 로직이 + 아니라 노출 방식만 다름. +- **의미**: `Flush()`는 `pending`이면 창 끝을 기다리지 않고 즉시 + `onWindowEnd`가 하는 커밋(passThrough + 창 재개방)을 강제 실행, + `pending`이 없으면 아무 일도 안 함(idempotent). `Cancel()`은 타이머를 + 정리하고 `pending = false`로 되돌리되 **전파는 안 함**(버림). --- @@ -662,10 +778,17 @@ function clearTimeout(timeout: Timeout) timeout._native() end > **주의**: `Gate` 노드의 내부 훅(`onUpstreamSignal`, `commit`)은 아직 > 이름도 계약도 확정되지 않은 가칭 — `Blocker`의 게이티드 노드가 이미 > 필요로 하는 것과 같은 훅이라(1절), 실제 구현 시엔 그쪽과 같이 정의할 것. +> 아래 코드의 `registry`/`Handle` 배선도 마찬가지로 스케치 수준 — 실제 +> 구현 시 이름은 자유. ```lua -- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절). +local function readTime(t: number | State): number + if type(t) == "number" then return t end + return t:Get() -- setTimeout 호출 시점에만 읽음 — 이미 스케줄된 타이머엔 영향 없음 +end + local function makeGate(reset: boolean, opts) if opts.Leading == false and opts.Trailing == false then error("Leading/Trailing 둘 다 false면 아무것도 통과하지 않음") @@ -673,66 +796,103 @@ local function makeGate(reset: boolean, opts) local leading = if reset then opts.Leading == true else opts.Leading ~= false local trailing = opts.Trailing ~= false - -- 팩토리 — :Apply가 state마다 한 번씩 호출하므로 아래 지역 상태는 - -- 게이트 노드 하나당 하나씩 새로 생김(팩토리 재사용해도 공유 안 됨) - return function(self) - local gate = Gate(self) -- Blocker가 쓰는 것과 같은 게이트 노드 - local pending = false -- 창 안에서 상류 신호가 있었는가 - local window = nil -- 살아있으면 "창 안", nil이면 idle - local cap = nil -- MaxTime 타이머(Debounce 전용) + -- 팩토리 레벨 상태 — makeGate 호출(=Debounce{...}/Throttle{...} 한 번)당 하나. + -- weak 레지스트리라 여기 등록돼도 게이트의 GC를 막지 않음(5-4절). + local instances = setmetatable({}, {__mode = "k"}) - local openWindow, onWindowEnd + local function flushAll() + for gate in instances do gate._flush() end + end + local function cancelAll() + for gate in instances do gate._cancel() end + end - function openWindow() - window = setTimeout(onWindowEnd, opts.Time) - end + -- 팩토리 자체 — :Apply(factory)가 호출할 수 있는 함수이면서, 동시에 + -- 전체 브로드캐스트 :Flush()/:Cancel()도 갖는 콜러블 객체(5-4절) + local factory = setmetatable({}, { + __call = function(_, self) + local gate = Gate(self) -- Blocker가 쓰는 것과 같은 게이트 노드 + local pending = false -- 창 안에서 상류 신호가 있었는가 + local window = nil -- 살아있으면 "창 안", nil이면 idle + local cap = nil -- MaxTime 타이머(Debounce 전용) - function onWindowEnd() - window = nil - if pending and trailing then - -- Blocker와 같은 순서: 상태를 먼저 정리하고 그 다음 전파 - -- (전파 도중 소비자가 동기적으로 상류를 :Set()해서 재진입해도 - -- 방금 닫은 창의 잔여 상태를 다시 건드리지 않게) - pending = false - if cap then clearTimeout(cap); cap = nil end - gate:passThrough() -- invalid 세팅 + 아래로 1회 전파 - openWindow() -- 통과했으니 창을 다시 엶 = 다음 통과까지 최소 Time + local openWindow, onWindowEnd + + function openWindow() + window = setTimeout(onWindowEnd, readTime(opts.Time)) end - -- pending이 없으면 창을 안 열고 완전히 idle로 복귀 - end - gate.onUpstreamSignal = function() - if window == nil then - -- 창 밖(idle) - if leading then gate:passThrough() else pending = true end - openWindow() - else - -- 창 안 - pending = true - if reset then -- ← Debounce만: 창을 뒤로 민다 - clearTimeout(window) + function onWindowEnd() + window = nil + if pending and trailing then + -- Blocker와 같은 순서: 상태를 먼저 정리하고 그 다음 전파 + -- (전파 도중 소비자가 동기적으로 상류를 :Set()해서 재진입해도 + -- 방금 닫은 창의 잔여 상태를 다시 건드리지 않게) + pending = false + if cap then clearTimeout(cap); cap = nil end + gate:passThrough() -- invalid 세팅 + 아래로 1회 전파 + openWindow() -- 통과했으니 창을 다시 엶 = 다음 통과까지 최소 Time + end + -- pending이 없으면 창을 안 열고 완전히 idle로 복귀 + end + + gate.onUpstreamSignal = function() + if window == nil then + -- 창 밖(idle) + if leading then gate:passThrough() else pending = true end openWindow() + else + -- 창 안 + pending = true + if reset then -- ← Debounce만: 창을 뒤로 민다 + clearTimeout(window) + openWindow() + end + end + + -- MaxTime: 창과 달리 절대 리셋되지 않는 두 번째 타이머. + -- "신호가 안 끊기면 영원히 발화 안 함"(1-1절)을 위한 안전장치라 + -- Debounce에서만 의미 있음. + if opts.MaxTime and cap == nil and pending then + cap = setTimeout(function() + cap = nil + if pending and trailing then + pending = false + if window then clearTimeout(window) end + gate:passThrough() + openWindow() + end + end, readTime(opts.MaxTime)) end end - -- MaxTime: 창과 달리 절대 리셋되지 않는 두 번째 타이머. - -- "신호가 안 끊기면 영원히 발화 안 함"(1-1절)을 위한 안전장치라 - -- Debounce에서만 의미 있음. - if opts.MaxTime and cap == nil and pending then - cap = setTimeout(function() - cap = nil - if pending and trailing then - pending = false - if window then clearTimeout(window) end - gate:passThrough() - openWindow() - end - end, opts.MaxTime) + -- 5-4절 제어 핸들 — Flush는 즉시 커밋(창 끝을 안 기다림), Cancel은 버림 + gate._flush = function() + if pending and trailing then + pending = false + if window then clearTimeout(window) end + if cap then clearTimeout(cap); cap = nil end + gate:passThrough() + openWindow() + end + end + gate._cancel = function() + pending = false + if window then clearTimeout(window); window = nil end + if cap then clearTimeout(cap); cap = nil end end - end - return gate - end + instances[gate] = true + if opts.Handle then + opts.Handle:Set({ Flush = gate._flush, Cancel = gate._cancel }) + end + + return gate + end, + __index = { Flush = flushAll, Cancel = cancelAll }, + }) + + return factory end function Debounce(opts: DebounceOptions) return makeGate(true, opts) end @@ -754,10 +914,14 @@ function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end 3.5 입력 → window == nil → leading 즉시 통과 ● ``` -**(B) value-hold 의미론이 붙는 자리**: `gate`는 `onUpstreamSignal`에서 -자기 `invalid`를 세우지 않고, 오직 `passThrough()`에서만 세움 — 그래서 -창이 열려 있는 동안 `gate:Get()`은 캐시된 직전 값을 반환. (A)를 택하면 -`onUpstreamSignal` 진입 시 invalid만 세우고 전파를 미루는 형태로 바뀜. +**(A) emit-gate가 붙는 자리(확정, 4절)**: `gate`는 다른 평범한 State와 +똑같이 **매 `onUpstreamSignal` 진입 시 자기 `invalid`를 즉시 세운다** — +`source-state-plan.md`의 "전파 모델 확정" 절이 정한 "emit은 항상 전파된다" +규칙이 게이트 자신에게도 그대로 적용됨. 위 코드의 `gate:passThrough()`가 +실제로 미루는 건 +**다운스트림 통지(전파)뿐**이지 invalid 세팅이 아님 — 그래서 창이 열려 +있는 동안 `gate:Get()`을 불러도 항상 최신값이 계산됨(캐시가 stale한 +채로 안 남음). --- @@ -787,9 +951,9 @@ function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end --- -## 9. 이름 +## 9. 이름 — `Debounce`/`Throttle` 확정 (2026-08-19) -### 9-1. ⚠️ Roblox 커뮤니티의 "debounce"와 충돌함 +### 9-1. ⚠️ Roblox 커뮤니티의 "debounce"와 충돌함 — 유지로 확정 Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리언**을 가리킴(`local debounce = false ... if debounce then return end`). 웹 @@ -808,9 +972,9 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리 | `Coalesce` | "합친다"는 동작 자체 | nil 병합(`Alternative`)과 어휘 충돌 | | `RateLimit` (Throttle 자리) | 의미 명확 | 서버 레이트 리밋 뉘앙스 | -**권고**: `Debounce`/`Throttle` 유지 + **사용자 문서 첫 줄에 Roblox 관용 -"debounce"와 다르다는 걸 못박기**. 업계 표준 이름을 버리면 검색·이주 -비용이 더 큼. 단 이건 사용자 취향이 강하게 걸리는 영역이라 Q1로 넘김. +**[2026-08-19 확정]** `Debounce`/`Throttle` 유지 + **사용자 문서 첫 줄에 +Roblox 관용 "debounce"와 다르다는 걸 못박기**. 업계 표준 이름을 버리면 +검색·이주 비용이 더 크다는 게 채택 근거 — 더 판단할 것 없음(구 Q1). ### 9-2. `-ed`를 안 붙이는 이유 (이건 코퍼스 규칙으로 결정됨) @@ -856,24 +1020,24 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리 --- -## 12. 사용자 판단 대기 — 열린 질문 +## 12. 사용자 판단 대기 — 전부 해소됨 (2026-08-19) -1. **이름** — `Debounce`/`Throttle` 유지(권장) vs 대안. Roblox 관용 - "debounce"(재진입 불리언)와의 충돌을 문서 경고로만 다룰지. (9-1절) -2. **의미론** — (A) emit-gate(`Blocker`와 동일, `:Get()`은 최신값) vs - **(B) value-hold(권장, `:Get()`도 지연된 값)**. (4절) -3. **[2026-08-14 개정] 공개 생성자 2개 + 내부 구현 1개(`Reset` 한 비트)** - — 초안의 "`Throttle`은 `Debounce{MaxTime}` 프리셋"은 이중 발화 버그가 - 있어 폐기됨. 개정안 확인만 필요. (5-3절, 7절) -4. **제어 핸들(`:Flush()`/`:Cancel()`)을 v1에 넣을지.** 세 가지 모양: - - **S1(권장, v1)**: `state:Apply(Debounce{...})` — 핸들 없음. - `Operator` 관용구와 완전 일치, 가장 단순. - - **S2**: `local d = Debouncer{...}; state:Debounce(d)` + `d:Flush()`/ - `d:Cancel()` — **`Blocker`의 모양과 글자 그대로 같음**(외부 객체를 - 들고 `state:Block(blocker)`로 배선). 구조적 추가 비용이 사실상 없고, - "검색창에서 Enter 누르면 즉시 커밋" 같은 실사용 요구를 커버함. - - **S3**: `__call`을 가진 객체로 S1/S2를 겸함 — 매직이라 비권장. - 실사용에서 Flush가 자주 필요하다고 보면 처음부터 S2가 나음. +**[2026-08-19] 남아있던 Q1/Q2/Q4/Q8까지 전부 닫혀 열린 항목이 없음** — +논의 원문은 `session/2026-08-19-03-debounce-throttle-final-close.md`. +이력만 남겨둠(각 항목이 왜 그렇게 됐는지는 가리키는 절이 소스, 여기서 +반복 안 함): + +1. ~~**이름**~~ **[2026-08-19 해소]** — `Debounce`/`Throttle` 유지, + Roblox 관용 "debounce"와의 충돌은 문서 경고로만 대응. (9-1절) +2. ~~**의미론**~~ **[2026-08-19 해소]** — **(A) emit-gate 채택**, (B) + value-hold는 laziness와 상충해 철회. (4절) +3. ~~**공개 생성자 2개 + 내부 구현 1개(`Reset` 한 비트)**~~ + **[2026-08-19 해소]** — 2026-08-14 개정안 그대로 채택, 이견 없었음. + (5-3절, 7절) +4. ~~**제어 핸들**~~ **[2026-08-19 해소]** — 넣기로 확정. 초안의 S1(핸들 + 없음)/S2(`Blocker`와 같은 공유 외부 객체) 둘 다 아니고, 개별은 + `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak + 레지스트리로 브로드캐스트)로 수렴. (5-4절) 5. ~~**패키지 경계**~~ **[2026-08-14 해소]** — 사용자가 quad-base + 엔진별 태스크 배선으로 확정. 주입 표면이 3개→5개로 늘어나는 비용은 수용됨. op 이름/시그니처도 사용자가 지정 @@ -882,14 +1046,12 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리 전제(무효화 dedup이 신호를 삼킴)가 틀린 것으로 드러나 질문 자체가 없어짐. emit은 항상 재전파되므로 게이트는 어디에 걸든 정상 동작함. (3절) -7. **`base/blocker-plan.md` 한 줄 명확화** — 그 문서가 인용하는 - "`Get()`은 라이브 레퍼런스를 준다"는 실제로는 `base/source-state-plan.md`의 - **레퍼런스 의미론** 원칙이지 "항상 상류 최신값"이 아님. Q2에서 (B)를 - 택하면 두 문서가 모순돼 보이므로 그 인용에 한 줄 주석을 넣는 게 좋음. - (확정 문서 수정이라 사용자 승인 후 반영 — 이 세션에선 안 건드림.) -8. **`Time = 0`을 허용할지** — "이번 스텝 합치기" 용도로 유용하지만 - 스케줄러 타이밍에 의존하는 준-`Blocker`가 됨. 허용(권장) / 금지 / - 별도 이름 부여 중 선택. (1절 인용문) +7. ~~**`base/blocker-plan.md` 한 줄 명확화**~~ **[2026-08-19 소멸]** — Q2가 + (A)로 확정되면서 이 항목이 걱정했던 모순(값-지연 의미론과 "`Get()`은 + 라이브 레퍼런스" 문구의 충돌)이 애초에 안 생김 — (A)는 `Blocker`와 + 완전히 같은 메커니즘이라 그 문구가 그대로 맞음. (4절) +8. ~~**`Time = 0`을 허용할지**~~ **[2026-08-19 해소]** — 허용, "defer될 + 수 있음"만 문서화, 금지/에러 안 함. (1절 인용문) 9. ~~**`Timeout` 핸들의 타입**~~ **[2026-08-14 완전 해소]** — 사용자 결정으로 **`type Timeout = { __type_timeout: true, _native: any }`**(마커는 런타임에도 실제로 넣고, 백엔드 페이로드 자리도 타입에 미리 선언). @@ -906,18 +1068,41 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리 전체 정정을 지시 — 같은 세션에 base/reference/research/ROADMAP/ 스파이크/audit까지 전부 반영했고, 역전 기록은 `archive/invalidate-dedup-propagation-reversed.md`. 상세는 3절. +11. ~~**`Time`/`MaxTime`을 `State`로 받을 수 있는가**~~ **[2026-08-19 + 신설·같은 날 해소]** — 허용. 구독이 아니라 `setTimeout`/`cap` 스케줄 + 시점의 폴링(`:Get()`)이라 새 무효화 채널이 안 생겨 5-2절이 인용하던 + `tween-plan.md` 선례("옵션에 두 번째 반응 경로를 만들지 않음")와 + 안 부딪힘. 이미 스케줄된 타이머엔 미반영, 다음 창부터 적용. (5-2절) --- -## 13. 우선순위 / 마일스톤 +## 13. 우선순위 / 마일스톤 — **[2026-08-19 재평가] 결국 순수 슈가로 귀결** **M0 착수를 막지 않음** — 이 문서의 어떤 결정도 디스패치/State 코어 계약을 -바꾸지 않음(11절에서 확인). 다만 `Operator` 슈가와 달리 **순수 슈가가 -아니라 실제 기능 갭**이라(타이머 없이는 사용자 코드로 재현하기 번거롭고, -재현하면 laziness를 깨기 쉬움) 우선순위는 그쪽보다 위로 두는 게 맞아 보임. +바꾸지 않음(11절에서 확인). -**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면. 그 둘이 -서면 언제든 얹을 수 있고, `Blocker` 구현과 **같은 시점에 하는 게 확실히 -쌈** — 1절에서 봤듯 게이트 노드를 공유하므로 따로 하면 같은 걸 두 번 -설계하게 됨. **권고: `Blocker` 구현 시점(M3)에 게이트 노드를 공용으로 -빼두고, Debounce/Throttle 자체는 그 위에 나중에 얹기.** +**[2026-08-19] 옛 서술("`Operator` 슈가와 달리 순수 슈가가 아니라 실제 +기능 갭이라 우선순위를 위로 둔다")은 이번 라운드로 뒤집힘.** 제어 핸들 +설계(5-4절)까지 확정하고 나니, `Debounce`/`Throttle`이 실제로 새로 +필요로 하는 quad-base 코어 표면은 **주입 op 2개(`setTimeout`/`clearTimeout`) +뿐**이고 — 게이트 메커니즘은 `Blocker`가 이미 확정한 gated state 개념 +위에서(1절), 제어 핸들은 이미 확정된 `Ref` 위에서(5-4절) 전부 얹히는 것으로 +드러남. 즉 **quad-base에 새 코어 메커니즘을 추가하지 않는 순수 슈가**다 — +`Animate`/`Operator.*`와 같은 성격. + +**그래도 착수 시점이 뒤로 밀리진 않는다.** 설계 자체가 실제 기능 갭에서 +나온 요청(사용자가 "만들어야 한다"고 직접 지정, `research/ +additional-primitives-plan.md`가 원래 "안 만들어도 된다"고 판단했던 걸 +뒤집은 배경)이라는 사실은 안 바뀌고, 이 문서가 `research/`에서 `base/`로 +승격된 것도 별개로 유효 — 달라지는 건 오직 **구현 우선순위**뿐이다. +순수 슈가라는 게 확인됐으니 `Operator` 콤비네이터 카탈로그와 같은 급으로 +맨 뒤로 미뤄도 됨(다른 기능이 이걸 의존하지 않고, 없어도 다른 기능이 안 +막힘). + +**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면(`setTimeout`/ +`clearTimeout`) + `Blocker`(gated state) + `Ref`. 전부 M3/M8 안에서 +확정되는 것들이라 그 이후 언제든 얹을 수 있다. **`Blocker` 구현 +시점(M3)에 게이트 노드를 공용으로 빼두는 것만은 여전히 그 시점에 해야 +함** — 1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 +설계하게 됨. 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무 +때나 나중에 얹으면 된다. diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index f90037e..e2ffcd0 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -850,7 +850,7 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it "지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면 **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전 — "`nil` 리턴은 파괴가 기본" 절 참고] 파괴됨**(단순 `Visible = - false`도 아니고, 언마운트도 아님 — `rawRemove`. `PopOnly`를 명시 + false`도 아니고, 언마운트도 아님 — `rawRemove`. `Detach`를 명시 반환해야 대신 언마운트+재사용됨). 편의상 `nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라 `:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw @@ -987,15 +987,15 @@ end **⚠️ [재정정, 2026-08-18 구현 전 QA, `/code-review high`로 이 절의 stale 서술 발견] 바로 위 두 문단은 "filter 탈락 = 언마운트(비파괴)"를 결론으로 쓰고 있는데, 그 결론은 이후 재역전됐다.** 지금 유효한 규칙은 "`nil` -리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴" 절(SL-3 해소, +리턴은 파괴가 기본 — `Detach`로만 비파괴" 절(SL-3 해소, `question.md`/`archive/question-resolved.md` 참고) — filter 탈락으로 `updateFn`이 그냥 `nil`을 반환하면 이제 **파괴**(`rawRemove`)가 기본이고, "Instance.new/Destroy 비용을 아끼고 싶다"는 이 절의 동기를 살리려면 -`nil` 대신 명시적으로 `PopOnly`를 반환해야 언마운트+재사용이 된다. 이 +`nil` 대신 명시적으로 `Detach`를 반환해야 언마운트+재사용이 된다. 이 절의 **동기**(matched-item 애니메이션/이벤트가 계속 돌면 안 된다는 문제 자체)는 여전히 유효하지만, "그래서 nil이 곧 언마운트"라는 결론 문장은 -`PopOnly` 신설로 대체됐다 — 이 절을 읽고 filter를 구현할 땐 반드시 위 -"`nil` 리턴은 파괴가 기본" 절도 같이 볼 것. +`Detach`(당시 가칭 `PopOnly`) 신설로 대체됐다 — 이 절을 읽고 filter를 +구현할 땐 반드시 위 "`nil` 리턴은 파괴가 기본" 절도 같이 볼 것. **"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** — item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그 @@ -1097,10 +1097,10 @@ function activateList(self, inst) local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능) local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key]) if result == None then result = nil end -- 편의: None도 nil과 동일 취급 - -- [2026-08-18] PopOnly(가칭)는 "이 자리를 비우되 죽이지는 말라"는 지시. - -- 아래 "PopOnly" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다. - local popOnly = (result == PopOnly) - if popOnly then result = nil end + -- [2026-08-18, 이름 2026-08-19 확정] Detach는 "이 자리를 비우되 죽이지는 말라"는 지시. + -- 아래 "Detach" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다. + local detach = (result == Detach) + if detach then result = nil end if result ~= nil then -- [2026-08-11 일곱 번째 세션] result가 nested Slot이면 그 @@ -1115,10 +1115,10 @@ function activateList(self, inst) -- "`nil` 리턴은 파괴가 기본" 절이 소스: -- (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만** -- — state 교체와 동형, 지우라고 한 적이 없음. - -- (b) PopOnly: **언마운트만**, 재사용은 ud가 홀드. + -- (b) Detach: **언마운트만**, 재사용은 ud가 홀드. -- (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시. if prev ~= nil then - if result ~= nil or popOnly then rawUnmount(self, prev) + if result ~= nil or detach then rawUnmount(self, prev) else rawRemove(self, prev) end end if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준 @@ -1128,7 +1128,7 @@ function activateList(self, inst) end userdata[key] = ud -- result와 무관, 그대로 기록 - -- (PopOnly 재사용은 여기 담긴 { old = ... }가 담당) + -- (Detach 재사용은 여기 담긴 { old = ... }가 담당) newKeyIndex[key] = pos end for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key @@ -1211,7 +1211,7 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 `rawMove`** (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째 세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을 **부분적으로 되돌림** — `nil` 리턴/키 소멸은 다시 **파괴**가 기본이고, - 값 교체와 `PopOnly`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이 + 값 교체와 `Detach`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이 소스) — `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임" 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그 셋을 직접 호출할 일이 없을 뿐. `rawUnmount`는 `rawRemove`의 @@ -1228,7 +1228,7 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로 미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정. -### `nil` 리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기) +### `nil` 리턴은 파괴가 기본 — `Detach`로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기; 이름 2026-08-19 확정) **[재정정]** 2026-08-13 여섯 번째 세션은 "자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"라는 일반 규칙을 세우면서 `:List`의 reconcile까지 @@ -1242,17 +1242,17 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 |---|---|---| | 새 값(`result ~= nil`) | **언마운트만** | 밀려난 것뿐이지 "지워라"가 아님. `state` 교체와 동형이고, `Slot { State }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State` 교체" 절의 확정도 그대로 유지됨 | | `nil` / `None` | **파괴**(`rawRemove`) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 | -| `PopOnly`(가칭) | **언마운트만** + 재사용 대기 | 아래 | +| `Detach` | **언마운트만** + 재사용 대기 | 아래 | | 키가 데이터에서 사라짐 | **파괴**(`rawRemove`) | `nil` 리턴과 같은 의미(그 아이템은 이제 없음) | -**`PopOnly`(가칭) — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.** +**`Detach` — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.** `filter` 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번 -파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`PopOnly`와 함께 userdata를 +파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`Detach`와 함께 userdata를 반환**하면 그 자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다: ```lua -- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다 -return PopOnly, { old = prev, source = ... } +return Detach, { old = prev, source = ... } ``` - **보존 주체는 `userdata`** — reconcile은 `mounted[key]`에서만 뺄 뿐 @@ -1264,7 +1264,7 @@ return PopOnly, { old = prev, source = ... } 전까지 안 지워진다** — 즉 "언제 진짜로 버릴지"를 `updateFn`이 결정한다. - **⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서 발견한 갭).** 그 경우 reconcile의 소멸 루프가 `mounted[key]`/`userdata[key]`를 - **둘 다** 지우는데, `PopOnly`로 홀드 중이던 요소는 `mounted[key]`가 이미 + **둘 다** 지우는데, `Detach`로 홀드 중이던 요소는 `mounted[key]`가 이미 `nil`이라 `rawRemove`(파괴) 대상이 아니다 — 결과적으로 그 요소는 **파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겨 GC 대상이 된다**(Parent는 이미 `nil`). 이건 같은 절의 표가 "키가 사라지면 파괴"라고 @@ -1274,12 +1274,33 @@ return PopOnly, { old = prev, source = ... } (b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침), (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 묻는다. **지금 문서는 (a)를 기본으로 가정하지 않는다** — 결정 전이므로 구현 금지. -- **이름은 가칭** — 사용자 확정: *"PopOnly 확정. 다만 이름은 변경될 수 - 있음. 이름에 대해서는 더 생각해보아야함"*. `question.md` 용어 정리 항목에 - 올려둠. 메커니즘(반환 규약 + userdata 홀드 + 재마운트)은 확정. +- **이름 확정 — `Detach`(2026-08-19).** 처음엔 가칭 `PopOnly`로 도입됐고 + 사용자가 *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더 + 생각해보아야함"*이라고 남겨 `question.md` 용어 정리 항목에 올라가 + 있었음 — 이후 세션에서 후보들을 검토하다 `Detach`로 확정. 근거는 둘: + (1) 이미 있는 `Extract`(바로 아래 불릿, 호출자가 직접 부르는 명령형 + 추출)와 동사가 겹치면 헷갈리는데, `Detach`는 "화면(부모 계층)에서만 + 떼어낼 뿐 관리 주체는 여전히 reconcile"이라는 의미라 `Extract`의 + "소유권을 통째로 호출자에게 넘긴다"는 것과 자연스럽게 구분됨. (2) + `nil`(파괴)과의 대비도 더 직접적으로 드러남. 메커니즘(반환 규약 + + userdata 홀드 + 재마운트)은 그대로 — 원문은 + `session/2026-08-19-02-detach-naming-and-placement.md`. +- **공개 표면 위치 확정 — 패키지 최상위 export(2026-08-19).** `Slot`은 + 함수(팩토리)라 `Slot.Detach`처럼 붙이려면 callable-table+메타테이블이 + 새로 필요한데, sentinel 상수 하나 때문에 그 구조를 들이는 건 과함 + (`conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고 + 구조를 복잡하게 만들지 않는다" 원칙). 대신 `None`과 같은 선례를 따른다 — + `None`도 여러 곳(Slot 요소, Attribute, offsetSource)에서 쓰이는 + sentinel이지만 공개 표면은 패키지 최상위(`quad-base/src/init.luau` + 재노출)이고 실제 정의는 관련 로직 옆(`Dispatch/None.luau`)에 있다. + `Detach`도 같은 패턴 — **정의는 Slot 관련 파일(`Slot.luau` 또는 + `Dispatch/Slot.luau`) 옆에 두고, `init.luau`에서 최상위로 재노출**한다. + 지금은 `:List` reconcile 한 곳에서만 쓰이지만 `None`도 처음엔 그렇게 + 시작해 이후 재사용됐으므로 최상위에 두는 게 자연스럽다. 정확한 파일 + 배치는 M6 구현 시점에 확정. - **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가 이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가 - 직접 부르는 명령형 경로**다. `PopOnly`는 같은 일을 **reconcile 안에서 + 직접 부르는 명령형 경로**다. `Detach`는 같은 일을 **reconcile 안에서 선언적으로** 하기 위한 것이라 서로 대체 관계가 아니다. ### 구독 시점 — `:List()` 호출이 아니라 Slot 마운트 시점, lazy `bindLifetime` @@ -2114,7 +2135,7 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` + `unbindLifetime` + `releaseOwner`를 부름. 2. **`:List`의 `reconcile`** — **[재정정, 2026-08-18 구현 전 QA]** 여기서 - 비파괴가 되는 건 **값 교체와 `PopOnly`뿐**이다. `updateFn`이 `nil`/`None`을 + 비파괴가 되는 건 **값 교체와 `Detach`뿐**이다. `updateFn`이 `nil`/`None`을 반환하거나 키가 데이터에서 사라진 경우는 **다시 파괴가 기본**(사용자 판정) — 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절이 소스. 2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은 @@ -2125,7 +2146,7 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 `:List` 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"**이되, **`:List`에서 `nil`을 반환하는 것 자체가 "지우라고 한 것"으로 센다** — `Ref`/`Attribute`의 "지울 거면 명시적으로" -철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `PopOnly`로 그 의도를 +철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `Detach`로 그 의도를 명시한다. `unmountSlotTree`는 `destroySlotTree`가 하는 일 중 **실제 파괴와 자식 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index bf6f60d..70a6c4f 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -194,9 +194,10 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 상류로 올라가 재계산하고, 그 결과를 캐시에 넣고, `invalid`를 끈다. - **emit 전파를 늦추거나 흡수할 수 있는 건 명시적인 게이트 요소뿐** — - 지금은 `Blocker`(`base/blocker-plan.md`)가 유일하고, 앞으로 추가된다면 - `research/debounce-throttle-plan.md`의 시간 기반 게이트가 같은 자리에 - 들어옴. **평범한 State는 절대 신호를 삼키지 않는다.** + 지금은 `Blocker`(`base/blocker-plan.md`)가 유일하고, + `base/debounce-throttle-plan.md`의 시간 기반 게이트가 같은 자리에 + 들어감(설계 확정, 구현은 아직). **평범한 State는 절대 신호를 삼키지 + 않는다.** - **[2026-08-14 정정 — 중요]** 이 자리엔 원래 "이미 `invalid`였다면 그 아래로 더 전파하지 않는다(다이아몬드 중복 워크 방지)"라고 적혀 있었으나 **틀린 서술이라 뒤집힘**. 그대로 두면 `:Get()`을 호출하지 않는 diff --git a/.claude/question.md b/.claude/question.md index 5450334..200b67d 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -48,11 +48,16 @@ "문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로 보완하기로 같이 확정. 코퍼스 반영 완료 — `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절. -- **`PopOnly`(가칭, 2026-08-18 신설)**: `:List` reconcile에서 "파괴하지 말고 - 자리만 비우라"를 지시하는 반환 센티널(`base/slot-plan.md`의 "`nil` 리턴은 - 파괴가 기본" 절). 메커니즘은 확정됐고 **이름만 열려 있음** — 사용자: - *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더 - 생각해보아야함"*. +- **[해소됨, 2026-08-19] `PopOnly` → `Detach` 확정** — 원문과 근거는 + `archive/question-resolved.md`. 요지: 이미 있는 + `Extract`(호출자가 직접 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데, + `Detach`는 "화면(부모 계층)에서만 떼어낼 뿐 관리 주체는 여전히 + reconcile"이라는 뜻이라 `Extract`의 "소유권을 통째로 넘긴다"와 자연스럽게 + 구분되고, `nil`(파괴)과의 대비도 더 직접적으로 드러남. 공개 표면 위치도 + 같이 확정 — `Slot`이 함수(팩토리)라 `Slot.Detach`처럼 붙이려면 + callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라 + **패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`의 + "`nil` 리턴은 파괴가 기본" 절. - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. @@ -134,35 +139,13 @@ 몫. 같은 리서치에서 포함 범위도 새로 갈렸음 — 비트/비교 연산자 그룹과 `Sub`/`Div`는 리액티브 콤비네이터로서 선례가 전혀 없어 드랍 후보로, `Clamp`/`Min`/`Max`는 선례가 강해 추가 후보로, Debounce/Throttle은 - 업계에 흔하지만 `Blocker`와는 다른 시간 기반 메커니즘이라 이 카탈로그가 - 아니라 quad-roblox 쪽 별도 프리미티브로 다룰지 판단이 필요한 별개 질문으로 - 분리됨. **[2026-08-13 세션 신설]** `Alternative`(nil 대체값, coalesce/`??`/ + 업계에 흔하지만 `Blocker`와는 다른 시간 기반 메커니즘이라 이 카탈로그 + 밖 별개 질문으로 분리됐었음 — **[2026-08-19 해소]** 그 별개 질문은 + `base/debounce-throttle-plan.md`로 전부 해소·승격 완료, 더 이상 판단 + 대기 아님. **[2026-08-13 세션 신설]** `Alternative`(nil 대체값, coalesce/`??`/ 엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정 규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`. 구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함. -- **[신설, 2026-08-14] Debounce/Throttle — 남은 열린 질문(개수는 - `research/debounce-throttle-plan.md` 12절이 소스, 여기서 반복 안 함)** — - 사용자 요청("`Blocker`와 유사하게 만들어야 한다")으로 - `research/debounce-throttle-plan.md` 신설. 네 번의 리뷰 라운드로 - **패키지 경계·주입 op 시그니처·`Timeout` 타입·동작 정식화는 전부 - 확정**됐고, 사용자 취향/방향이 갈리는 것만 남음 — 주요 4개: - - **의미론** — 창이 열려 있는 동안 `:Get()`이 최신값을 주는가 - (`Blocker`와 동일) vs 지연된 값을 주는가(권장, VueUse/RxJS와 일치). - - **제어 핸들** — `state:Apply(Debounce{...})`로 끝낼지, `Blocker`와 - 글자 그대로 같은 모양(`Debouncer` 객체 + `state:Debounce(d)` + - `d:Flush()`/`:Cancel()`)으로 갈지. "검색창에서 Enter 누르면 즉시 - 커밋"류가 잦다고 보면 후자가 처음부터 나음. - - **이름** — Roblox 관용 "debounce"(재진입 방지 불리언)와 정면 충돌. - 업계 표준 이름을 유지하고 문서로 경고할지, 다른 이름을 쓸지. - - **`Time = 0` 허용 여부** — "이번 스텝 합치기"로 유용하지만 스케줄러 - 타이밍에 의존하는 준-`Blocker`가 됨. - 이 외에 사소한 것 둘도 12절에 열려있음: 공개 생성자 2개 개정안이 - 사용자 확인만 남은 상태, `base/blocker-plan.md`가 인용하는 "`Get()`은 - 라이브 레퍼런스" 문구에 한 줄 명확화가 필요(확정 문서 수정이라 승인 - 대기, 아직 안 건드림). 우선순위 낮음 — M0를 막지 않고 코어 계약도 안 - 건드림. 다만 **M3에서 - `Blocker`를 구현할 때 게이티드 노드를 공용으로 빼두는 것**만은 그 - 시점에 해야 함(따로 하면 같은 설계를 두 번 함). - **중첩 State 평탄화 `State>` → `State`(2026-08-13 여섯 번째 세션 신설, 백로그)** — **[근거 축소, 열네 번째 세션]** 원래 이 항목의 주 근거는 "깊은 체인에선 힌트가 `nil`로 전달돼 깜빡임 방지가 꺼진다"는 @@ -181,7 +164,7 @@ `getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자 탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전 필요**, `base/store-plan.md`의 "타입 추론 문제" 절. -- **[신설, 2026-08-18 커밋 전 `/code-review high`] `PopOnly`로 홀드 중이던 +- **[신설, 2026-08-18 커밋 전 `/code-review high`] `Detach`로 홀드 중이던 요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면 `mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가 `userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고 diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 5efb586..d530de5 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -91,7 +91,8 @@ State 메소드로 두려던 초기 폼팩터가 기각된 경위만 여전히 세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황.~~ **[2026-08-14 뒤집힘]** 사용자가 직접 "`Blocker`와 유사하게 만들어야 한다"고 지정 — 위 "빈 자리 아님" 판정은 더 이상 유효하지 않음. - 설계 초안은 `research/debounce-throttle-plan.md`. (원 판정의 근거였던 + 설계는 `base/debounce-throttle-plan.md`(2026-08-19 전부 해소돼 + `research/`에서 승격). (원 판정의 근거였던 "세 레포에 없다"는 관찰 자체는 사실이지만, 2026-08-12 열아홉 번째 세션의 외부 리서치에서 RxJS/VueUse 등 Roblox 밖에선 가장 흔한 콤비네이터 카테고리 중 하나로 확인돼 근거로서의 무게가 이미 약해져 있었음.) diff --git a/.claude/research/operator-sugar-plan.md b/.claude/research/operator-sugar-plan.md index 05e35c3..3b94433 100644 --- a/.claude/research/operator-sugar-plan.md +++ b/.claude/research/operator-sugar-plan.md @@ -237,8 +237,9 @@ introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어 모양을 벗어남 — `Operator.*`(quad-base, 엔진 무종속)에 넣을 수 있는 게 아니라 quad-roblox 쪽 별도 프리미티브(Tween과 비슷한 위치)로 다뤄야 할 가능성이 큼. 이 문서 범위 밖의 별도 설계 질문으로 분리해서 판단 필요. - **[2026-08-14, 분리 완료]** 사용자 요청으로 `research/debounce-throttle-plan.md` - 신설 — 이 항목은 그 문서로 이관됐고 여기선 더 이상 다루지 않음. 그 + **[2026-08-14, 분리 완료, 2026-08-19 설계 전부 해소돼 `base/debounce-throttle-plan.md`로 + 승격]** 사용자 요청으로 신설 — 이 항목은 그 문서로 이관됐고 여기선 더 + 이상 다루지 않음. 그 문서의 결론 두 가지만 여기 관련 있음: (1) 붙이는 모양은 이 카탈로그와 똑같이 `factory(self)` + `:Apply`라 위 "왜 `:Apply`인가" 규칙이 그대로 적용됨, (2) 다만 배치는 위 추측과 달리 **quad-base + 주입 op 2개**로 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index d53e8db..a7db24e 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1504,3 +1504,34 @@ key로 완료 여부 기록) 방식으로 직접 해소하는 아이디어도 포함)가 있었는데 그날 session/ 파일은 1개뿐, 2026-08-19는 이 세션 전까지 (QA 3라운드 커밋 1개가 있었음에도) 0개였음. 과거 대화 트랜스크립트에 접근 불가라 그 공백을 사후 재구성하는 건 허위 기록 위험이 있어 보류 — 처리 방침은 사용자 확인 대기. + +## 2026-08-19 — `PopOnly` → `Detach` 리네임 + 공개 표면 위치 확정 + +원문: `session/2026-08-19-02-detach-naming-and-placement.md` + +가칭으로 남아있던 `:List` reconcile의 비파괴 반환 sentinel `PopOnly`의 +이름을 사용자 요청으로 브레인스토밍(풀링/보관 은유 계열, "언마운트+보류" +계열, 기존 조어 구조 계열 후보 제시) → 사용자가 이미 있는 `Extract`(명령형 +추출)와 동사가 안 겹치면서 "화면에서만 떼고 관리 주체는 그대로"라는 뜻을 +살릴 수 있다는 이유로 `Detach`를 골라 확정. 이어서 공개 표면 위치("Slot이 +함수라 `Slot.Detach`로 못 붙임")도 논의 — `None` sentinel의 선례(공개 +표면은 패키지 최상위 export, 실제 정의는 관련 로직 옆)를 그대로 따르기로 +확정. `base/slot-plan.md`/`question.md`/`todos.md`/ +`archive/question-resolved.md`/`ROADMAP.md` 전량 반영, `doc-check.py` +ERROR 0. + +## 2026-08-19 — `Debounce`/`Throttle` 마지막 판단 대기 4개 닫음, `base/`로 승격 + +원문: `session/2026-08-19-03-debounce-throttle-final-close.md` + +`research/debounce-throttle-plan.md` 12절에 남아있던 이름/의미론/제어 +핸들/`Time=0`을 리뷰 — 이름은 유지+문서 경고로 사용자가 먼저 확정, 나머지 +셋은 논의를 거쳐 (A) emit-gate 채택(값-지연 (B)는 laziness와 상충해 +철회), 제어 핸들은 개별 `Ref` 아웃파라미터+전체 팩토리 브로드캐스트(weak +레지스트리)로 수렴, `Time=0`은 허용으로 확정. 부수로 `Time`/`MaxTime`을 +`number|State`로 확장(스케줄 시점에만 폴링, 구독 아님)도 결정. +전부 닫히면서 문서가 `research/`에서 `base/`로 승격됐고, 제어 핸들 설계가 +`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힌다는 게 +드러나 **순수 슈가로 재평가**(옛 "실제 기능 갭이라 우선순위 위" 서술 +철회). `ROADMAP.md`/`question.md`/`todos.md`/`README.md`/ +`source-state-plan.md`/`blocker-plan.md` 전량 반영. diff --git a/.claude/session/2026-08-19-02-detach-naming-and-placement.md b/.claude/session/2026-08-19-02-detach-naming-and-placement.md new file mode 100644 index 0000000..6084b4a --- /dev/null +++ b/.claude/session/2026-08-19-02-detach-naming-and-placement.md @@ -0,0 +1,72 @@ +# 2026-08-19 — `PopOnly` → `Detach` 리네임 + 공개 표면 위치 확정 + +**요청**: `base/slot-plan.md`에 가칭으로 남아있던 `PopOnly`(`:List` +reconcile에서 "파괴하지 말고 자리만 비우라"는 반환 센티널)의 이름을 사용자가 +직접 못 정해 아이디어를 요청 — "지금은 다른 에이전트들이 있어서 쓰지는 +말아달라"는 조건으로 브레인스토밍만 먼저 진행. + +## 1. 메커니즘 재확인 + +`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절을 다시 읽고 의미를 +정리: `:List` reconcile에서 `updateFn`이 반환하는 sentinel로, 이 자리를 +언마운트(`Parent = nil`)는 하되 **파괴는 하지 말고 userdata를 들고 있다가 +나중에 재사용**하라는 지시 — 목적은 `Instance.new`/`Destroy` 비용을 아끼는 +재사용(오브젝트 풀링에 가까움). `nil` 반환(파괴)과도, 새 값 반환(언마운트만, +즉시 교체)과도 구분되는 세 번째 경로. + +## 2. 이름 후보 제시 + +풀링/보관 은유 계열(`Bench`/`Stash`/`Shelve`/`Park`), "언마운트+보류" 계열 +(`Hold`/`Detach`/`Suspend`), 기존 조어 구조를 살리는 계열(`PopKeep`/ +`KeepOnly`/`SoftPop`)을 제시하고 `Bench`(가장 직관적인 은유) 또는 `Hold` +(문서가 이미 "홀드"라는 용어를 쓰고 있어 도입 마찰이 적음) 둘 중 하나를 +추천. + +## 3. 사용자가 `Detach` 선택 — 근거 검증 + +사용자: *"Detach 많이 괜찮아보이는데, Slot 에서 뽑아내다라는 Extract 를 +지금 쓰고 있으니까, 이미 있는 Extract 를 쓰긴 뭐하니 Detach 를 넣는건 +어때보임?"* + +`Extract`/`ExtractAll`은 호출자가 직접 부르는 **명령형** 추출(소유권을 +통째로 호출자에게 넘김)인 반면, `Detach`(구 `PopOnly`)는 reconcile 내부에서 +여전히 userdata로 추적하며 화면(부모 계층)에서만 떼는 **선언적** sentinel — +"소유권 이관" vs "내부 보류 상태"로 성격이 갈려 `Extract`와 이름이 겹쳐도 +혼동 소지가 적다고 판단, 채택 추천. Roblox 개발자가 `Attachment`/ +`WeldConstraint`류의 물리적 "detach"를 먼저 연상할 여지는 있지만 "부모 +계층에서 떼어낸다"는 실제 동작과 크게 어긋나지 않아 큰 문제 아니라고 정리. +`Detach` 채택 확정. + +## 4. 공개 표면 위치 — 최상위 export + +사용자: *"근데 그러면, Detach 는 어디에서 던져줘야한다 생각함? 그냥 base +모듈 바로 최상위? Slot 이랑 연관인데 Slot 옆에 존재하게 되는게 뭔가 느낌이 +애매한거 같긴 함. 그런데 Slot 은 또 함수라서, 뭔가 넣기 애매해. 넣으려면 +메타테이블이 되어야하기도 하고. 그냥 최상위가 좋음?"* + +`architecture.md`의 패키지 트리를 확인해 `None` sentinel의 선례를 찾음 — +`None`도 Slot 요소/Attribute/offsetSource 등 여러 곳에서 쓰이는 sentinel +이지만, 공개 표면은 패키지 최상위(`quad-base/src/init.luau` 재노출)이고 +실제 정의는 관련 로직 옆(`Dispatch/None.luau`)에 있음(공개 표면과 구현 +위치가 분리돼 있는 기존 패턴). `Detach`도 같은 성격의 sentinel이므로 같은 +자리(최상위)에 두는 게 일관적이라고 판단 — `Slot`이 함수라 `Slot.Detach` +형태로 붙이려면 callable-table+메타테이블이 새로 필요한데, sentinel 상수 +하나 때문에 그 구조를 들이는 건 `conventions.md`의 "드문 오용이나 가상의 +미래 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙에 +안 맞음. 정의 파일 위치(`Slot.luau` 또는 `Dispatch/Slot.luau`)는 `None`과 +같은 패턴(정의는 관련 로직 옆, 재노출만 `init.luau`에서)을 따르되 정확한 +배치는 M6 구현 시점에 확정하기로 함. + +## 5. 반영 + +사용자: *"이거 base/slot-plan.md에 정리해서 반영해줘. 세션 기록도 +남겨주고, 핸드오버 준비해줘. 적절히 정해진것 같아서 커밋해줘도 될듯."* + +`base/slot-plan.md`("이름은 가칭" 불릿을 "이름 확정"/"공개 표면 위치 확정" +두 불릿으로 교체, 본문 전체의 `PopOnly` 표기를 `Detach`로 치환하되 사용자 +발언 직접 인용과 히스토리 서술은 유지), `question.md`(용어 정리 1번 항목을 +`DI`→`D`와 같은 형식의 `[해소됨]` 항목으로 교체, 3번의 남은 열린 항목 이름만 +치환), `todos.md`(00번/2번 항목 갱신), `archive/question-resolved.md`(새 +`[해소됨, 2026-08-19]` 절 추가), `ROADMAP.md`(M6 체크리스트의 `PopOnly` +표기 치환)에 반영. 인덱스 레이어(`README.md`)와 `session-summary.md`도 +같이 갱신, 이 문서가 그 포인터. diff --git a/.claude/session/2026-08-19-03-debounce-throttle-final-close.md b/.claude/session/2026-08-19-03-debounce-throttle-final-close.md new file mode 100644 index 0000000..64c5cda --- /dev/null +++ b/.claude/session/2026-08-19-03-debounce-throttle-final-close.md @@ -0,0 +1,100 @@ +# 2026-08-19 — `Debounce`/`Throttle` 마지막 판단 대기 4개 닫음, `base/`로 승격 + +**요청**: `research/debounce-throttle-plan.md` 12절에 남아있던 판단 대기 +항목(이름/의미론/제어 핸들/`Time = 0`) 중 사용자가 이미 답을 준 이름(Q1)을 +빼고 나머지를 리뷰. "지금은 다른 에이전트들이 있어서 읽기만 해달라"는 +조건으로 시작 — 다른 에이전트들이 끝난 뒤 반영·핸드오버까지 진행. + +## 1. 리뷰 준비 — 문서 재확인, 새 질문 발견 + +문서를 다시 읽는 과정에서 12절에 없던 새 항목이 나옴 — **`Time`/`MaxTime`을 +`State`로 받을 수 있는가**(5-2절은 당시 "plain 값만"으로 확정돼 있었음). +사용자가 먼저 판단을 제시: *"1. 이름 -> Debounce Throttle 유지를 하는게 +나도 맞다고 봄. 관용 표현과 다르다는 경고로 넣기로 문서화 계획만 되도록 +해줘 + debounce-throttle 에 시간은 state 로 받을 수 있을지는 확인 필요한 +부분으로 보임."* + +## 2. Time as State + +사용자: *"Throttle 과 Debounce 는 state 중간이나 말단에 들어가서 emit 을 +제어하는거라, tween 과 완전 다름. 그리고 한번 바인딩 된다면 다시 Time +같은걸 바꿀 수 없게되어버림. (…) 필요 시마다 get 해서 쓰긴 하는거야."* +즉 구독이 아니라 **`setTimeout` 호출 시점에만 폴링**하자는 제안 — 검토 +결과 5-2절이 인용하던 `tween-plan.md`의 "옵션에 두 번째 반응 경로를 +안 만든다" 근거와 안 부딪힘(폴링은 새 무효화 채널이 아님). 후속 확인: +*"이미 스캐쥴 한건 반영 안함. Animate 슈거랑 비슷함. 오직 setTimeout +수행할 때 읽음. MaxTime 같은 다른것도 number|state 주는거 +가능해보임."* → `Time`/`MaxTime` 둘 다 `number | State` 허용, +이미 스케줄된 타이머엔 미반영·다음 창부터 적용으로 확정. + +## 3. Q2 — 의미론, (B) value-hold 철회 + +사용자: *"Get 을 지연된 값으로 만들어낸다 하면, 이전에 Compute 안 했던걸 +다시 컴퓨팅 하기 어려움. 리프 바인딩이라 가정하면 그냥 Blocker 동작이랑 +동일하는게 맞다고 생각하는데. (…) value-hold 라는걸 해야하는 순간부터, +Get을 명시적으로 호출하고 들고 있어준다는게 되지 않느냐는것. (…) 이전에 +Get하지 않았다면 아주 이전 값일 가능성이 있는데. 이는 어떻게 제어하는가?"* + +검토 결과 지적이 정확함을 확인 — (B)의 "held value" 계약은 직전 커밋에서 +`invalid = true`로 세팅된 채 아무도 안 읽다가 새 창이 열리는 경로에서 +조용히 깨지고, 이걸 고치려면 창이 열리는 순간 upstream을 강제로 pull해야 +해서 laziness와 정면 충돌(Throttle의 주 용례인 "비싼 연산 게이팅"에서 +치명적). **(A) emit-gate로 확정, (B) 철회.** 부수로 Q7(blocker-plan.md +명확화)도 (A)를 택하면서 자동 소멸. + +## 4. Q4 — 제어 핸들, 세 라운드 만에 수렴 + +1차 제안(에이전트): 게이트가 반환하는 State 자체에 `:Flush()`/`:Cancel()`을 +직접 붙임. 사용자 반박: *"State 가 확장되거나 해야함. 상태 따라 debounce +에 대한 메서드를 실행할 수 있거나 없고. 타입이 나뉘거나 함. 차라리 +Debounce() 할 때, 디바운싱을 진짜 하는건 state 따로이지만, Flush Cancel +은 한 핸들에서 수행 가능하고(마치 On/Off 처럼) 그게 전파 되는건 어떻게 +봄?"* — State 서브타입 분기 문제를 정확히 짚음. + +2차 제안: `base/ref-plan.md`의 `Ref`(채워지길 기다리는 빈 박스) 패턴을 +재사용 — 옵션 필드로 `Handle = Ref()`를 받아 게이트 생성 시 채움. 사용자 +동의, 이어서 대칭 확장 질문: *"Debounce{} 결과에 :Flush 하면 있는 전체가 +플러싱되고, 하나만 하고 싶으면 Handler 를 넣어 쓰게 하면 되고, +정확해보임."* → 개별은 `Ref` 아웃파라미터, 전체는 팩토리 자신의 +`:Flush()`/`:Cancel()`(모든 인스턴스에 브로드캐스트)로 최종 확정. + +`:Apply()`를 계속 쓰는 이유도 확인: *"왜 Apply 일 이유가 있어? (…) 내부적으로 +Blocker 로 동작하기 때문임?"* — 두 근거 다 유효(① `Operator` 관용구가 +이미 "factory(self) + `:Apply`"로 확정, ② 내부적으로 `Blocker`와 같은 +gated state를 공유) 확인 후 동의. + +전체 브로드캐스트의 구현 함정(weak 레지스트리 필요, GC 방해 방지) 지적에 +사용자 동의: *"1. 은 동의, weak 로 전부 가지고 있으면 돼."* `Cancel`도 +`Flush`와 대칭 포함 확정: *"Cancel도 대칭으로 포함해줘."* + +## 5. Q8 — `Time = 0` + +사용자: *"Q8 은 금지할 이유가 없어보이긴 함. 그냥 defer 될 수 있다고만 +알리면 별 문제 없음."* → 허용, "defer될 수 있음"만 문서화, 별도 이름/에러 +없음. + +## 6. 부수 발견 — 순수 슈가로 귀결 + +사용자: *"이건 어쩌다 보니 사실상 슈거가 되었고, setTimeout 만 나중에 +진짜 quad-base 에 추가적으로 들어가는 부분이 될듯. 표면 상 아주 나중에 +구현되어도 괜찮아 보이는데, 지금 구현 상 base 승격에 문제 없어보임."* +제어 핸들까지 확정되면서 새로 필요한 quad-base 코어 표면이 주입 op +2개(`setTimeout`/`clearTimeout`)뿐임이 드러남 — 게이트는 `Blocker`가 이미 +확정한 gated state 위, 핸들은 이미 확정된 `Ref` 위에 전부 얹힘. 옛 +13절의 "순수 슈가가 아니라 실제 기능 갭이라 우선순위를 위로 둔다"는 +서술을 뒤집고, `Operator.*`와 같은 급의 후순위로 재평가. + +## 7. 반영 + +*"다른 에이전트들 다 끝났어, 이제 반영해줘. 세션 기록도 남기고 핸드오버 +준비해줘. 끝나면 커밋하면 돼."* + +`base/debounce-throttle-plan.md`(구 `research/debounce-throttle-plan.md`, +`research/`에서 승격 — 4차 리뷰 배너, 4/5-2/5-3/5-4/7/9-1/12/13절 갱신, +`DebounceHandle`/`Timeout` 등 타입 확장, 의사코드에 `readTime`/weak +레지스트리/`Flush`·`Cancel` 반영), `ROADMAP.md`(백로그 항목 경로+서술 +갱신), `.claude/todos.md`(4번 항목), `.claude/question.md`(3번 항목 +전체 제거 — 전량 해소), `.claude/README.md`(research→base 테이블 이동), +`base/source-state-plan.md`/`base/blocker-plan.md`(경로 참조 갱신)에 +반영. `archive/`·`session/`의 과거 경로 참조는 히스토리 문서라 그대로 +둠(`doc-check.py`가 파일명 기준 폴백 매칭이라 깨지지 않음). diff --git a/.claude/todos.md b/.claude/todos.md index 2ca18f1..7cd86ff 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -59,12 +59,15 @@ 시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도. - **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md` `:Unsubscribe()` 절) — M3 착수 전 확인. - - **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림. - - **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) — - 지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. **[정정, - 2026-08-18 `/code-review high` — `ROADMAP.md`의 M6 PopOnly 체크박스와 - 대조해 발견] M6(`:List`가 있는 마일스톤) 착수 전 필요** — M8(`Ref`) - 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 지나칠 위험이 있었음. + - **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면 + 위치도 `None`과 같은 최상위 export로 같이 확정). 원문은 + `archive/question-resolved.md`. + - **`Detach`(구 `PopOnly`) 홀드 중 키가 사라졌을 때의 처분** + (`base/slot-plan.md`) — 지금 의사코드대로면 파괴도 반환도 안 되고 + 참조만 끊김. **[정정, 2026-08-18 `/code-review high` — `ROADMAP.md`의 + M6 `Detach` 체크박스와 대조해 발견] M6(`:List`가 있는 마일스톤) 착수 + 전 필요** — M8(`Ref`) 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 + 지나칠 위험이 있었음. - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. @@ -142,7 +145,8 @@ 2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이 목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정) — 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영 - 완료로 목록에서 빠짐**, 대신 `PopOnly`(가칭)가 새로 들어옴): `Slot`(2순위), + 완료로 목록에서 빠짐**, **[2026-08-19] `PopOnly`→`Detach`도 확정·반영 + 완료로 목록에서 빠짐**): `Slot`(2순위), `canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지 방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/ `Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위). @@ -174,15 +178,17 @@ (`debug-tooling-plan.md`/`documentation-plan.md`/ `documentation-content-map.md`/`framework-comparison-findings.md`/ `operator-sugar-plan.md`). - **[2026-08-14 추가, 성격이 다름]** 시간 기반 전파 게이트 - `Debounce`/`Throttle`(`research/debounce-throttle-plan.md`)도 백로그이긴 - 하나 위 항목들과 달리 **사용자가 직접 요청한 실제 기능 갭**이고 순수 - 슈가가 아님 — M0/M3를 막지는 않지만, **M3에서 `Blocker`를 구현할 때 - 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 해야 함**(따로 - 하면 같은 설계를 두 번 함). 주입 op 2개(`setTimeout`/`clearTimeout`)가 - 백엔드 팩토리 표면에 추가될 예정이라는 것도 M1 설계 시 인지. 설계는 - 네 라운드로 대부분 확정됐고 남은 열린 질문은 `question.md` 3번(개수는 - 거기도 반복 안 함 — 소스는 `research/debounce-throttle-plan.md` 12절). + **[2026-08-14 추가, 2026-08-19 설계 전부 해소 후 `base/`로 승격]** 시간 + 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`)도 + 백로그이지만 위 항목들과는 발단이 다름 — **사용자가 직접 요청한 실제 + 기능 갭**에서 시작됨(그 문서 13절). 다만 제어 핸들 설계까지 닫히고 나니 + 실제로 quad-base에 새 코어 메커니즘을 추가하지 않는 **순수 슈가**로 + 확인돼(같은 절), 위 항목들과 우선순위는 다시 같아짐 — M0/M3를 막지 + 않고, **M3에서 `Blocker`를 구현할 때 게이티드 노드를 공용 `Gate`로 + 빼두는 것만은 그 시점에 해야 함**(따로 하면 같은 설계를 두 번 함). + 주입 op 2개(`setTimeout`/`clearTimeout`)가 백엔드 팩토리 표면에 + 추가될 예정이라는 것도 M1 설계 시 인지. 남은 열린 질문 없음(구 + `question.md` 3번, 전량 해소로 항목 자체가 빠짐). **[2026-08-18 추가]** 사용자 아이디어 메모 두 건도 같은 성격의 백로그로 신설 — 스크롤 최적화 외부 유틸 `quad-roblox-fastscroll` (`research/fastscroll-plan.md`, 선행으로 `Visible=false`일 때 diff --git a/ROADMAP.md b/ROADMAP.md index 822eb25..bc6026c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -395,7 +395,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함 (자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈). **쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고 - `:List`의 `reconcile` 중 **값 교체와 `PopOnly`(가칭) 경로만**. + `:List`의 `reconcile` 중 **값 교체와 `Detach` 경로만**. **여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고 **[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`이 `nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의 @@ -479,14 +479,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글 아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면 저비용 재사용 경로. - **[2026-08-18 신설] `PopOnly`(가칭) 반환 경로** — `updateFn`이 - `PopOnly, { old = ..., source = ... }`를 반환하면 그 자리는 **파괴하지 - 않고 `Parent = nil`로만 내려와** Slot에서 빠지고, 보존은 반환한 - userdata가 담당(다음 사이클에 거기서 `old`를 꺼내 반환하면 재마운트). - `Instance.new`/`Destroy` 비용을 아끼는 filter용 경로. - **⚠️ 이름은 가칭이고, "키가 데이터에서 사라졌을 때 PopOnly로 홀드 - 중이던 요소를 어떻게 처분하는가"는 미결** — 착수 전 결론 필요 - (`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저, + **[2026-08-18 신설, 이름 2026-08-19 확정] `Detach` 반환 경로** — + `updateFn`이 `Detach, { old = ..., source = ... }`를 반환하면 그 + 자리는 **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠지고, + 보존은 반환한 userdata가 담당(다음 사이클에 거기서 `old`를 꺼내 + 반환하면 재마운트). `Instance.new`/`Destroy` 비용을 아끼는 filter용 + 경로. 공개 표면은 `None`과 같이 패키지 최상위 export(`base/ + slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절). + **⚠️ "키가 데이터에서 사라졌을 때 `Detach`로 홀드 중이던 요소를 + 어떻게 처분하는가"는 미결**(이름과 무관한 별개 항목) — 착수 전 결론 + 필요(같은 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저, `userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`가 `prev`보다 앞이었음). **`updateFn`의 `index`는 `keyFn`의 raw `index`(원본 `data` 배열 @@ -907,10 +909,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 테스트는 2026-08-13 세 번째 세션에 불필요로 해소됨 — `archive/question-resolved.md` 참고, v2엔 대응 개념 자체가 없음) - [ ] Slot 형제 순서 보장(다중 백엔드 관점) — Roblox만이면 급하지 않음 -- [ ] **[2026-08-14 신설]** 시간 기반 전파 게이트 `Debounce`/`Throttle` - (`research/debounce-throttle-plan.md`) — **M3에서 `Blocker`를 구현할 - 때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할 것** - (둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함). +- [ ] **[2026-08-14 신설, 2026-08-19 설계 전부 해소 후 `base/`로 승격]** + 시간 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`) + — 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을 + 추가하지 않는 **순수 슈가**로 확인됨(`Blocker`의 gated state + `Ref` + + 아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **M3에서 `Blocker`를 + 구현할 때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할 + 것**(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함). 프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음. 주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`, Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라