design: Debounce/Throttle 마지막 판단 대기 4개 닫고 base/로 승격

의미론은 (A) emit-gate로 확정(value-hold 안은 laziness와 상충해 철회),
제어 핸들은 개별 Ref 아웃파라미터 + 전체 팩토리 브로드캐스트(weak
레지스트리)로 수렴, Time/MaxTime은 number|State<number> 허용(스케줄
시점에만 폴링), 이름은 Debounce/Throttle 유지로 확정. 전부 닫히면서
quad-base에 새 코어 메커니즘을 안 더하는 순수 슈가로 재평가됨(Blocker의
gated state + Ref + 주입 op 2개 위에 전부 얹힘) — 우선순위 서술도 갱신.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey-agent-selene 2026-08-19 02:11:11 +09:00
parent 2348ea8058
commit 14f733dde1
No known key found for this signature in database
11 changed files with 474 additions and 186 deletions

View file

@ -56,6 +56,7 @@
| `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `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` 포인터로 압축 | | `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)) | | `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<number>`(스케줄 시점에만 폴링) 허용. 이름은 `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 착수 전 확인) | | `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<T>.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | | `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<T>.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`와 동일) | | `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번 절은 이제 해소된 항목만 남음 | 하 — 배경 자료, 더 이상 사용자 판단 대기 상태 아님 | | `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`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `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/` 스파이크 실측만 남음 | | `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<State<T>|T>``State<T>` 평탄화 항목 신설(백로그) — `State<State<T>>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | | `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리(**[2026-08-19]** 그 질문은 `base/debounce-throttle-plan.md`로 전부 해소·승격 완료), `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State<State<T>|T>``State<T>` 평탄화 항목 신설(백로그) — `State<State<T>>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 |
| `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` 허용 |
| `quad-recursive-acronym.md` | **[2026-08-14 신설]** GNU/WINE류로 `Quad`를 재귀 약어화하는 카피 브레인스토밍 — 설계 결정도 착수 게이팅도 아니고 나중에 README.md 헤딩 등에 쓸 캐치프레이즈 후보 모음. 자학 개그 방향(기각)과 지연평가/재귀·커링/펑터/클로저를 자랑하는 방향(채택 후보, 미확정) 정리 | 하 — 카피 소재, 설계 상의 필요 없음. 사용자가 최종 문구 고르면 반영 | | `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 코어 구현 시점까지 미결 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 |
| `doc-include-plan.md` | **[2026-08-14 신설]** 문서 stale 감소용 include 도구 `doc-include.py`(가칭) — 원본 파일에 `<!--#summary-->` 류 마커로 요약 구간을 표시해두면 인용하는 문서가 그 구간을 기계적으로 추출해 붙여넣게 하는 도구. `doc-check.py`(사후 탐지)와 짝을 이루는 사전 차단 장치. AsciiDoc tagged include/markdown-magic이 선례, build vs buy 검토 후 Python 표준 라이브러리로 직접 제작(~100줄) 채택. 파일럿은 `.claude/session-summary.md``.claude/session/*.md` 요약 마커부터(CLAUDE.md 분할로 목적지가 "통째로 생성되는 파일"이 돼 단방향 생성으로 단순화됨) | 하 — M0/설계 게이트와 무관한 메타 도구. **[2026-08-16 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) | | `doc-include-plan.md` | **[2026-08-14 신설]** 문서 stale 감소용 include 도구 `doc-include.py`(가칭) — 원본 파일에 `<!--#summary-->` 류 마커로 요약 구간을 표시해두면 인용하는 문서가 그 구간을 기계적으로 추출해 붙여넣게 하는 도구. `doc-check.py`(사후 탐지)와 짝을 이루는 사전 차단 장치. AsciiDoc tagged include/markdown-magic이 선례, build vs buy 검토 후 Python 표준 라이브러리로 직접 제작(~100줄) 채택. 파일럿은 `.claude/session-summary.md``.claude/session/*.md` 요약 마커부터(CLAUDE.md 분할로 목적지가 "통째로 생성되는 파일"이 돼 단방향 생성으로 단순화됨) | 하 — M0/설계 게이트와 무관한 메타 도구. **[2026-08-16 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) |

View file

@ -25,8 +25,9 @@ State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것.
지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기 지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기
`invalid` 상태와 무관하게 **항상** 아래로 전파하고(`base/source-state-plan.md` `invalid` 상태와 무관하게 **항상** 아래로 전파하고(`base/source-state-plan.md`
"전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된 "전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된
게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`research/ 게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`base/
debounce-throttle-plan.md`)가 추가되면 같은 자리에 들어옴. 이걸 못 박아 debounce-throttle-plan.md`, 설계 확정·구현은 아직)가 추가되면 같은 자리에
들어옴. 이걸 못 박아
두는 이유: 과거에 "이미 `invalid`면 전파를 멈춘다"는 서술이 base에 두는 이유: 과거에 "이미 `invalid`면 전파를 멈춘다"는 서술이 base에
있었고, 그건 사실상 `Blocker`가 하는 일을 모든 State에 암묵적으로 심는 있었고, 그건 사실상 `Blocker`가 하는 일을 모든 State에 암묵적으로 심는
것이라 `Blocker`의 존재 의의를 반쯤 지워버렸음(역전 경위는 것이라 `Blocker`의 존재 의의를 반쯤 지워버렸음(역전 경위는

View file

@ -1,10 +1,18 @@
# Debounce / Throttle — 시간 기반 전파 게이트 # Debounce / Throttle — 시간 기반 전파 게이트
**상태**: research — **[2026-08-14 신설]** 사용자 요청("`Blocker`와 유사하게 **상태**: base — **[2026-08-19 세션, 전부 해소되어 `research/`에서 승격]**
Debounce/Throttle를 만들어야 한다")으로 신설. 여기 적힌 건 **에이전트가 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 동작을 짚어준 > **[2026-08-14 1차 리뷰 반영]** 사용자가 스로틀의 trailing 동작을 짚어준
> 뒤 세 가지가 바뀜: (1) **Q5(패키지 경계) 해소** — quad-base + 엔진별 > 뒤 세 가지가 바뀜: (1) **Q5(패키지 경계) 해소** — quad-base + 엔진별
@ -33,6 +41,24 @@ Debounce/Throttle를 만들어야 한다")으로 신설. 여기 적힌 건 **에
> **`base/source-state-plan.md`의 무효화 dedup 문장이 확정된 `Observer` > **`base/source-state-plan.md`의 무효화 dedup 문장이 확정된 `Observer`
> 계약과 모순**되고 `base/architecture.md`와도 어긋난다는 게 드러나, > 계약과 모순**되고 `base/architecture.md`와도 어긋난다는 게 드러나,
> base 정정 항목(Q10)이 새로 생김. > 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<number>`로 확장**하는 것도
> 이 라운드에 같이 확정(5-2절) — 원문은 위 세션 파일.
`research/operator-sugar-plan.md`가 "`Operator.*` 카탈로그 밖의 별도 `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`를 즉시 세우되 아래로 전파만 미룸. 창이 열려 있는 게이트는 자기 `invalid`를 즉시 세우되 아래로 전파만 미룸. 창이 열려 있는
동안 누가 `debounced:Get()`하면 **최신값**이 나옴. 동안 누가 `debounced:Get()`하면 **최신값**이 나옴 — `Blocker`의 gated
state와 글자 그대로 같은 동작.
### (B) value-hold — 값 자체가 지연됨 (권장) ### ~~(B) value-hold — 값 자체가 지연됨~~ — **철회됨, laziness와 상충**
게이트는 창이 닫혀 있는 동안 **자기를 invalid로 만들지 않음**. 즉 캐시된 게이트가 창이 닫혀 있는 동안 자기를 invalid로 만들지 않고 캐시된 직전
직전 값을 계속 들고 있고, `debounced:Get()`은 **지연된 값**을 반환. 타이머가 값을 들고 있다가, `debounced:Get()`이 **지연된 값**을 반환하게 하려던
커밋할 때 비로소 invalid + 전파. 안. 업계 선례(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의 *값 자체*가 부수로, (B)를 지지했던 업계 선례(VueUse/RxJS)도 재검토하면 그대로 옮겨올
늦게 따라오고, RxJS `debounceTime`도 값의 방출을 미룸. "debounce된 근거가 약함 — 둘 다 push/eager 모델(Vue reactivity의 effect는 즉시 도는
상태"를 다른 곳에서 읽었을 때 debounce 안 된 값이 나오면 놀람. push, RxJS는 애초에 eager push 스트림)이라 "값이 지연된다"가 공짜로
2. **`Blocker`와 역할이 깔끔히 갈림.** `Blocker`는 "지금 중간 상태니까 성립하는 세계고, quad처럼 **pull-lazy가 원칙**인 곳엔 그대로 안 맞는
구독자에게 알리지 않을 뿐, 진실은 이미 새 값"이고, Debounce는 "이 선례였음.
노드의 값은 정의상 늦게 따라오는 값"임. 후자를 (A)로 만들면 두 도구가
같은 자리에서 미묘하게만 다른 애매한 물건이 됨.
3. **확정된 원칙과 충돌하지 않음.** `base/blocker-plan.md`가 인용하는
"`Get()`은 라이브 레퍼런스를 준다" 원칙은 `base/source-state-plan.md`에서
실제로는 **레퍼런스 의미론**(테이블을 복사본이 아니라 라이브 참조로
준다, 그래서 `Get()` 결과를 캐시해두고 `==` 비교하면 안 됨)에 대한
것이지 "항상 상류 최신값"이라는 뜻이 아님. 게이트 노드의 *자기* 값이
지연된 값이라면 `:Get()`은 여전히 그 노드의 라이브 값을 주는 것.
→ 다만 blocker-plan의 그 한 줄이 후자로 읽힐 여지가 있어, 확정되면
그 문장에 한 줄 명확화를 넣는 게 좋음(열린 질문 Q7).
**(B)의 캐비엇 하나**: 노드가 아직 한 번도 계산된 적 없으면(캐시 없음) **(A) 채택의 부수 효과 — Q7 소멸**: (A)는 `Blocker`의 gated state와
붙잡고 있을 "이전 값"이 없어서 첫 `:Get()`은 그냥 최신값을 계산함. 완전히 같은 메커니즘이라, `base/blocker-plan.md`가 이미 쓰고 있는
"지연은 두 번째 값부터"라는 뜻 — 자연스럽고 무해하지만 문서에 명시할 것. "`Get()`은 라이브 레퍼런스를 준다" 문구가 그대로 맞고 별도 명확화가
필요 없음(옛 Q7이 걱정했던 모순은 (B)를 택했을 때만 생기는 문제였음).
--- ---
@ -383,24 +414,35 @@ local sampled = mousePos:Apply(Throttle{Time = 1 / 30})
```lua ```lua
type DebounceOptions = { type DebounceOptions = {
Time: number, -- 필수. 창 길이(초) — 신호마다 리셋됨 Time: number | State<number>, -- 필수. 창 길이(초) — 신호마다 리셋됨
Leading: boolean?, -- 기본 false. 버스트의 첫 신호를 즉시 통과시킬지 Leading: boolean?, -- 기본 false. 버스트의 첫 신호를 즉시 통과시킬지
Trailing: boolean?, -- 기본 true. 조용해진 뒤 한 번 통과시킬지 Trailing: boolean?, -- 기본 true. 조용해진 뒤 한 번 통과시킬지
MaxTime: number?, -- 기본 nil. 신호가 안 끊겨도 최대 이 간격마다 강제 통과 MaxTime: (number | State<number>)?, -- 기본 nil. 신호가 안 끊겨도 최대 이 간격마다 강제 통과
Handle: Ref<GateHandle>?, -- 기본 nil. 이 인스턴스 전용 제어 핸들(5-4절)
} }
type ThrottleOptions = { type ThrottleOptions = {
Time: number, -- 필수. 창 길이(초) — 신호가 리셋하지 못함 Time: number | State<number>, -- 필수. 창 길이(초) — 신호가 리셋하지 못함
Leading: boolean?, -- 기본 true. 창 밖 첫 신호를 즉시 통과시킬지 Leading: boolean?, -- 기본 true. 창 밖 첫 신호를 즉시 통과시킬지
Trailing: boolean?, -- 기본 true. 창 안에 눌러둔 게 있으면 창 끝에 통과시킬지 Trailing: boolean?, -- 기본 true. 창 안에 눌러둔 게 있으면 창 끝에 통과시킬지
Handle: Ref<GateHandle>?, -- 기본 nil. 이 인스턴스 전용 제어 핸들(5-4절)
} }
``` ```
- **모든 필드는 plain 값만**`State`를 못 받음. `base/tween-plan.md` - **[2026-08-19 확정] `Time`/`MaxTime`은 `number | State<number>` 허용** —
"옵션 값 모양" 절이 `Tween{...}`에 대해 확정한 것과 같은 규칙, 같은 `Leading`/`Trailing`은 여전히 plain만(정적 정책값이라 반응형일 이유가
이유(옵션 안에 두 번째 반응 경로를 만들지 않음). `Time`을 반응형으로 없음). `base/tween-plan.md`의 "옵션 값 모양" 절이 든 "옵션 안에 두 번째
바꾸고 싶으면 `state:Apply(...)` 자체를 다시 만드는 상위 구조가 필요한데, 반응 경로를 만들지 않음" 근거는 여기 안 부딪힘 — Debounce/Throttle은
그건 이 프리미티브가 아니라 `State<State<T>>` 영역. `Time`**구독하지 않고**, `setTimeout`을 실제로 호출하는 그 순간에만
`:Get()`으로 값을 읽는 폴링이라 새 무효화 채널이 안 생김(`Animate`가
트윈 시작 시점에만 duration을 읽는 것과 같은 결).
- **이미 스케줄된 타이머엔 반영 안 됨**`setTimeout(fn, delay)`
한 번 예약된 delay는 못 바꾸므로, `Time`을 바꿔도 **다음 창부터만**
적용됨(진행 중인 창은 그대로 끝까지 감). 7절 의사코드의 `openWindow`/
`cap` 스케줄 지점이 유일한 읽기 지점.
- `Time`을 State로 만들고 싶으면 `state:Apply(...)` 상위 구조
(`State<State<T>>`)가 필요하다던 옛 서술은 무효 — 그런 상위 구조 없이
바로 지원됨.
- `Leading = true, Trailing = false` → "버스트 시작에 한 번만" - `Leading = true, Trailing = false` → "버스트 시작에 한 번만"
- `Leading = false, Trailing = true` → 기본값, 일반적인 debounce - `Leading = false, Trailing = true` → 기본값, 일반적인 debounce
- 둘 다 `false`는 아무 일도 안 하는 설정 → **즉시 error** 권장(`Slot:Add`의 - 둘 다 `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`)은 아직 > **주의**: `Gate` 노드의 내부 훅(`onUpstreamSignal`, `commit`)은 아직
> 이름도 계약도 확정되지 않은 가칭 — `Blocker`의 게이티드 노드가 이미 > 이름도 계약도 확정되지 않은 가칭 — `Blocker`의 게이티드 노드가 이미
> 필요로 하는 것과 같은 훅이라(1절), 실제 구현 시엔 그쪽과 같이 정의할 것. > 필요로 하는 것과 같은 훅이라(1절), 실제 구현 시엔 그쪽과 같이 정의할 것.
> 아래 코드의 `registry`/`Handle` 배선도 마찬가지로 스케치 수준 — 실제
> 구현 시 이름은 자유.
```lua ```lua
-- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절). -- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절).
local function readTime(t: number | State<number>): number
if type(t) == "number" then return t end
return t:Get() -- setTimeout 호출 시점에만 읽음 — 이미 스케줄된 타이머엔 영향 없음
end
local function makeGate(reset: boolean, opts) local function makeGate(reset: boolean, opts)
if opts.Leading == false and opts.Trailing == false then if opts.Leading == false and opts.Trailing == false then
error("Leading/Trailing 둘 다 false면 아무것도 통과하지 않음") error("Leading/Trailing 둘 다 false면 아무것도 통과하지 않음")
@ -673,9 +796,21 @@ local function makeGate(reset: boolean, opts)
local leading = if reset then opts.Leading == true else opts.Leading ~= false local leading = if reset then opts.Leading == true else opts.Leading ~= false
local trailing = opts.Trailing ~= false local trailing = opts.Trailing ~= false
-- 팩토리 — :Apply가 state마다 한 번씩 호출하므로 아래 지역 상태는 -- 팩토리 레벨 상태 — makeGate 호출(=Debounce{...}/Throttle{...} 한 번)당 하나.
-- 게이트 노드 하나당 하나씩 새로 생김(팩토리 재사용해도 공유 안 됨) -- weak 레지스트리라 여기 등록돼도 게이트의 GC를 막지 않음(5-4절).
return function(self) local instances = setmetatable({}, {__mode = "k"})
local function flushAll()
for gate in instances do gate._flush() end
end
local function cancelAll()
for gate in instances do gate._cancel() end
end
-- 팩토리 자체 — :Apply(factory)가 호출할 수 있는 함수이면서, 동시에
-- 전체 브로드캐스트 :Flush()/:Cancel()도 갖는 콜러블 객체(5-4절)
local factory = setmetatable({}, {
__call = function(_, self)
local gate = Gate(self) -- Blocker가 쓰는 것과 같은 게이트 노드 local gate = Gate(self) -- Blocker가 쓰는 것과 같은 게이트 노드
local pending = false -- 창 안에서 상류 신호가 있었는가 local pending = false -- 창 안에서 상류 신호가 있었는가
local window = nil -- 살아있으면 "창 안", nil이면 idle local window = nil -- 살아있으면 "창 안", nil이면 idle
@ -684,7 +819,7 @@ local function makeGate(reset: boolean, opts)
local openWindow, onWindowEnd local openWindow, onWindowEnd
function openWindow() function openWindow()
window = setTimeout(onWindowEnd, opts.Time) window = setTimeout(onWindowEnd, readTime(opts.Time))
end end
function onWindowEnd() function onWindowEnd()
@ -727,12 +862,37 @@ local function makeGate(reset: boolean, opts)
gate:passThrough() gate:passThrough()
openWindow() openWindow()
end end
end, opts.MaxTime) end, readTime(opts.MaxTime))
end end
end end
return gate -- 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
end
gate._cancel = function()
pending = false
if window then clearTimeout(window); window = nil end
if cap then clearTimeout(cap); cap = nil end
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 end
function Debounce(opts: DebounceOptions) return makeGate(true, opts) 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 즉시 통과 ● 3.5 입력 → window == nil → leading 즉시 통과 ●
``` ```
**(B) value-hold 의미론이 붙는 자리**: `gate``onUpstreamSignal`에서 **(A) emit-gate가 붙는 자리(확정, 4절)**: `gate`는 다른 평범한 State와
자기 `invalid`를 세우지 않고, 오직 `passThrough()`에서만 세움 — 그래서 똑같이 **매 `onUpstreamSignal` 진입 시 자기 `invalid`를 즉시 세운다**
창이 열려 있는 동안 `gate:Get()`은 캐시된 직전 값을 반환. (A)를 택하면 `source-state-plan.md`의 "전파 모델 확정" 절이 정한 "emit은 항상 전파된다"
`onUpstreamSignal` 진입 시 invalid만 세우고 전파를 미루는 형태로 바뀜. 규칙이 게이트 자신에게도 그대로 적용됨. 위 코드의 `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`는 압도적으로 **재진입 방지 불리언**을 Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리언**을
가리킴(`local debounce = false ... if debounce then return end`). 웹 가리킴(`local debounce = false ... if debounce then return end`). 웹
@ -808,9 +972,9 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리
| `Coalesce` | "합친다"는 동작 자체 | nil 병합(`Alternative`)과 어휘 충돌 | | `Coalesce` | "합친다"는 동작 자체 | nil 병합(`Alternative`)과 어휘 충돌 |
| `RateLimit` (Throttle 자리) | 의미 명확 | 서버 레이트 리밋 뉘앙스 | | `RateLimit` (Throttle 자리) | 의미 명확 | 서버 레이트 리밋 뉘앙스 |
**권고**: `Debounce`/`Throttle` 유지 + **사용자 문서 첫 줄에 Roblox 관용 **[2026-08-19 확정]** `Debounce`/`Throttle` 유지 + **사용자 문서 첫 줄에
"debounce"와 다르다는 걸 못박기**. 업계 표준 이름을 버리면 검색·이주 Roblox 관용 "debounce"와 다르다는 걸 못박기**. 업계 표준 이름을 버리면
비용이 더 큼. 단 이건 사용자 취향이 강하게 걸리는 영역이라 Q1로 넘김. 검색·이주 비용이 더 크다는 게 채택 근거 — 더 판단할 것 없음(구 Q1).
### 9-2. `-ed`를 안 붙이는 이유 (이건 코퍼스 규칙으로 결정됨) ### 9-2. `-ed`를 안 붙이는 이유 (이건 코퍼스 규칙으로 결정됨)
@ -856,24 +1020,24 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리
--- ---
## 12. 사용자 판단 대기 — 열린 질문 ## 12. 사용자 판단 대기 — 전부 해소됨 (2026-08-19)
1. **이름**`Debounce`/`Throttle` 유지(권장) vs 대안. Roblox 관용 **[2026-08-19] 남아있던 Q1/Q2/Q4/Q8까지 전부 닫혀 열린 항목이 없음** —
"debounce"(재진입 불리언)와의 충돌을 문서 경고로만 다룰지. (9-1절) 논의 원문은 `session/2026-08-19-03-debounce-throttle-final-close.md`.
2. **의미론** — (A) emit-gate(`Blocker`와 동일, `:Get()`은 최신값) vs 이력만 남겨둠(각 항목이 왜 그렇게 됐는지는 가리키는 절이 소스, 여기서
**(B) value-hold(권장, `:Get()`도 지연된 값)**. (4절) 반복 안 함):
3. **[2026-08-14 개정] 공개 생성자 2개 + 내부 구현 1개(`Reset` 한 비트)**
— 초안의 "`Throttle`은 `Debounce{MaxTime}` 프리셋"은 이중 발화 버그가 1. ~~**이름**~~ **[2026-08-19 해소]** — `Debounce`/`Throttle` 유지,
있어 폐기됨. 개정안 확인만 필요. (5-3절, 7절) Roblox 관용 "debounce"와의 충돌은 문서 경고로만 대응. (9-1절)
4. **제어 핸들(`:Flush()`/`:Cancel()`)을 v1에 넣을지.** 세 가지 모양: 2. ~~**의미론**~~ **[2026-08-19 해소]** — **(A) emit-gate 채택**, (B)
- **S1(권장, v1)**: `state:Apply(Debounce{...})` — 핸들 없음. value-hold는 laziness와 상충해 철회. (4절)
`Operator` 관용구와 완전 일치, 가장 단순. 3. ~~**공개 생성자 2개 + 내부 구현 1개(`Reset` 한 비트)**~~
- **S2**: `local d = Debouncer{...}; state:Debounce(d)` + `d:Flush()`/ **[2026-08-19 해소]** — 2026-08-14 개정안 그대로 채택, 이견 없었음.
`d:Cancel()`**`Blocker`의 모양과 글자 그대로 같음**(외부 객체를 (5-3절, 7절)
들고 `state:Block(blocker)`로 배선). 구조적 추가 비용이 사실상 없고, 4. ~~**제어 핸들**~~ **[2026-08-19 해소]** — 넣기로 확정. 초안의 S1(핸들
"검색창에서 Enter 누르면 즉시 커밋" 같은 실사용 요구를 커버함. 없음)/S2(`Blocker`와 같은 공유 외부 객체) 둘 다 아니고, 개별은
- **S3**: `__call`을 가진 객체로 S1/S2를 겸함 — 매직이라 비권장. `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak
실사용에서 Flush가 자주 필요하다고 보면 처음부터 S2가 나음. 레지스트리로 브로드캐스트)로 수렴. (5-4절)
5. ~~**패키지 경계**~~ **[2026-08-14 해소]** — 사용자가 quad-base + 5. ~~**패키지 경계**~~ **[2026-08-14 해소]** — 사용자가 quad-base +
엔진별 태스크 배선으로 확정. 주입 표면이 3개→5개로 늘어나는 비용은 엔진별 태스크 배선으로 확정. 주입 표면이 3개→5개로 늘어나는 비용은
수용됨. op 이름/시그니처도 사용자가 지정 수용됨. op 이름/시그니처도 사용자가 지정
@ -882,14 +1046,12 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리
전제(무효화 dedup이 신호를 삼킴)가 틀린 것으로 드러나 질문 자체가 전제(무효화 dedup이 신호를 삼킴)가 틀린 것으로 드러나 질문 자체가
없어짐. emit은 항상 재전파되므로 게이트는 어디에 걸든 정상 동작함. 없어짐. emit은 항상 재전파되므로 게이트는 어디에 걸든 정상 동작함.
(3절) (3절)
7. **`base/blocker-plan.md` 한 줄 명확화** — 그 문서가 인용하는 7. ~~**`base/blocker-plan.md` 한 줄 명확화**~~ **[2026-08-19 소멸]** — Q2가
"`Get()`은 라이브 레퍼런스를 준다"는 실제로는 `base/source-state-plan.md` (A)로 확정되면서 이 항목이 걱정했던 모순(값-지연 의미론과 "`Get()`은
**레퍼런스 의미론** 원칙이지 "항상 상류 최신값"이 아님. Q2에서 (B)를 라이브 레퍼런스" 문구의 충돌)이 애초에 안 생김 — (A)는 `Blocker`
택하면 두 문서가 모순돼 보이므로 그 인용에 한 줄 주석을 넣는 게 좋음. 완전히 같은 메커니즘이라 그 문구가 그대로 맞음. (4절)
(확정 문서 수정이라 사용자 승인 후 반영 — 이 세션에선 안 건드림.) 8. ~~**`Time = 0`을 허용할지**~~ **[2026-08-19 해소]** — 허용, "defer될
8. **`Time = 0`을 허용할지** — "이번 스텝 합치기" 용도로 유용하지만 수 있음"만 문서화, 금지/에러 안 함. (1절 인용문)
스케줄러 타이밍에 의존하는 준-`Blocker`가 됨. 허용(권장) / 금지 /
별도 이름 부여 중 선택. (1절 인용문)
9. ~~**`Timeout` 핸들의 타입**~~ **[2026-08-14 완전 해소]** — 사용자 결정으로 9. ~~**`Timeout` 핸들의 타입**~~ **[2026-08-14 완전 해소]** — 사용자 결정으로
**`type Timeout = { __type_timeout: true, _native: any }`**(마커는 **`type Timeout = { __type_timeout: true, _native: any }`**(마커는
런타임에도 실제로 넣고, 백엔드 페이로드 자리도 타입에 미리 선언). 런타임에도 실제로 넣고, 백엔드 페이로드 자리도 타입에 미리 선언).
@ -906,18 +1068,41 @@ Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리
전체 정정을 지시 — 같은 세션에 base/reference/research/ROADMAP/ 전체 정정을 지시 — 같은 세션에 base/reference/research/ROADMAP/
스파이크/audit까지 전부 반영했고, 역전 기록은 스파이크/audit까지 전부 반영했고, 역전 기록은
`archive/invalidate-dedup-propagation-reversed.md`. 상세는 3절. `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 코어 계약을 **M0 착수를 막지 않음** — 이 문서의 어떤 결정도 디스패치/State 코어 계약을
바꾸지 않음(11절에서 확인). 다만 `Operator` 슈가와 달리 **순수 슈가가 바꾸지 않음(11절에서 확인).
아니라 실제 기능 갭**이라(타이머 없이는 사용자 코드로 재현하기 번거롭고,
재현하면 laziness를 깨기 쉬움) 우선순위는 그쪽보다 위로 두는 게 맞아 보임.
**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면. 그 둘이 **[2026-08-19] 옛 서술("`Operator` 슈가와 달리 순수 슈가가 아니라 실제
서면 언제든 얹을 수 있고, `Blocker` 구현과 **같은 시점에 하는 게 확실히 기능 갭이라 우선순위를 위로 둔다")은 이번 라운드로 뒤집힘.** 제어 핸들
쌈** — 1절에서 봤듯 게이트 노드를 공유하므로 따로 하면 같은 걸 두 번 설계(5-4절)까지 확정하고 나니, `Debounce`/`Throttle`이 실제로 새로
설계하게 됨. **권고: `Blocker` 구현 시점(M3)에 게이트 노드를 공용으로 필요로 하는 quad-base 코어 표면은 **주입 op 2개(`setTimeout`/`clearTimeout`)
빼두고, Debounce/Throttle 자체는 그 위에 나중에 얹기.** 뿐**이고 — 게이트 메커니즘은 `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` 함수)는 그 위에 아무
때나 나중에 얹으면 된다.

View file

@ -194,9 +194,10 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 상류로 올라가 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 상류로 올라가
재계산하고, 그 결과를 캐시에 넣고, `invalid`를 끈다. 재계산하고, 그 결과를 캐시에 넣고, `invalid`를 끈다.
- **emit 전파를 늦추거나 흡수할 수 있는 건 명시적인 게이트 요소뿐** - **emit 전파를 늦추거나 흡수할 수 있는 건 명시적인 게이트 요소뿐**
지금은 `Blocker`(`base/blocker-plan.md`)가 유일하고, 앞으로 추가된다면 지금은 `Blocker`(`base/blocker-plan.md`)가 유일하고,
`research/debounce-throttle-plan.md`의 시간 기반 게이트가 같은 자리에 `base/debounce-throttle-plan.md`의 시간 기반 게이트가 같은 자리에
들어옴. **평범한 State는 절대 신호를 삼키지 않는다.** 들어감(설계 확정, 구현은 아직). **평범한 State는 절대 신호를 삼키지
않는다.**
- **[2026-08-14 정정 — 중요]** 이 자리엔 원래 "이미 `invalid`였다면 그 - **[2026-08-14 정정 — 중요]** 이 자리엔 원래 "이미 `invalid`였다면 그
아래로 더 전파하지 않는다(다이아몬드 중복 워크 방지)"라고 적혀 있었으나 아래로 더 전파하지 않는다(다이아몬드 중복 워크 방지)"라고 적혀 있었으나
**틀린 서술이라 뒤집힘**. 그대로 두면 `:Get()`을 호출하지 않는 **틀린 서술이라 뒤집힘**. 그대로 두면 `:Get()`을 호출하지 않는

View file

@ -139,35 +139,13 @@
몫. 같은 리서치에서 포함 범위도 새로 갈렸음 — 비트/비교 연산자 그룹과 몫. 같은 리서치에서 포함 범위도 새로 갈렸음 — 비트/비교 연산자 그룹과
`Sub`/`Div`는 리액티브 콤비네이터로서 선례가 전혀 없어 드랍 후보로, `Sub`/`Div`는 리액티브 콤비네이터로서 선례가 전혀 없어 드랍 후보로,
`Clamp`/`Min`/`Max`는 선례가 강해 추가 후보로, Debounce/Throttle은 `Clamp`/`Min`/`Max`는 선례가 강해 추가 후보로, Debounce/Throttle은
업계에 흔하지만 `Blocker`와는 다른 시간 기반 메커니즘이라 이 카탈로그가 업계에 흔하지만 `Blocker`와는 다른 시간 기반 메커니즘이라 이 카탈로그
아니라 quad-roblox 쪽 별도 프리미티브로 다룰지 판단이 필요한 별개 질문으로 밖 별개 질문으로 분리됐었음 — **[2026-08-19 해소]** 그 별개 질문은
분리됨. **[2026-08-13 세션 신설]** `Alternative`(nil 대체값, coalesce/`??`/ `base/debounce-throttle-plan.md`로 전부 해소·승격 완료, 더 이상 판단
대기 아님. **[2026-08-13 세션 신설]** `Alternative`(nil 대체값, coalesce/`??`/
엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정 엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정
규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`. 규칙에 그대로 맞아 포함 근거는 있음. 상세는 `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<T>>``State<T>`(2026-08-13 여섯 번째 - **중첩 State 평탄화 `State<State<T>>``State<T>`(2026-08-13 여섯 번째
세션 신설, 백로그)** — **[근거 축소, 열네 번째 세션]** 원래 이 항목의 세션 신설, 백로그)** — **[근거 축소, 열네 번째 세션]** 원래 이 항목의
주 근거는 "깊은 체인에선 힌트가 `nil`로 전달돼 깜빡임 방지가 꺼진다"는 주 근거는 "깊은 체인에선 힌트가 `nil`로 전달돼 깜빡임 방지가 꺼진다"는

View file

@ -91,7 +91,8 @@ State 메소드로 두려던 초기 폼팩터가 기각된 경위만 여전히
세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황.~~ 세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황.~~
**[2026-08-14 뒤집힘]** 사용자가 직접 "`Blocker`와 유사하게 만들어야 **[2026-08-14 뒤집힘]** 사용자가 직접 "`Blocker`와 유사하게 만들어야
한다"고 지정 — 위 "빈 자리 아님" 판정은 더 이상 유효하지 않음. 한다"고 지정 — 위 "빈 자리 아님" 판정은 더 이상 유효하지 않음.
설계 초안은 `research/debounce-throttle-plan.md`. (원 판정의 근거였던 설계는 `base/debounce-throttle-plan.md`(2026-08-19 전부 해소돼
`research/`에서 승격). (원 판정의 근거였던
"세 레포에 없다"는 관찰 자체는 사실이지만, 2026-08-12 열아홉 번째 세션의 "세 레포에 없다"는 관찰 자체는 사실이지만, 2026-08-12 열아홉 번째 세션의
외부 리서치에서 RxJS/VueUse 등 Roblox 밖에선 가장 흔한 콤비네이터 외부 리서치에서 RxJS/VueUse 등 Roblox 밖에선 가장 흔한 콤비네이터
카테고리 중 하나로 확인돼 근거로서의 무게가 이미 약해져 있었음.) 카테고리 중 하나로 확인돼 근거로서의 무게가 이미 약해져 있었음.)

View file

@ -237,8 +237,9 @@ introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어
모양을 벗어남 — `Operator.*`(quad-base, 엔진 무종속)에 넣을 수 있는 게 모양을 벗어남 — `Operator.*`(quad-base, 엔진 무종속)에 넣을 수 있는 게
아니라 quad-roblox 쪽 별도 프리미티브(Tween과 비슷한 위치)로 다뤄야 할 아니라 quad-roblox 쪽 별도 프리미티브(Tween과 비슷한 위치)로 다뤄야 할
가능성이 큼. 이 문서 범위 밖의 별도 설계 질문으로 분리해서 판단 필요. 가능성이 큼. 이 문서 범위 밖의 별도 설계 질문으로 분리해서 판단 필요.
**[2026-08-14, 분리 완료]** 사용자 요청으로 `research/debounce-throttle-plan.md` **[2026-08-14, 분리 완료, 2026-08-19 설계 전부 해소돼 `base/debounce-throttle-plan.md`
신설 — 이 항목은 그 문서로 이관됐고 여기선 더 이상 다루지 않음. 그 승격]** 사용자 요청으로 신설 — 이 항목은 그 문서로 이관됐고 여기선 더
이상 다루지 않음. 그
문서의 결론 두 가지만 여기 관련 있음: (1) 붙이는 모양은 이 카탈로그와 문서의 결론 두 가지만 여기 관련 있음: (1) 붙이는 모양은 이 카탈로그와
똑같이 `factory(self)` + `:Apply`라 위 "왜 `:Apply`인가" 규칙이 그대로 똑같이 `factory(self)` + `:Apply`라 위 "왜 `:Apply`인가" 규칙이 그대로
적용됨, (2) 다만 배치는 위 추측과 달리 **quad-base + 주입 op 2개**로 적용됨, (2) 다만 배치는 위 추측과 달리 **quad-base + 주입 op 2개**로

View file

@ -1519,3 +1519,19 @@ key로 완료 여부 기록) 방식으로 직접 해소하는 아이디어도
확정. `base/slot-plan.md`/`question.md`/`todos.md`/ 확정. `base/slot-plan.md`/`question.md`/`todos.md`/
`archive/question-resolved.md`/`ROADMAP.md` 전량 반영, `doc-check.py` `archive/question-resolved.md`/`ROADMAP.md` 전량 반영, `doc-check.py`
ERROR 0. 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<number>`로 확장(스케줄 시점에만 폴링, 구독 아님)도 결정.
전부 닫히면서 문서가 `research/`에서 `base/`로 승격됐고, 제어 핸들 설계가
`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힌다는 게
드러나 **순수 슈가로 재평가**(옛 "실제 기능 갭이라 우선순위 위" 서술
철회). `ROADMAP.md`/`question.md`/`todos.md`/`README.md`/
`source-state-plan.md`/`blocker-plan.md` 전량 반영.

View file

@ -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<number> 주는거
가능해보임."* → `Time`/`MaxTime` 둘 다 `number | State<number>` 허용,
이미 스케줄된 타이머엔 미반영·다음 창부터 적용으로 확정.
## 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`가 파일명 기준 폴백 매칭이라 깨지지 않음).

View file

@ -178,15 +178,17 @@
(`debug-tooling-plan.md`/`documentation-plan.md`/ (`debug-tooling-plan.md`/`documentation-plan.md`/
`documentation-content-map.md`/`framework-comparison-findings.md`/ `documentation-content-map.md`/`framework-comparison-findings.md`/
`operator-sugar-plan.md`). `operator-sugar-plan.md`).
**[2026-08-14 추가, 성격이 다름]** 시간 기반 전파 게이트 **[2026-08-14 추가, 2026-08-19 설계 전부 해소 후 `base/`로 승격]** 시간
`Debounce`/`Throttle`(`research/debounce-throttle-plan.md`)도 백로그이긴 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`)도
하나 위 항목들과 달리 **사용자가 직접 요청한 실제 기능 갭**이고 순수 백로그이지만 위 항목들과는 발단이 다름 — **사용자가 직접 요청한 실제
슈가가 아님 — M0/M3를 막지는 않지만, **M3에서 `Blocker`를 구현할 때 기능 갭**에서 시작됨(그 문서 13절). 다만 제어 핸들 설계까지 닫히고 나니
게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 해야 함**(따로 실제로 quad-base에 새 코어 메커니즘을 추가하지 않는 **순수 슈가**로
하면 같은 설계를 두 번 함). 주입 op 2개(`setTimeout`/`clearTimeout`)가 확인돼(같은 절), 위 항목들과 우선순위는 다시 같아짐 — M0/M3를 막지
백엔드 팩토리 표면에 추가될 예정이라는 것도 M1 설계 시 인지. 설계는 않고, **M3에서 `Blocker`를 구현할 때 게이티드 노드를 공용 `Gate`
네 라운드로 대부분 확정됐고 남은 열린 질문은 `question.md` 3번(개수는 빼두는 것만은 그 시점에 해야 함**(따로 하면 같은 설계를 두 번 함).
거기도 반복 안 함 — 소스는 `research/debounce-throttle-plan.md` 12절). 주입 op 2개(`setTimeout`/`clearTimeout`)가 백엔드 팩토리 표면에
추가될 예정이라는 것도 M1 설계 시 인지. 남은 열린 질문 없음(구
`question.md` 3번, 전량 해소로 항목 자체가 빠짐).
**[2026-08-18 추가]** 사용자 아이디어 메모 두 건도 같은 성격의 백로그로 **[2026-08-18 추가]** 사용자 아이디어 메모 두 건도 같은 성격의 백로그로
신설 — 스크롤 최적화 외부 유틸 `quad-roblox-fastscroll` 신설 — 스크롤 최적화 외부 유틸 `quad-roblox-fastscroll`
(`research/fastscroll-plan.md`, 선행으로 `Visible=false`일 때 (`research/fastscroll-plan.md`, 선행으로 `Visible=false`일 때

View file

@ -909,10 +909,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
테스트는 2026-08-13 세 번째 세션에 불필요로 해소됨 — 테스트는 2026-08-13 세 번째 세션에 불필요로 해소됨 —
`archive/question-resolved.md` 참고, v2엔 대응 개념 자체가 없음) `archive/question-resolved.md` 참고, v2엔 대응 개념 자체가 없음)
- [ ] Slot 형제 순서 보장(다중 백엔드 관점) — Roblox만이면 급하지 않음 - [ ] Slot 형제 순서 보장(다중 백엔드 관점) — Roblox만이면 급하지 않음
- [ ] **[2026-08-14 신설]** 시간 기반 전파 게이트 `Debounce`/`Throttle` - [ ] **[2026-08-14 신설, 2026-08-19 설계 전부 해소 후 `base/`로 승격]**
(`research/debounce-throttle-plan.md`) — **M3에서 `Blocker`를 구현할 시간 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`)
때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할 것** — 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을
(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함). 추가하지 않는 **순수 슈가**로 확인됨(`Blocker`의 gated state + `Ref` +
아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **M3에서 `Blocker`
구현할 때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할
것**(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함).
프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음. 프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음.
주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`, 주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`,
Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라 Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라