docs: Debounce/Throttle 백로그 신설 + "emit은 항상 전파" base 역전 정정

워크트리(worktree-debounce-throttle-plan)에서 네 라운드로 다듬은 결과를
메인의 3단계 분할 구조에 맞춰 필요한 변경만 이식.

## 신설: research/debounce-throttle-plan.md

- Blocker가 이미 쓰는 게이티드 노드의 릴리스 트리거만 타이머로 바꾼 것.
  공개 Blocker API엔 "상류 신호 도착" 통지가 없어 그 위엔 못 얹음 →
  M3에서 게이트를 공용 Gate로 뺄 것.
- 두 도구의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐.
  공개 생성자 2개 + 내부 구현 1개(초안이 옮겨온 lodash식 maxWait 공식엔
  trailing 통과 직후 이중 발화 버그가 있었음).
- quad-base + 주입 op 2개: setTimeout(func, delay) -> Timeout /
  clearTimeout. Roblox는 task.delay/task.cancel로 배선(인자 순서 반대).
  os.clock()은 Luau 표준 라이브러리라 주입 대상 아님(diff 전용).
  Timeout = { __type_timeout: true, _native: any }.

## 역전: emit은 자기 invalid 상태와 무관하게 항상 전파된다

source-state-plan.md의 "이미 invalid였다면 그 아래로 더 전파하지 않는다"가
확정된 Observer 계약(fn이 :Get()을 안 불러도 됨)과 정면 충돌 — 액면대로면
:Get() 안 하는 Observer는 한 번 울고 영구 침묵. architecture.md가 같은
다이아몬드 문제를 pull-recompute로 설명하는 것과도 어긋나 있었음.

정정 모델: invalid는 캐시 낡음 표시일 뿐, 중복 재계산은 pull-recompute+
캐시가 막고 중복 통지는 안 접음(접으려면 Blocker 같은 명시적 게이트).

- source-state-plan.md: 전파 규칙 재작성, "다이아몬드 의존성은 무엇이
  푸는가" 절 신설, Observer 절 상호 참조. 플래튼 기각/:With 빌더 기각
  근거를 캐시 공유로 재작성(두 결론 유지, 근거 강도는 상승)
- architecture.md, blocker-plan.md(전파를 지연시키는 유일한 요소로 위치
  명문화), comparison-fusion-vide.md, framework-comparison-findings.md
- ROADMAP M0 체크리스트: 확인할 것이 정반대가 됨
- luau-test 05 → rewrite-required/(옛 모델을 통과 상태로 검증 중이었음),
  STATUS.md 개수 동기화(rewrite 6→7, done 14→13)
- audit: 05 행 정정 + "12개 전원 통과"를 액면대로 읽지 말라는 경고
- archive/invalidate-dedup-propagation-reversed.md 신설

doc-check: ERROR 0 / WARN 84(작업 전 85).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
qwreey 2026-08-14 05:22:06 +09:00
parent 17a2e4f05f
commit 623c9316fe
Signed by: qwreey
GPG key ID: D28DB79297A214BD
19 changed files with 1706 additions and 33 deletions

View file

@ -74,6 +74,7 @@
| `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`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리, `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가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 |
| `lifecycle-hooks-plan.md` | **[2026-08-14 신설]** `OnCreated`/`OnDestroyed` 생명주기 훅 — `OnCreated(fn)``PreRef():Callback(fn)`, `OnDestroyed(fn)``Effect(function() return fn end)`를 반환하는 순수 팩토리 함수라 새 타입/Dispatch 메커니즘이 전혀 필요 없음(호출 즉시 평가돼 기존 `PreRef`/`EffectHandle` 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원. `OnRendered`는 현재 base에 없는 post-pass가 실제로 필요해 공짜가 아니라 **지금은 의도적으로 구현 안 함** — 거울상 `PostRef` 스케치(`PreRef`의 pre-pass와 대칭인 post-pass)만 백로그 후보로 남김 | 하 — 형제 백로그(`quad-mock`/`quad-debug`/`Operator`/`Fallback`)와 동급, "quad 개발 상당 부분 끝난 뒤" | | `lifecycle-hooks-plan.md` | **[2026-08-14 신설]** `OnCreated`/`OnDestroyed` 생명주기 훅 — `OnCreated(fn)``PreRef():Callback(fn)`, `OnDestroyed(fn)``Effect(function() return fn end)`를 반환하는 순수 팩토리 함수라 새 타입/Dispatch 메커니즘이 전혀 필요 없음(호출 즉시 평가돼 기존 `PreRef`/`EffectHandle` 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원. `OnRendered`는 현재 base에 없는 post-pass가 실제로 필요해 공짜가 아니라 **지금은 의도적으로 구현 안 함** — 거울상 `PostRef` 스케치(`PreRef`의 pre-pass와 대칭인 post-pass)만 백로그 후보로 남김 | 하 — 형제 백로그(`quad-mock`/`quad-debug`/`Operator`/`Fallback`)와 동급, "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` 허용 |
| `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 코어 구현 시점까지 미결 |
## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 ## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요
@ -94,6 +95,7 @@
| `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 | | `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 |
| `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween<T>` 래퍼 모델로 완전히 대체됨(`base/tween-plan.md`) | | `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween<T>` 래퍼 모델로 완전히 대체됨(`base/tween-plan.md`) |
| `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 | | `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 |
| `invalidate-dedup-propagation-reversed.md` | **[역전됨, 2026-08-14 신설]** "신호를 받은 State는 이미 `invalid`였다면 그 아래로 더 전파하지 않는다"(다이아몬드 중복 워크 방지) — 실제로는 **emit이 자기 `invalid` 상태와 무관하게 항상 전파**되고, 중복 재계산은 pull-recompute+캐시가 막음. 옛 서술은 확정된 `Observer` 계약(`fn`이 `:Get()`을 안 불러도 됨)과 정면 충돌해 **`:Get()` 안 하는 Observer가 한 번 울고 영구 침묵**하게 만들었고, `architecture.md`와도 어긋나 있었음. Debounce 설계 중 사용자 지적으로 발견 — 그 위에 쌓였던 `debounce-throttle-plan.md` 3절 발견도 같이 철회됨 |
| `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 | | `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 |
| `slot-discard-no-portal-reversed.md` | **[역전됨, 2026-08-13 일곱 번째 세션 신설]** Slot의 **"retract = 폐기, 옮기지 않음"(2026-08-04 확정) + "portal은 오버엔지니어링이라 안 함"** — 여섯 번째 세션에 `State<Slot>` 교체가 파괴에서 **언마운트**로 뒤집히며 portal이 별도 기능이 아니라 그 귀결이 됨(`state<Frame>`와 동일한 시맨틱). `base/slot-plan.md`에 히스토리로 남아 있던 세 덩어리(확정 문단 + `State<Slot?>` 왕복 분석 + 포탈 검토와 숙제 셋)를 원문 그대로 이전, 숙제 셋이 각각 어떻게 결말났는지도 정리 | | `slot-discard-no-portal-reversed.md` | **[역전됨, 2026-08-13 일곱 번째 세션 신설]** Slot의 **"retract = 폐기, 옮기지 않음"(2026-08-04 확정) + "portal은 오버엔지니어링이라 안 함"** — 여섯 번째 세션에 `State<Slot>` 교체가 파괴에서 **언마운트**로 뒤집히며 portal이 별도 기능이 아니라 그 귀결이 됨(`state<Frame>`와 동일한 시맨틱). `base/slot-plan.md`에 히스토리로 남아 있던 세 덩어리(확정 문단 + `State<Slot?>` 왕복 분석 + 포탈 검토와 숙제 셋)를 원문 그대로 이전, 숙제 셋이 각각 어떻게 결말났는지도 정리 |
| `existing-instance-bind-rejected.md` | **[기각됨, 2026-08-14 세션 — `research/`에서 이전]** 이미 생성된 Instance에 나중에 `{k=v}` 프롭 테이블을 바인드하는 기능 — 오래 "열린 가능성"으로 남겨뒀으나 사용자 확정으로 기각. 사유: 허용하면 `Dispatch.setOffsetSource`/`setLength` 같은 "quad가 만든 트리" 전제의 부기를 바깥에서 밀고 당기는 부가 작용이 전부 가능해져 **버그 표면이 치명적으로 넓어짐**. `pre-implementation-audit.md` 2-4(Slot 단일 마운트 소유권과의 충돌)도 이걸로 해소 | | `existing-instance-bind-rejected.md` | **[기각됨, 2026-08-14 세션 — `research/`에서 이전]** 이미 생성된 Instance에 나중에 `{k=v}` 프롭 테이블을 바인드하는 기능 — 오래 "열린 가능성"으로 남겨뒀으나 사용자 확정으로 기각. 사유: 허용하면 `Dispatch.setOffsetSource`/`setLength` 같은 "quad가 만든 트리" 전제의 부기를 바깥에서 밀고 당기는 부가 작용이 전부 가능해져 **버그 표면이 치명적으로 넓어짐**. `pre-implementation-audit.md` 2-4(Slot 단일 마운트 소유권과의 충돌)도 이걸로 해소 |

View file

@ -0,0 +1,129 @@
# [역전됨] "이미 `invalid`면 그 아래로 더 전파하지 않는다" — 무효화 전파 dedup
**신설**: 2026-08-14 (Debounce/Throttle 설계 중 사용자 지적으로 발견)
**역전 대상**: `base/source-state-plan.md`의 "전파 모델 확정" 절
(2026-08-14 세 번째 세션의 3단계 분할 전에는 `base/bind-system-plan.md`
있었음 — 이 문서가 인용하는 옛 경로는 그 시절 것)
**현재 유효한 서술**: 같은 절의 "전파 모델 확정" + "다이아몬드 의존성은
무엇이 푸는가"(2026-08-14 재작성)
---
## 뒤집힌 원문
`base/source-state-plan.md`(분할 전에는 `base/bind-system-plan.md`)에
2026-08-04부터 2026-08-14까지 이렇게 적혀 있었음:
> - `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만
> 쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다
> 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구).
> - **신호를 받은 State는 자기 `invalid` 플래그만 세우고, 이미 `invalid`였다면
> 그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는
> 장치(Vide가 저자 스스로 `todo.md`에 미해결로 남긴 문제의 해결책).**
> - 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 — "필요할 때 계산"
> 원칙(사용자 확정).
굵게 표시한 두 번째 항목이 역전 대상. 첫/세 번째 항목은 그대로 유효함.
## 무엇이 맞는가 (사용자 확정, 2026-08-14)
> **emit은 항상 전파함. `Blocker`나 emit 전파 지연요소만 이를 지연할 수 있음.**
>
> 재계산 막아지는 건 맞음 — 한 곳에서 `Get`이 되면, `invalid`하다면 위로
> 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음 `invalid`
> 꺼짐.
즉:
- `invalid` 플래그의 역할은 **"내 캐시가 낡았다"는 표시 하나뿐**이고,
전파를 제어하는 장치가 아님.
- 중복 **재계산**을 막는 주체는 **pull-recompute + 노드별 캐시**.
- 중복 **통지**는 접지 않음 — 다이아몬드에서 아래쪽 `Observer`가 한
사이클에 두 번 우는 건 의도된 동작. 접고 싶으면 `Blocker` 같은 **명시적
게이트**를 쓸 것.
## 왜 틀렸는가 — 근거 셋
### 1. ⭐ 확정된 `Observer` 계약과 정면 충돌 (결정적)
같은 파일의 "`state:Observer(fn)`" 절이 **`fn``:Get()`을 부르지 않아도
되는 것을 명시적으로 허용**함:
> 재계산이 진짜 필요한지가 다른 `:With`한 값에 따라 갈리는 경우가 있어서
> (…) `Get()` 호출 여부를 작성자가 직접 결정하게 열어둔 것.
역전 전 규칙을 액면대로 적용하면:
```
source:Set(1) → state invalid 세팅 → Observer 발화 → fn이 :Get() 안 함
→ state는 invalid로 남음
source:Set(2) → state 이미 invalid → 전파 중단 → Observer 침묵 ❌
source:Set(3) → 마찬가지 ❌ ... 이후 영원히
```
**`:Get()`을 안 하는 Observer는 딱 한 번 울고 영구히 침묵함.** 문서가
정당한 사용법으로 허용한 것이 문서의 다른 문장 때문에 조용히 깨지는
것이므로, 취향 차이가 아니라 **base 내부의 실제 모순**이었음.
### 2. `base/architecture.md`와 어긋나 있었음
`architecture.md`는 같은 다이아몬드 문제를 이렇게 서술 중이었음:
> 전파는 push-invalidate(신호만)/pull-recompute(`Get()` 시점) — **Fusion식
> eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림**
즉 중복 *재계산*을 막는 주체를 **pull-recompute 자체**로 지목함(이쪽이
맞음). `source-state-plan.md`는 같은 문제의 해결 주체를 `invalid` 플래그
dedup으로 지목 — **base 안에서 두 문서가 서로 다른 것을 가리키고 있었음.**
### 3. 다이아몬드 근거가 요구하는 범위를 넘어섰음
다이아몬드는 *한 번의 변경*이 여러 경로로 같은 노드에 닿는 문제라,
막으려면 **그 전파 파동(wave) 안에서만** 접으면 됨. 그런데 역전 전 문장은
`invalid`를 **시간에 걸쳐 유지되는 상태**로 써서("이미 `invalid`였다면"),
누가 `:Get()`할 때까지 **이후의 모든 변경**까지 삼켰음. 파동 내 dedup과
시간축 dedup은 전혀 다른 범위인데 한 문장에 뭉뚱그려져 있었음.
덧붙여, "여러 emit을 하나로 접어뒀다가 나중에 1회 방출"은 사용자 지적대로
**`Blocker`의 `HasBlockedEmit`이 opt-in으로 제공하는 동작**임
(`base/blocker-plan.md`). 같은 동작을 모든 State 노드에 암묵적으로 심으면
`Blocker`의 존재 의의가 절반 사라짐.
## 어떻게 들어왔나 (추정)
사용자는 원래부터 "emit은 항상 전파"로 말해왔음(2026-08-14 확인:
"내가 원래 emit은 항상 전파라 했는데 어떤 엉뚱한 에이전트가 이상한짓
하고 간듯"). Vide의 미해결 문제(`todo.md`의 "복잡한 다이아몬드 그래프에서
중복 재평가 방지")를 quad가 어떻게 푸는지 서술하는 과정에서, **이미 캐시로
풀려 있는 것**을 전파 억제 장치로 잘못 귀속시킨 것으로 보임.
## 영향 범위 — 2026-08-14에 같이 고친 것
| 파일 | 무엇이 바뀌었나 |
|---|---|
| `base/source-state-plan.md` | 전파 규칙 재작성 + "다이아몬드 의존성은 무엇이 푸는가" 절 신설. "왜 State 체인을 플래튼하지 않는가"와 `:With` 빌더 기각 근거 2번의 **근거를 캐시 공유로 교체**(두 결론 자체는 안 바뀜). `Observer` 절에 "이 허용은 항상-전파에 의존한다"는 상호 참조 추가 |
| `base/architecture.md` | 원래도 맞는 서술이었으나, 캐시가 주체임을 명시적으로 보강 |
| `base/blocker-plan.md` | "emit 전파를 지연시킬 수 있는 유일한 요소"라는 위치를 명문화 |
| `reference/comparison-fusion-vide.md` | Vide 대비 서술에서 "플래그 dedup" → "캐시" |
| `research/framework-comparison-findings.md` | 같은 정정 |
| `research/debounce-throttle-plan.md` | 이 문서의 3절 발견이 통째로 철회됨(아래) |
| `ROADMAP.md` | M0 체크리스트의 "이미 invalid면 전파 중단되는지" 항목 교체 |
| `luau-test` `05-store-state-diamond-propagation.luau` | 틀린 모델을 **통과 상태로 검증 중이었음**`rewrite-required/`로 이동 |
| `audit/luau-test-first-run-2026-08-13.md` | 그 스파이크의 통과 기록에 정정 표시 |
### 부수 피해 — `research/debounce-throttle-plan.md` 3절
이 잘못된 서술을 전제로, Debounce 설계 문서가 **"파생 State 위에 얹으면
debounce가 조용히 throttle로 퇴화한다"**는 발견을 만들어냈고, 거기서
"`Debounce`는 `Source`에 가깝게 걸어라"는 규칙(“`Blocker`의 '끝에 걸어라'와
정확한 거울상”)과 열린 질문 하나가 파생됐음. **전제가 무너지면서 전부
철회됨** — 그 문서 3절에 철회 기록으로 남아 있음.
## 교훈
**확정 문서의 한 문장을 근거로 새 설계를 세울 때, 그 문장이 *같은 문서의
다른 확정 문장*과 모순되지 않는지까지 확인할 것.** 이 건은 인용 자체는
정확했는데(verbatim) 인용된 문장이 틀린 경우였고, 그 위에 두 라운드에 걸쳐
설계가 쌓였다가 통째로 무너졌음. `doc-check.py`는 참조가 *존재하는지*는
보지만 *서로 모순되는지*는 못 봄 — 이건 여전히 사람/에이전트가 손으로
대조해야 하는 영역.

View file

@ -34,6 +34,75 @@
## 지금 열려있는 것 (우선순위순) ## 지금 열려있는 것 (우선순위순)
### 0-E. ~~무효화 dedup 문장이 `Observer` 계약과 모순~~ **[해소됨, 2026-08-14 — 같은 날 신설·해소]**
> **[해소됨] 결론: 사용자 확정 — "emit은 항상 전파함. `Blocker`나 emit
> 전파 지연요소만 이를 지연할 수 있음. 재계산 막아지는 건 맞음 — 한
> 곳에서 `Get`이 되면, `invalid`하다면 위로 올라가서 받아와서 계산
> 처리된 게 들어오고 cache가 쓰인 다음 `invalid`가 꺼짐."**
>
> 즉 `invalid`는 **캐시 낡음 표시**일 뿐 전파 제어 장치가 아니고,
> 중복 재계산은 pull-recompute + 캐시가 막으며, 중복 통지는 접지 않음.
> 사용자가 "다 다시 써야 한다"고 지시해 **같은 세션에 코퍼스 전체
> 정정 완료** — `base/source-state-plan.md`(전파 규칙 재작성 + "다이아몬드
> 의존성은 무엇이 푸는가" 절 신설 + `Observer` 절 상호 참조),
> `base/architecture.md`, `base/blocker-plan.md`,
> `reference/comparison-fusion-vide.md`,
> `research/framework-comparison-findings.md`, `ROADMAP.md` M0 체크리스트,
> 스파이크 `05`(→`rewrite-required/`), `audit/luau-test-first-run-2026-08-13.md`.
>
> 원문·역전 근거·영향 범위 전체: `archive/invalidate-dedup-propagation-reversed.md`.
> 발견 경위: `research/debounce-throttle-plan.md` 3절.
**아래는 해소 전 원문(2026-08-14 신설 시점).**
**사용자 지적에서 시작**: "emit은 항상 재전파된다. 저 동작(한 번만 접어
뒀다 나중에 1회)은 정확히 `Blocker`가 하는 것." 확인 결과 맞고,
`base/source-state-plan.md`의 "전파 모델 확정" 절에 있는
아래 문장이 **과잉 일반화**로 보임:
> 신호를 받은 State는 자기 `invalid` 플래그만 세우고, **이미 `invalid`였다면
> 그 아래로 더 전파하지 않는다** — 다이아몬드 의존성에서 중복 워크를 막는 장치
**왜 문제인가 — 확정된 계약과 실제로 충돌함**:
1. **`Observer`가 깨짐(가장 심각).** 같은 파일의 "`state:Observer(fn)`"
절이 **`fn``:Get()`을 안 불러도 되는 것을 명시적으로 허용**함
("`Get()` 호출 여부를 작성자가 직접 결정하게 열어둔 것"). 그런데 위
문장을 액면대로 적용하면 `:Get()`을 안 하는 Observer는 상태가 invalid로
남아, **두 번째 변경부터 영원히 안 울림**:
```
source:Set(1) → invalid 세팅 → Observer 발화 → fn이 :Get() 안 함
source:Set(2) → 이미 invalid → 전파 없음 → Observer 침묵 ❌ (이후 계속)
```
2. **`base/architecture.md`와 어긋남.** 그쪽은 같은 다이아몬드 문제를
"**pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드
의존성 중복 재계산 문제가 풀림**"이라고 서술 — 즉 중복 *평가*를 막는
주체는 pull-recompute 자체지 `invalid` 플래그가 아님. 플래그 dedup이
절약하는 건 재계산이 아니라 **플래그 세팅 순회 비용**뿐인데,
`source-state-plan.md`(분할 전 `bind-system-plan.md`)가 이걸 다이아몬드의
해결책으로 승격시켜 서술함.
3. **범위 오류.** 다이아몬드가 요구하는 건 **한 번의 전파 파동 안에서의**
중복 방지인데, 문장은 `invalid`를 시간에 걸쳐 유지되는 상태로 써서
**이후의 모든 변경**까지 삼킴. 두 범위가 한 문장에 뭉뚱그려짐.
그리고 "여러 emit을 하나로 접어뒀다 1회 방출"은 사용자 지적대로
`Blocker``HasBlockedEmit`**opt-in으로** 제공하는 동작이라,
모든 State에 암묵적으로 심으면 `Blocker`의 존재 의의가 절반 사라짐.
**정정안 두 갈래(사용자 선택 필요)**:
- **(a)** 문장을 삭제하고 "emit은 구독자에게 항상 전파된다"만 남김.
- **(b, 권장)** 범위를 못 박아 다시 씀 — "**한 번의 전파 파동 안에서만**
같은 노드를 두 번 방문하지 않는다(구현 최적화, 의미론에 영향 없음)".
원 문장이 담으려던 의도(순회 절약)를 살리면서 시간축으로 새는 걸 막음.
**같이 확인할 것**: `source-state-plan.md`의 "`:With`도 새 State 노드로 확정" 절의 근거
2번("빌더면 다이아몬드 dedup을 못 타고 특수 케이스가 생김")이 **약해짐**
그 장치가 정확성이 아니라 순회 최적화가 되므로. 근거 1·3번이 그대로
유효해 **결론(빌더 기각)은 안 바뀌고**, 근거 2번의 강도만 조정하면 됨.
상세 트레이싱과 배경은 `research/debounce-throttle-plan.md` 3절.
**확정 문서 수정이라 이 세션에선 안 건드림.**
### 0-Y. ~~⭐ 최우선~~ **[해소됨, 2026-08-13 열세 번째 세션]** — `:Compute(fn)`의 lazy 핸들 계약을 유지할 것인가 (2026-08-13 여섯 번째 세션, 첫 실측에서 발견) ### 0-Y. ~~⭐ 최우선~~ **[해소됨, 2026-08-13 열세 번째 세션]** — `:Compute(fn)`의 lazy 핸들 계약을 유지할 것인가 (2026-08-13 여섯 번째 세션, 첫 실측에서 발견)
> **[해소됨] 결론: 계약은 그대로 유지, 이건 quad가 풀 문제가 아니라 > **[해소됨] 결론: 계약은 그대로 유지, 이건 quad가 풀 문제가 아니라

View file

@ -36,7 +36,7 @@
| `02-none-sentinel-vs-nil-holes` | ✅ 통과 | `nil` 소진 시 `#t`가 50→**49**로 무너지고 순회가 흐트러짐 / `None` 소진은 `#t`가 항상 50. 반대로 Ref 콜백 배열은 `None`을 쓰면 1000회 반복 후 **죽은 슬롯 1000개**가 그대로 남음 — 두 배열의 규칙이 서로 반대여야 한다는 2026-08-09 열한 번째 세션 정정이 정량적으로 확인됨 | | `02-none-sentinel-vs-nil-holes` | ✅ 통과 | `nil` 소진 시 `#t`가 50→**49**로 무너지고 순회가 흐트러짐 / `None` 소진은 `#t`가 항상 50. 반대로 Ref 콜백 배열은 `None`을 쓰면 1000회 반복 후 **죽은 슬롯 1000개**가 그대로 남음 — 두 배열의 규칙이 서로 반대여야 한다는 2026-08-09 열한 번째 세션 정정이 정량적으로 확인됨 |
| `03-recursive-store-bind-dispatch` | ✅ 통과 | StoreBind 재귀 재-dispatch, `None`→`nil` 흘러가기, 무한재귀 없이 종료 | | `03-recursive-store-bind-dispatch` | ✅ 통과 | StoreBind 재귀 재-dispatch, `None`→`nil` 흘러가기, 무한재귀 없이 종료 |
| `04-dispatch-chain-retractFrom` | ✅ 통과 + **버그 재현** | 아래 별도 절 | | `04-dispatch-chain-retractFrom` | ✅ 통과 + **버그 재현** | 아래 별도 절 |
| `05-store-state-diamond-propagation` | ✅ 통과 | 다이아몬드 의존성에서 `stateC` 재계산이 정확히 1회(중복 재계산 없음), invalidate는 2번 도달하지만 2번째가 즉시 중단 | | `05-store-state-diamond-propagation` | ⚠️ **통과했으나 검증 대상이 뒤집힘**(2026-08-14) | 다이아몬드 의존성에서 `stateC` 재계산이 정확히 1회(중복 재계산 없음)라는 **앞부분은 그대로 유효**. 다만 "invalidate는 2번 도달하지만 2번째가 즉시 중단"은 **폐기된 모델**을 검증한 것 — 그 전파 중단 규칙이 `Observer` 계약과 모순돼 역전됨(`archive/invalidate-dedup-propagation-reversed.md`). 스파이크는 `rewrite-required/`로 이동, 재작성 후 재측정 필요 |
| `06-component-boundary-nil-hole-props` | ✅ 통과 | `or None` 없으면 앞쪽 nil-hole로 `bad[1]`/`bad[2]`가 사라짐, 관용구 쓰면 항상 5칸 유지 | | `06-component-boundary-nil-hole-props` | ✅ 통과 | `or None` 없으면 앞쪽 nil-hole로 `bad[1]`/`bad[2]`가 사라짐, 관용구 쓰면 항상 5칸 유지 |
| `07-relate-weak-table-gc` | ✅ 통과(**이번에 보강 후**) | 아래 별도 절 | | `07-relate-weak-table-gc` | ✅ 통과(**이번에 보강 후**) | 아래 별도 절 |
| `11-modifier-illegal-value-error` | ⚠️ 부분 | 대부분 의도된 가드 에러로 통과하나 "다른 Modifier" 케이스만 브랜드 판별이 크래시해 **엉뚱한 이유로 통과** — 수정 진행 | | `11-modifier-illegal-value-error` | ⚠️ 부분 | 대부분 의도된 가드 에러로 통과하나 "다른 Modifier" 케이스만 브랜드 판별이 크래시해 **엉뚱한 이유로 통과** — 수정 진행 |
@ -193,6 +193,14 @@ API 전부에 걸림. 2026-08-07 일곱 번째 세션의 커링 스타일 확정
**최종 런타임 상태: 12개 전원 통과**(01/02/03/04/05/06/07/11/17/18/19/20), **최종 런타임 상태: 12개 전원 통과**(01/02/03/04/05/06/07/11/17/18/19/20),
crash 0, FAIL 0. crash 0, FAIL 0.
> **[2026-08-14 정정 — 이 "전원 통과"를 액면 그대로 읽지 말 것]** 통과
> 자체는 사실이지만, 그 뒤 **검증 대상이던 설계가 바뀐 스파이크가 셋**
> 생겼음(`04`/`19`는 열네 번째 세션의 하강 diff 재디스패치로, `05`
> 2026-08-14의 "emit은 항상 전파" 정정으로). 셋 다 `rewrite-required/`
> 있고 **재작성 후 재측정이 필요함** — 즉 지금 기준 "현행 설계를 검증하며
> 통과한" 런타임 스파이크는 9개임. 개수의 소스는 항상
> `luau-test/STATUS.md`.
## 스파이크 자체 수정이 더 필요한 것 (설계 문제 아님) ## 스파이크 자체 수정이 더 필요한 것 (설계 문제 아님)
- `13`: B 런타임 섹션이 A의 더미 스텁에 막혀 단독 실행 불가 — 분리 필요. - `13`: B 런타임 섹션이 A의 더미 스텁에 막혀 단독 실행 불가 — 분리 필요.

View file

@ -314,7 +314,12 @@ Store 생성 시 미리 만들어둔 경우), 아직 없으면 그 자리에서
상세는 `base/source-state-plan.md` "Source가 State를 만족함" 절). 전파는 상세는 `base/source-state-plan.md` "Source가 State를 만족함" 절). 전파는
push-invalidate(신호만)/ push-invalidate(신호만)/
pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드 pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드
의존성 중복 재계산 문제가 풀림. State는 쓰기 대상이 아니고, 값을 쓰는 의존성 중복 재계산 문제가 풀림(**[2026-08-14 보강]** 푸는 주체는
**노드별 캐시**임을 명시 — `invalid`는 "내 캐시가 낡았다" 표시일 뿐이고
**emit 전파는 자기 `invalid` 상태와 무관하게 항상 일어남**. 전파를 늦추는
`Blocker` 같은 명시적 게이트뿐. 한때 `source-state-plan.md`가 "이미
`invalid`면 전파 중단"으로 서술했으나 `Observer` 계약과 모순돼 역전됨 —
`archive/invalidate-dedup-propagation-reversed.md`). State는 쓰기 대상이 아니고, 값을 쓰는
경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 — 경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 —
`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07] `:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07]
읽기는 `:Get()` 하나로 통일 — `.value` 표기는 Ref 전용으로 좁혀짐). 값 하나만 읽기는 `:Get()` 하나로 통일 — `.value` 표기는 Ref 전용으로 좁혀짐). 값 하나만

View file

@ -21,6 +21,17 @@
온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되 온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되
State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것. State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것.
**[2026-08-14 위치 명문화]** `Blocker`는 **quad에서 emit(무효화 신호) 전파를
지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기
`invalid` 상태와 무관하게 **항상** 아래로 전파하고(`base/source-state-plan.md`
"전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된
게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`research/
debounce-throttle-plan.md`)가 추가되면 같은 자리에 들어옴. 이걸 못 박아
두는 이유: 과거에 "이미 `invalid`면 전파를 멈춘다"는 서술이 base에
있었고, 그건 사실상 `Blocker`가 하는 일을 모든 State에 암묵적으로 심는
것이라 `Blocker`의 존재 의의를 반쯤 지워버렸음(역전 경위는
`archive/invalidate-dedup-propagation-reversed.md`).
## 메커니즘 (확정) ## 메커니즘 (확정)
``` ```

View file

@ -186,9 +186,22 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
- `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만 - `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만
쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다 쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다
업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구). 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구).
- 신호를 받은 State는 자기 `invalid` 플래그만 세우고, 이미 `invalid`였다면 - **⭐ emit(무효화 신호)은 구독자에게 *항상* 전파된다 — 자기 `invalid`
그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는 상태와 무관.** 신호를 받은 State는 자기 `invalid` 플래그를 세우고,
장치(Vide가 저자 스스로 `todo.md`에 미해결로 남긴 문제의 해결책). **이미 `invalid`였더라도 그대로 아래로 전파한다.**
- **`invalid` 플래그의 역할은 "내 캐시가 낡았다"는 표시 하나뿐** —
전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 상류로 올라가
재계산하고, 그 결과를 캐시에 넣고, `invalid`를 끈다.
- **emit 전파를 늦추거나 흡수할 수 있는 건 명시적인 게이트 요소뿐**
지금은 `Blocker`(`base/blocker-plan.md`)가 유일하고, 앞으로 추가된다면
`research/debounce-throttle-plan.md`의 시간 기반 게이트가 같은 자리에
들어옴. **평범한 State는 절대 신호를 삼키지 않는다.**
- **[2026-08-14 정정 — 중요]** 이 자리엔 원래 "이미 `invalid`였다면 그
아래로 더 전파하지 않는다(다이아몬드 중복 워크 방지)"라고 적혀 있었으나
**틀린 서술이라 뒤집힘**. 그대로 두면 `:Get()`을 호출하지 않는
`Observer`(아래 "`state:Observer(fn)`" 절이 명시적으로 허용하는 사용법)가
**한 번 울고 영구히 침묵**하게 됨. 원문·역전 근거·영향 범위는
`archive/invalidate-dedup-propagation-reversed.md` 참고.
- 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 — - 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 —
"필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/ "필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/
생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한
@ -198,7 +211,36 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀 State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀
필요 없음. 필요 없음.
- `emit`은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함 - `emit`은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함
("emit 필요 여부" 열린 질문은 이걸로 해소). ("emit 필요 여부" 열린 질문은 이걸로 해소). 항상 전파해도 부담이 작은
이유이기도 함 — 신호 하나가 트리를 훑는 비용이지 재계산 비용이 아님.
### 다이아몬드 의존성은 무엇이 푸는가 (2026-08-14 명확화)
`a → b`, `a → c`, `(b, c) → d` 형태에서 `a`가 한 번 바뀌면 `d`는 두 경로로
신호를 **두 번 받는다**(위 규칙대로 전파는 안 멈추므로). 그래도 **중복
재계산은 일어나지 않는다** — 계산은 `:Get()` 시점에만, 캐시를 통해서만
일어나기 때문:
1. `a:Set()``b`,`c`가 `invalid` 세팅 후 각각 `d`로 전파 → `d`
`invalid` 세팅(두 번째 신호는 이미 세워진 플래그를 다시 세울 뿐).
2. 누군가 `d:Get()``d``invalid`이므로 상류(`b`,`c`)를 `:Get()`
각자 재계산·캐시·`invalid` 해제 → `d`도 계산·캐시·해제.
3. 이후 같은 사이클에서 또 `d:Get()`이 와도 `d`는 이미 valid라 **캐시를
그대로 반환**.
즉 **중복 재계산을 막는 주체는 pull-recompute + 캐시**이지, 전파를
중간에 끊는 게 아니다(`base/architecture.md`의 전파 모델 요약과 같은
이야기). Vide가 `todo.md`에 미해결로 남긴 "다이아몬드 중복 **재평가**"는
이 캐시 구조로 풀리고, quad가 추가로 접지 않는 것은 **중복 *통지***뿐이다
`d` 아래의 `Observer`는 한 사이클에 두 번 울 수 있고, 그건 의도된
동작이다(통지를 접으려면 `Blocker` 같은 명시적 게이트를 쓸 것).
- **순회 비용이 실측에서 문제가 되면** 그때 "**한 번의 전파 파동
안에서만** 같은 노드를 두 번 방문하지 않는다"는 최적화를 넣을 수 있음
(방문 집합/에포크 카운터). 단 그건 **파동 단위**여야 하고, 지금처럼
시간에 걸쳐 유지되는 `invalid` 플래그로 하면 안 됨 — 그게 위 정정의
핵심. 이 최적화는 의미론에 안 보이는 순수 구현 사항이라 지금 결정할
필요 없음.
**전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)** **전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)**
@ -239,11 +281,16 @@ State를 여러 갈래가 공유하는 다이아몬드 형태(`b`에서 `c1 = b:
**"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** "관측해야 **"별도 데이터스트럭처 관리" 부담은 실제로는 작음.** "관측해야
실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건 실체화된다" 원칙 때문에 살아있는 노드-대-노드 구독 엣지가 필요한 건
실제로 관측되는(`Get()`되는) State뿐 — 중간에 만들어놓고 아무도 안 보는 실제로 관측되는(`Get()`되는) State뿐 — 중간에 만들어놓고 아무도 안 보는
State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 워크를 막는 State는 구독 등록 자체가 안 일어남. 다이아몬드에서 중복 재계산을 막는
`invalid` 플래그 dedup 장치도 체인 전체가 링크드일 것을 요구하지 않고 것도 **노드별 캐시**(위 "다이아몬드 의존성은 무엇이 푸는가" 절)라 체인
각 노드가 자기 구독자 목록만 가지면 되는 것이라, 이 결정과 무관하게 전체가 링크드일 것을 요구하지 않고 각 노드가 자기 구독자 목록 + 자기
그대로 유지됨. 구현은 Observer와 동일한 패턴(외부 weak table, 캐시만 가지면 되는 것이라, 이 결정과 무관하게 그대로 유지됨. 구현은
`{[child] = true}` 류)으로 충분 — 새 메커니즘 발명 아님. Observer와 동일한 패턴(외부 weak table, `{[child] = true}` 류)으로 충분 —
새 메커니즘 발명 아님. **[2026-08-14 정정]** 원래 이 자리는 "`invalid`
플래그 dedup 장치"를 근거로 들었으나, 그 장치 자체가 폐기됨(위 전파
모델 절) — 다만 **플래튼 기각이라는 결론은 안 바뀜**. 오히려 캐시가
유일한 중복 방지 수단이 되면서 "State는 캐싱하는 존재"라는 근거가 더
강해짐.
**결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 **결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가
플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는 플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는
@ -326,12 +373,18 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"(
전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로 전부 실제 노드로 두면 코드상의 호출 체인이 그래프 엣지와 1:1로 그대로
대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는 대응됨. 빌더로 만들면 그래프 툴이 "이건 노드가 아니라 나중에 갈라지는
지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음. 지점"이라는 가상의 분기 모양을 따로 합성해야 함 — 그럴 이유가 없음.
2. **다이아몬드 dedup을 못 타고 특수 케이스가 생김.** With가 진짜 노드면 2. **공유 캐시를 못 타고 중복 계산이 생김. [2026-08-14 근거 재작성]**
`w = key1:With(key2)`에서 갈라지는 `c1 = w:Compute(g1)`, `c2 = 원래 이 항목은 "invalid 플래그로 다이아몬드 중복 워크 방지" 장치를
w:Compute(g2)` 같은 흔한 fan-out이 이미 확정된 "invalid 플래그로 근거로 들었으나 **그 장치는 폐기됨**(위 "전파 모델 확정" 절 정정) —
다이아몬드 중복 워크 방지" 장치(위 "전파 모델 확정" 절)를 그대로 근거를 실제로 유효한 것으로 바꿔 적음. With가 진짜 노드면
재사용함. 빌더면 c1/c2가 key1/key2에 각자 직접 구독을 걸어야 해서 `w = key1:With(key2)`에서 갈라지는 `c1 = w:Compute(g1)`,
기존 dedup 경로를 매번 우회하는 특수 케이스가 생김. `c2 = w:Compute(g2)` 같은 흔한 fan-out에서 **`w`의 캐시를 c1/c2가
공유**함(위 "다이아몬드 의존성은 무엇이 푸는가" 절의 그 캐시). 빌더면
`w`라는 노드가 아예 없어서 c1/c2가 key1/key2에 각자 직접 구독을 걸고
각자 계산하므로, 공유 지점이 사라짐 — 바로 위 "왜 State 체인을
Modifier처럼 플래튼하지 않는가" 절이 기각한 것과 **같은 문제**임.
(근거의 강도는 이 재작성으로 오히려 올라감: 예전 근거는 순회 비용
최적화였지만, 지금 근거는 실제 중복 **계산**임.)
3. **clone 기반 구현은 Compute 노드 위에서 실제로 깨짐(사용자 지적, 3. **clone 기반 구현은 Compute 노드 위에서 실제로 깨짐(사용자 지적,
검증 완료).** `c = a:Compute(f)` 뒤에 `w = c:With(b)`를 clone으로 검증 완료).** `c = a:Compute(f)` 뒤에 `w = c:With(b)`를 clone으로
구현하면, `table.clone``c`의 캐시 슬롯(계산된 값 + `invalid` 구현하면, `table.clone``c`의 캐시 슬롯(계산된 값 + `invalid`
@ -826,6 +879,15 @@ retract/Destroy되면 자동으로 정리됨.
`:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 `:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의
`noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()` `noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()`
호출 여부를 작성자가 직접 결정하게 열어둔 것. 호출 여부를 작성자가 직접 결정하게 열어둔 것.
- **⚠️ 이 허용이 전파 모델의 "emit은 항상 전파된다"에 의존한다
(2026-08-14 명시).** `fn``:Get()`을 안 하면 상류 State는 계속
`invalid`로 남는데, 만약 "이미 `invalid`면 전파를 멈춘다"는 규칙이
있으면 **이 Observer는 두 번째 변경부터 영원히 안 울림**. 실제로
2026-08-14 이전까지 위 "전파 모델 확정" 절에 그런 문장이 있었고,
이 계약과 정면 충돌하는 상태로 방치돼 있었음
(`archive/invalidate-dedup-propagation-reversed.md`). **두 서술은
같이 움직여야 함** — 전파를 접는 최적화를 다시 넣고 싶어지면
반드시 이 항목부터 확인할 것.
- **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯 - **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯
번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제 번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제
`fn`을 만들어 반환하는 패턴, `Modifier``Boldify(10)` 커링(`modifier-plan.md` `fn`을 만들어 반환하는 패턴, `Modifier``Boldify(10)` 커링(`modifier-plan.md`

View file

@ -71,7 +71,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `ref-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | | `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `ref-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 |
| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | | `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 |
| `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State<State<T>>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong``handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 | | `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State<State<T>>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong``handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 |
| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 | | `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-14] 전제가 뒤집혀 재작성 대기** — 옛 모델("이미 dirty면 전파 중단")을 검증 중이었으나 그게 폐기됨, 이제 확인할 건 "emit은 항상 전파되고 중복 재계산은 캐시로만 막힌다" | ROADMAP M0-1 |
| `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 | | `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 |
| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 | | `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 |
| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 | | `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 |

View file

@ -1,6 +1,8 @@
# 스파이크 상태판 — **폴더가 곧 상태** # 스파이크 상태판 — **폴더가 곧 상태**
> 마지막 갱신: 2026-08-14 **세 번째 세션**(`bindLifetime`/`canExecute`/ > 마지막 갱신: 2026-08-14 (**"emit은 항상 전파"** 정정으로 `05`가 옛 모델을
> 검증 중이라 `rewrite-required/`로 이동 —
> `archive/invalidate-dedup-propagation-reversed.md`). 직전 갱신은 같은 날 **세 번째 세션**(`bindLifetime`/`canExecute`/
> `unbindLifetime` 재정정으로 `10`이 옛 모델을 검증하고 있어 > `unbindLifetime` 재정정으로 `10`이 옛 모델을 검증하고 있어
> `rewrite-required/`로 이동 — 이제 `not-run/`에는 스파이크가 없고 헬퍼만 > `rewrite-required/`로 이동 — 이제 `not-run/`에는 스파이크가 없고 헬퍼만
> 남음). 직전 갱신은 2026-08-13 열네 번째 세션(하강 diff 재디스패치 확정으로 > 남음). 직전 갱신은 2026-08-13 열네 번째 세션(하강 diff 재디스패치 확정으로
@ -17,9 +19,9 @@
| 폴더 | 뜻 | 개수 | 누가 처리 | | 폴더 | 뜻 | 개수 | 누가 처리 |
|---|---|---|---| |---|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 | | `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 7 | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — | | `done/` | 통과 or 판정 끝, 더 할 일 없음 | 13 | — |
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
@ -43,7 +45,7 @@
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
아님(계약 자체는 위에서 이미 확정됨). 아님(계약 자체는 위에서 이미 확정됨).
## 🟠 `rewrite-required/` — 스파이크가 낡음 (6건) ## 🟠 `rewrite-required/` — 스파이크가 낡음 (7건)
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 **[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
@ -62,6 +64,7 @@
|---|---|---| |---|---|---|
| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | | `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | | `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
| `05-store-state-diamond-propagation.luau` | 옛 모델 기준으로는 ✅ 통과였음 | **검증하던 모델이 뒤집힘**(2026-08-14) — 이 스파이크는 "이미 dirty면 더 아래로 전파하지 않음"을 assert하는데, 그게 `Observer` 계약과 모순돼 폐기됨(`archive/invalidate-dedup-propagation-reversed.md`). 재작성 방향: **emit은 자기 invalid 상태와 무관하게 항상 전파**되는지, 중복 재계산은 `:Get()` 시점 캐시로만 막히는지(재계산 1회 검증은 그대로 유효), 그리고 **`:Get()`을 안 부르는 `Observer`가 매 변경마다 계속 울리는지**(옛 모델에선 두 번째부터 침묵 — 이게 음성 대조군으로 딱 맞음) |
| `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 | | `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 |
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
| `16-type-store-key-typefunction.luau` | ❌ 실패 | `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음 — 실제 API 재확인 후 재시도 | | `16-type-store-key-typefunction.luau` | ❌ 실패 | `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음 — 실제 API 재확인 후 재시도 |
@ -76,18 +79,18 @@
|---|---| |---|---|
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
## ✅ `done/` — 통과 or 판정 끝 (14건) ## ✅ `done/` — 통과 or 판정 끝 (13건)
**런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중 **런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중
`04`/`19`는 검증 대상 설계가 바뀌어 위 `rewrite-required/`로 이동했고, `04`/`19`, [2026-08-14] 추가로 `05`는 검증 대상 설계가 바뀌어 위
아래 표엔 남은 10개만 있음**(실측 당시 통과였다는 사실 자체는 유효): `rewrite-required/`로 이동했고, 아래 표엔 남은 9개만 있음**(실측 당시
통과였다는 사실 자체는 유효):
| 파일 | 확인된 것 | | 파일 | 확인된 것 |
|---|---| |---|---|
| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 | | `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 |
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 |
| `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 |
| `05-store-state-diamond-propagation` | 다이아몬드에서 재계산 정확히 1회, invalidate 2번째는 즉시 중단 |
| `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 | | `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 |
| `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 | | `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 |
| `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 | | `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 |

View file

@ -1,4 +1,21 @@
--[[ --[[
!!! [2026-08-14] 재작성 대기 — 아래 코드는 폐기된 모델을 검증 중 !!!
이 파일은 "이미 dirty로 표시된 노드는 더 아래로 전파하지 않는다"를
assert하는데, 그 규칙이 확정된 Observer 계약(fn이 :Get()을 안 불러도
됨)과 모순돼 역전됐음. 통과했다고 해서 현행 설계를 검증한 게 아님.
- 역전 근거/영향 범위: archive/invalidate-dedup-propagation-reversed.md
- 현행 모델: base/bind-system-plan.md "전파 모델 확정" /
"다이아몬드 의존성은 무엇이 푸는가"
재작성 방향(STATUS.md와 동일):
1. emit은 자기 invalid 상태와 무관하게 *항상* 전파되는가
2. 중복 재계산은 :Get() 시점 캐시로만 막히는가 (재계산 1회는 그대로 유효)
3. :Get()을 안 부르는 Observer가 매 변경마다 계속 울리는가
— 옛 모델에선 두 번째부터 침묵했으므로 이게 딱 맞는 음성 대조군
--- 이하 원문(옛 모델) ---
검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get() 검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get()
시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지. 시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지.

View file

@ -190,6 +190,24 @@
엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정 엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정
규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`. 규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`.
구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함. 구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함.
- **[신설, 2026-08-14] Debounce/Throttle — 남은 열린 질문 4개** —
사용자 요청("`Blocker`와 유사하게 만들어야 한다")으로
`research/debounce-throttle-plan.md` 신설. 네 번의 리뷰 라운드로
**패키지 경계·주입 op 시그니처·`Timeout` 타입·동작 정식화는 전부
확정**됐고, 사용자 취향/방향이 갈리는 것만 남음:
- **의미론** — 창이 열려 있는 동안 `:Get()`이 최신값을 주는가
(`Blocker`와 동일) vs 지연된 값을 주는가(권장, VueUse/RxJS와 일치).
- **제어 핸들**`state:Apply(Debounce{...})`로 끝낼지, `Blocker`
글자 그대로 같은 모양(`Debouncer` 객체 + `state:Debounce(d)` +
`d:Flush()`/`:Cancel()`)으로 갈지. "검색창에서 Enter 누르면 즉시
커밋"류가 잦다고 보면 후자가 처음부터 나음.
- **이름** — Roblox 관용 "debounce"(재진입 방지 불리언)와 정면 충돌.
업계 표준 이름을 유지하고 문서로 경고할지, 다른 이름을 쓸지.
- **`Time = 0` 허용 여부** — "이번 스텝 합치기"로 유용하지만 스케줄러
타이밍에 의존하는 준-`Blocker`가 됨.
우선순위 낮음 — 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

@ -41,6 +41,11 @@ Store/Slot/Tween/bind-dispatch 설계 결정에 근거로 인용될 때만 열
깊이우선으로 모든 의존 노드를 재평가(lazy/pull 경로 없음). **저자들 스스로 깊이우선으로 모든 의존 노드를 재평가(lazy/pull 경로 없음). **저자들 스스로
`todo.md`에 "복잡한 다이아몬드 그래프에서 중복 재평가 방지" 를 미해결로 남겨둠** `todo.md`에 "복잡한 다이아몬드 그래프에서 중복 재평가 방지" 를 미해결로 남겨둠**
— quad Store가 이 naive BFS 방식을 그대로 베끼면 안 되는 이유. — quad Store가 이 naive BFS 방식을 그대로 베끼면 안 되는 이유.
**[2026-08-14 보강]** quad가 이걸 피하는 방식은 "전파를 중간에 끊는 것"이
아니라 **애초에 push 시점에 계산을 안 하는 것**(pull-recompute + 노드별
캐시) — 신호는 두 경로로 두 번 도착해도 계산은 `:Get()` 때 한 번뿐.
즉 quad도 중복 *통지*는 접지 않고 중복 *재평가*만 안 일어남
(`base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절).
- **정리 모델**: 의존성 엣지(`parents`)와 구조적 소유(`owner`/`owned`)를 같은 - **정리 모델**: 의존성 엣지(`parents`)와 구조적 소유(`owner`/`owned`)를 같은
`Node`에서 두 개의 별도 관계로 분리 — CHANGELOG 0.2.0에서 "destroy가 더 이상 `Node`에서 두 개의 별도 관계로 분리 — CHANGELOG 0.2.0에서 "destroy가 더 이상
reactive dependent까지 타고 내려가지 않고 owned만" 으로 명시적으로 고침(초기 reactive dependent까지 타고 내려가지 않고 owned만" 으로 명시적으로 고침(초기

View file

@ -84,8 +84,14 @@ State 메소드로 두려던 초기 폼팩터가 기각된 경위만 여전히
못박아둬서 캡슐화 깨짐 문제 자체가 대부분 상황에서 안 생김. 못박아둬서 캡슐화 깨짐 문제 자체가 대부분 상황에서 안 생김.
- **Fusion `Observer`/`Attribute`**: quad `state:Observer(fn)` + - **Fusion `Observer`/`Attribute`**: quad `state:Observer(fn)` +
`bind-system-plan.md`의 Attribute 논의로 이미 커버 중, 신규 아님. `bind-system-plan.md`의 Attribute 논의로 이미 커버 중, 신규 아님.
- **디바운스/스로틀**: Fusion/Vide/v1 어디에도 공개 프리미티브로 없음 — - **디바운스/스로틀**: ~~Fusion/Vide/v1 어디에도 공개 프리미티브로 없음 —
세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황. 세 레포 모두 없다는 것 자체가 "quad도 굳이 안 만들어도 된다"는 정황.~~
**[2026-08-14 뒤집힘]** 사용자가 직접 "`Blocker`와 유사하게 만들어야
한다"고 지정 — 위 "빈 자리 아님" 판정은 더 이상 유효하지 않음.
설계 초안은 `research/debounce-throttle-plan.md`. (원 판정의 근거였던
"세 레포에 없다"는 관찰 자체는 사실이지만, 2026-08-12 열아홉 번째 세션의
외부 리서치에서 RxJS/VueUse 등 Roblox 밖에선 가장 흔한 콤비네이터
카테고리 중 하나로 확인돼 근거로서의 무게가 이미 약해져 있었음.)
## 문서화 백로그 (2026-08-06~07, `documentation-content-map.md`에도 반영) ## 문서화 백로그 (2026-08-06~07, `documentation-content-map.md`에도 반영)

View file

@ -0,0 +1,923 @@
# Debounce / Throttle — 시간 기반 전파 게이트
**상태**: research — **[2026-08-14 신설]** 사용자 요청("`Blocker`와 유사하게
Debounce/Throttle를 만들어야 한다")으로 신설. 여기 적힌 건 **에이전트가
먼저 전부 정의해본 초안**이고, 확정된 건 하나도 없음 — 사용자가 이 문서를
읽고 판단하는 게 다음 단계. 열린 결정은 맨 아래 "사용자 판단 대기" 절에
번호로 모아뒀음.
> **[2026-08-14 1차 리뷰 반영]** 사용자가 스로틀의 trailing 동작을 짚어준
> 뒤 세 가지가 바뀜: (1) **Q5(패키지 경계) 해소** — quad-base + 엔진별
> 태스크 배선으로 확정(6절), (2) **초안 의사코드의 실제 버그 수정**
> lodash식 `MaxTime` 공식이 trailing 통과 직후 이중 발화하는 구멍이
> 있었고, "`Reset` 한 비트" 정식화로 대체(1-1/5-3/7절), (3) **3절 발견의
> 범위 축소** — 파생 State 위 퇴화는 `Debounce`만 겪고 `Throttle`은 거의
> 면역임이 드러남.
>
> **[2026-08-14 2차 리뷰 반영]** 사용자가 주입 op의 이름/시그니처를 직접
> 지정: **`setTimeout(func, delay) -> Timeout` / `clearTimeout(handle)`**
> (6절). 같이 확정/정정된 것 — (1) **`os.clock()`은 주입 대상이 아님**
> (Luau 표준 라이브러리이고, Lua 5.x와 달리 일관되게 고정밀 값 —
> 단 **절대 시각이 아니라 차이 계산 전용**, 이 설계는 남은 시간만 재므로
> 무관), 그래서 초안이 "시계가 필요하면 세 번째 op 추가"라고 한 부분은 무효,
> (2) **취소를 제공 안 하는 엔진도 대응 가능**(래핑 + 유효 플래그) —
> `clearTimeout`은 백엔드에 요구해도 되는 계약, (3) `Timeout` 타입은
> **`{ __type_timeout: true, _native: any }`로 확정**(에이전트가 권한
> `any`는 사용자 반론으로 철회 — 6절에 뒤집힌 이유 기록), (4) `MaxTime`
> 타이머 2개가 아니라 `min()` 하나로 줄이는 구현(6-1절).
>
> **[2026-08-14 3차 리뷰 반영]** 사용자가 **"emit은 항상 재전파된다"**고
> 지적 — 이 문서가 두 라운드 동안 "가장 중요한 발견"으로 들고 있던 3절이
> **전제부터 틀렸던 것으로 드러나 통째로 철회됨**(파생 State 위 퇴화 없음,
> "`Source`에 가깝게" 규칙 폐기, Q6 소멸). 대신 그 확인 과정에서
> **`base/source-state-plan.md`의 무효화 dedup 문장이 확정된 `Observer`
> 계약과 모순**되고 `base/architecture.md`와도 어긋난다는 게 드러나,
> base 정정 항목(Q10)이 새로 생김.
`research/operator-sugar-plan.md`가 "`Operator.*` 카탈로그 밖의 별도
설계 질문"으로 분리해뒀던 항목이 이 문서의 출발점(그 문서 "열린 질문 —
포함 범위" 절의 Debounce/Throttle 항목). `research/additional-primitives-plan.md`
2026-08-06 조사에서 "Fusion/Vide/v1 어디에도 없으니 quad도 굳이 안
만들어도 된다는 정황"으로 적어둔 판단은 **이 요청으로 뒤집힘**(그
문서에도 포인터를 남겨둠).
---
## 0. 세 줄 요약
1. **Debounce/Throttle은 `Blocker`와 같은 자리(무효화 전파 게이트)에
놓이고, 다른 건 "언제 열리는가"뿐** — `Blocker`는 사용자가
`:Off()`로 열고, 이쪽은 타이머가 연다. 새 전파 메커니즘이 아니라
**`Blocker`가 이미 쓰는 게이트 노드의 릴리스 트리거 교체**. 그리고
**Debounce와 Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한
비트뿐**(1-1절) — 공개 이름은 둘, 구현은 하나.
2. **quad의 lazy 전파(push-invalidate/pull-recompute) 위에서도 laziness가
깨지지 않음** — 게이트는 무효화 채널만 만지고 `:Get()`을 절대 호출하지
않기 때문. **[2026-08-14 3차 리뷰]** 여기 있던 "파생 State 위에 얹으면
퇴화한다"는 발견은 **전제가 틀려 철회됨**(3절) — emit은 항상 재전파되므로
게이트는 중간에 뭐가 끼든 매 변경마다 신호를 받음. 대신 그 과정에서
**`base/source-state-plan.md`의 무효화 dedup 문장이 확정된 `Observer`
계약과 모순된다는 게 드러나** base 정정 항목이 생김(Q10).
3. **타이머는 엔진 종속이지만 부기 알고리즘은 아님**`Tag`/`Attribute`가
14차 세션에 밟은 길(알고리즘은 quad-base, 엔진에 손대는 마지막 한 줄만
주입)을 그대로 따라 **주입 op 2개**(`setTimeout`/`clearTimeout`)만
추가. `Tween`처럼 통째로 quad-roblox에 두는 건 근거가 다름.
**[2026-08-14 사용자 확정]** 이 방향으로 확정 — 순수 Luau엔 `task`
자체가 없어 base가 동작하는 기본 스케줄러를 제공할 수조차 없고,
`Throttle`도 trailing 때문에 이 배선이 똑같이 필요함(6절). 반대로
**`os.clock()`은 Luau 표준 라이브러리라 주입 대상이 아님** — 주입이
필요한 건 "미래에 실행시키는 능력"뿐이고, "얼마나 지났나"는 언어가
이미 줌(절대 시각이 아니라 **차이 계산 전용**, 6절 주의).
---
## 1. 왜 `Blocker`로는 안 되는가 (그리고 왜 그 옆자리인가)
`base/blocker-plan.md`의 "메커니즘 (확정)"이 정의하는 게이티드 State는
정확히 이렇게 동작함:
- 상류가 emit(무효화)하면 게이티드 노드로 전파를 시도
- 블록 중이면 전파 안 하고 `HasBlockedEmit = true`만 세팅
- 열릴 때 `HasBlockedEmit`이 true면 **정확히 1회** 전파하고 플래그 리셋
이건 debounce/throttle이 필요로 하는 것과 **글자 그대로 같음**. 차이는 딱
하나 — 여는 주체:
| | 닫는 계기 | 여는 계기 | 창의 길이 |
|---|---|---|---|
| `Blocker` | 사용자 `:On()` | 사용자 `:Off()` | 사용자가 정함(코드 구간) |
| `Debounce` | 상류 신호 도착 | 신호가 `Time`동안 없으면 | 조용해질 때까지(가변) |
| `Throttle` | 통과 직후 | `Time` 경과 | 고정 |
**그래서 "중복 프리미티브 아니냐"는 반문에 대한 답**: `Blocker`는 "내가
코드로 구간을 안다"(여러 `:Set()`을 한 트랜잭션으로 묶음), Debounce/Throttle은
"구간을 코드가 모른다"(사용자 입력·고빈도 신호처럼 언제 끝날지 모름).
`Blocker`가 lexical `Batch(fn)`을 기각하면서 얻은 것이 정확히 "콜스택이
아니라 값으로 표현"인데(`archive/batch-rejected.md`), 시간 기반 게이트는
값으로도 표현할 수 없는 나머지 절반임 — 겹치는 게 아니라 상보적.
> **참고로 겹치는 지점이 딱 하나 있음**: `Debounce{Time = 0}`은 사실상
> "이번 스텝의 변경을 자동으로 합쳐서 다음 스텝에 한 번만 전파"라
> `Blocker`를 손으로 여닫는 것의 자동판 근사가 됨. 이게 `Blocker`
> 대체하지는 않음(`Blocker`는 "정확히 이 코드 구간"이라는 결정성을 주고,
> `Time = 0`은 스케줄러 타이밍에 의존) — 다만 문서에서 두 도구를 비교
> 설명할 때 좋은 대조 사례.
### 1-1. 정확히 어떤 동작인가 — 두 도구의 차이는 "한 비트"뿐 (2026-08-14 후속, 초안 정정)
**[초안 정정]** 이 절은 사용자가 스로틀의 trailing 동작("1초 스로틀에서
0.0과 0.1에 입력하면, 1.0에 0.1의 값이 다시 적용되어야 한다")을 짚어준
뒤 검증하다가 **초안 의사코드의 실제 버그를 발견해** 다시 쓴 것. 초안은
lodash식 `MaxTime`(maxWait) 공식을 그대로 옮겼는데, 그 형태는 trailing
통과 직후 타이머를 전부 회수해버려서 **바로 뒤에 온 신호가 "창 밖"으로
판정돼 또 즉시 발화**함(1초 안에 두 번 나감). 아래 정식화는 그 구멍이
구조적으로 없음.
**디바운스** — "조용해질 때까지 기다렸다 한 번". 신호가 올 때마다 타이머를
**처음부터 다시** 시작하고, `Time`동안 아무 신호도 없을 때 1회 발화.
```
Debounce{Time = 1}
0.0 신호 ──┐ 창 마감을 1.0으로
0.1 신호 ──┤ 취소하고 1.1로
0.3 신호 ──┤ 취소하고 1.3으로
(조용)
1.3 ● 1회 발화 (0.3 시점의 값)
```
**스로틀** — "창 하나당 최대 한 번". 창 길이가 **고정**이라 신호가 창을
밀지 못함. 첫 신호는 즉시 통과(leading), 창 안에 들어온 신호는 눌러뒀다가
창이 끝날 때 최신값으로 1회 통과(trailing).
```
Throttle{Time = 1}
0.0 신호 ──● 즉시 통과(leading), 창 마감 1.0 고정
0.1 신호 ──┘ 눌러둠(pending), 창은 그대로 1.0
1.0 ● 0.1 시점의 최신값으로 통과(trailing) — 사용자가 짚은 그 동작
통과했으니 창을 다시 엶(마감 2.0)
1.05 신호 ──┘ 아직 창 안 → 눌러둠 (초안 버그였던 이중 발화가 여기서 막힘)
2.0 ● 통과
```
| | 버스트 중간 | 버스트 끝 | 신호가 끊이지 않을 때 |
|---|---|---|---|
| Debounce | 아무것도 안 함 | 1회 | **영원히 발화 안 함** |
| Throttle | `Time`마다 1회 | 마지막 1회 | `Time`마다 계속 |
"신호가 끊이지 않으면 영원히 안 함"은 버그가 아니라 **디바운스의 정의
그 자체** — lodash에 `maxWait`가 있는 이유가 정확히 이것(아무리 계속
와도 최대 이만큼마다는 한 번 내보내라). 그래서 `MaxTime`은 이 새
정식화에서 **디바운스 전용 안전장치**로 역할이 좁아짐(스로틀은 원래
주기적으로 발화하므로 필요 없음).
**⭐ 결론 — 두 도구의 차이는 딱 한 비트**:
> **신호가 창 타이머를 리셋하는가.** 디바운스는 리셋함(창이 신호를 따라
> 밀림), 스로틀은 리셋 안 함(창 길이 고정).
leading/trailing/통과 후 창 재개방은 **완전히 동일**. 그래서 공용 게이트
하나에 `Reset` 불리언 하나만 있으면 둘 다 나옴(7절 의사코드). lodash처럼
`maxWait`로 스로틀을 흉내 낼 필요가 없고, 그 과정에서 생기던 이중 발화
구멍도 없어짐.
### 공개 `Blocker` API 위에 얹어서 만들 수는 없음
"`Debounce`를 내부적으로 `Blocker` 하나 만들어 타이머로 `:On()`/`:Off()`
치는 걸로 구현하면 안 되나?"는 자연스러운 질문인데, **안 됨** — 공개
`Blocker` API엔 "상류 신호가 지금 도착했다"를 알려주는 통지가 없음.
타이머를 (재)시작하려면 그 순간을 알아야 하는데 알 방법이 없어서, 결국
게이트 노드 내부 훅이 필요함.
**그래서 권하는 구현 방향**: `Blocker`의 게이티드 노드를 내부 공용
`Gate` 노드로 한 겹 일반화하고(= "상류 신호를 받되 전파 여부를 정책이
결정하는 State 노드"), `Blocker`/`Debounce`/`Throttle`이 그 위의 서로 다른
정책으로 얹히는 형태. 새 노드 종류를 하나 더 만드는 게 아니라, 이미 하나
있는 걸 두 번째 사용처가 생겼으니 이름 붙여 꺼내는 것뿐.
---
## 2. laziness는 안 깨진다 (확인)
quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정" 절에서
확정된 push-invalidate(신호만) / pull-recompute(`:Get()`
시점)이고, 전역 원칙은 "관측해야 실체화된다"임. 시간 기반 게이트가 이걸
깨는지 확인해봤는데 — **안 깨짐**:
- 게이트는 **무효화 신호만** 받고, 자기도 **무효화 신호만** 내려보냄.
`:Get()`을 스스로 호출하는 지점이 한 군데도 없음.
- 타이머 콜백이 하는 일은 "invalid 세팅 + 아래로 전파" 뿐. 실제 계산은
여전히 소비자가 `:Get()`할 때만 일어남.
- 즉 게이트는 **eager 노드가 아님**. Fusion의 `timeliness="eager"`류 장치를
들여올 필요 없음(그건 이미 기각된 방향).
**단, 이 성질을 깨는 변형이 하나 있으니 넣지 말 것**: "값이 실제로
바뀌었을 때만 통과"(distinct-until-changed)를 게이트에 섞으면 게이트가
`:Get()`을 호출해야 하고, 그 순간 상류 체인 전체가 eager가 됨. 필요하면
그건 별도 콤비네이터로, 그리고 그 대가를 명시적으로 문서화한 채로 다룰 것
(이 문서 범위 밖).
---
## 3. ~~파생 State 위에 얹으면 debounce가 throttle로 퇴화한다~~ → **철회됨, 전제가 틀렸음 (2026-08-14 3차 리뷰)**
> **이 절은 이 문서에서 "가장 중요한 발견"으로 두 라운드를 버텼지만,
> 사용자 지적으로 전제가 무너져 통째로 철회됨.** 원래 주장: 무효화
> dedup 때문에 게이트가 버스트 중 두 번째 이후 신호를 못 받아,
> `Debounce`가 "마지막 변경 후 T초"가 아니라 "첫 변경 후 T초"로
> 조용히 퇴화한다 — 그래서 `Source`에 가깝게 걸어야 한다는 규칙과
> 열린 질문 Q6이 여기서 나왔었음. **셋 다 무효.** 원문은
> `session/2026-08-14-08-debounce-throttle-backlog.md`에 보존.
### 무엇이 전제였고 왜 무너졌는가
전제는 `base/source-state-plan.md` "전파 모델 확정" 절(2026-08-14 세 번째
세션의 3단계 분할 전에는 `bind-system-plan.md`)의 이 한 줄이었음:
> 신호를 받은 State는 자기 `invalid` 플래그만 세우고, **이미 `invalid`였다면
> 그 아래로 더 전파하지 않는다** — 다이아몬드 의존성에서 중복 워크를 막는 장치
사용자 지적: **"emit은 항상 재전파된다. 저 동작은 정확히 `Blocker`
하는 것."** 확인해보니 맞고, 저 문장은 **이전 세션의 과잉 일반화**로
보임. 근거 셋:
1. **다이아몬드 근거가 요구하는 범위를 넘어섬.** 다이아몬드 문제는
`a → b`, `a → c`, `(b,c) → d`에서 **한 번의 변경**이 여러 경로로
`d`에 도달하는 것 — 이걸 막으려면 **그 전파 파동(wave) 안에서만**
중복을 접으면 됨. 그런데 저 문장은 `invalid`를 **시간에 걸쳐 유지되는
상태**로 써서("이미 `invalid`였다면"), 누가 `:Get()`할 때까지 **이후의
모든 변경**까지 삼켜버림. 파동 내 dedup과 시간축 dedup은 전혀 다른
범위인데 한 문장으로 뭉뚱그려짐.
2. **애초에 다이아몬드 중복 *재계산*은 이 장치가 푸는 게 아님.**
`base/architecture.md`가 같은 주제를 이렇게 서술함 — "전파는
push-invalidate(신호만)/pull-recompute(`Get()` 시점) — **Fusion식
eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림**". 즉
중복 평가를 막는 건 **pull-recompute 그 자체**임(값은 `:Get()`
한 번만 계산됨). `invalid` 플래그 dedup이 절약하는 건 재계산이 아니라
**플래그 세팅 트리 순회 비용**뿐 — 정확성 장치가 아니라 최적화인데,
`source-state-plan.md`는 이걸 다이아몬드의 해결책으로 승격시켜 서술함.
**base 안에서 두 문서가 같은 문제의 해결 주체를 다르게 지목하고 있음.**
3. **⭐ 확정된 `Observer` 계약과 정면 충돌.** 같은 파일이
"`state:Observer(fn)`" 절에서 **`fn``:Get()`을 부르지 않아도 되는
것을 명시적으로 허용**함 — "재계산이 진짜 필요한지가 다른 `:With`
값에 따라 갈리는 경우가 있어서 (…) `Get()` 호출 여부를 작성자가 직접
결정하게 열어둔 것". 그런데 dedup 문장을 액면대로 적용하면:
```
source:Set(1) → state invalid → Observer 발화 → fn이 :Get() 안 함
→ state는 invalid로 남음
source:Set(2) → state 이미 invalid → 전파 안 함 → Observer 안 울림 ❌
source:Set(3) → 마찬가지 ❌ ... 영원히
```
**`:Get()`을 안 하는 Observer는 딱 한 번 울고 영구히 침묵함.**
문서가 정당한 사용법으로 허용한 것이 문서의 다른 문장 때문에 조용히
깨지는 것이므로, 취향 문제가 아니라 **base 내부의 실제 모순**임.
사용자의 "저건 `Blocker`가 하는 것" 지적도 정확함 — `HasBlockedEmit`
바로 "블록 중엔 여러 emit을 하나로 접어뒀다가 열릴 때 1회"이고,
`Blocker`는 그걸 **명시적으로 켜고 끄는 opt-in 게이트**로 제공함. 같은
동작을 모든 State 노드에 암묵적으로 심어두면 `Blocker`의 존재 의의가
절반 사라짐.
### 그래서 정정 후 그림 (권고)
- **emit(무효화 신호)은 구독자에게 항상 전파된다.** State가 이미
`invalid`인지는 전파 여부와 무관.
- 다이아몬드 중복 *재계산*은 **pull-recompute가 이미 구조적으로**
막음(`architecture.md`의 서술이 맞음) — 별도 장치 불필요.
- 한 번의 변경이 여러 경로로 같은 노드에 닿는 **파동 내 중복 순회**가
실측에서 문제가 되면, 그때 **파동 단위**(방문 집합/에포크 카운터)로
접으면 됨 — 이건 의미론에 안 보이는 **순수 구현 최적화**이고, 지금처럼
영속 플래그로 하면 안 됨.
### 이 문서에 미치는 영향 — 세 개가 사라짐
1. **"파생 State 위에서 퇴화한다"는 함정 자체가 없음.** 게이트는 중간에
무엇이 끼어 있든 매 변경마다 신호를 받으므로 `Debounce`가 어디서든
정상 동작함.
2. **"`Source`에 가깝게 걸어라"는 규칙 불필요.** `Blocker`의 "파이프라인
끝에 걸어라"와의 "정확한 거울상"이라는 서술도 같이 폐기 — 예쁜
대칭이었지만 틀린 전제 위에 있었음.
3. **열린 질문 Q6 소멸**(문서 권고 / 경고 / 에러 중 택일할 대상이 없음).
2차 리뷰에서 "이건 `Debounce`만 겪고 `Throttle`은 거의 면역"이라고
범위를 좁혔던 정정도 같이 무의미해짐 — 애초에 둘 다 안 겪음.
### 대신 생긴 것 — base 정정 **완료** (2026-08-14, 사용자 확정)
사용자가 모델을 확정해줌:
> **emit은 항상 전파함. `Blocker`나 emit 전파 지연요소만 이를 지연할 수
> 있음.** 재계산 막아지는 건 맞음 — 한 곳에서 `Get`이 되면, `invalid`하다면
> 위로 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음
> `invalid`가 꺼짐.
그리고 "다 다시 써야 한다"는 지시로 **코퍼스 전체 정정을 같은 세션에
수행함**. 정정된 모델:
- `invalid` 플래그 = **"내 캐시가 낡았다"는 표시 하나뿐**, 전파 제어
장치가 아님.
- **emit은 자기 `invalid` 상태와 무관하게 항상 아래로 전파됨.**
- 중복 **재계산**은 pull-recompute + 노드별 캐시가 막음. 중복 **통지**는
안 접음(다이아몬드에서 아래쪽 Observer가 두 번 우는 건 의도된 동작).
- 전파를 지연/흡수할 수 있는 건 **명시적 게이트뿐** — 지금은 `Blocker`,
앞으로 이 문서의 시간 기반 게이트.
고친 곳(원문·역전 근거·영향 범위 전체는
`archive/invalidate-dedup-propagation-reversed.md`):
`base/source-state-plan.md`(전파 규칙 재작성 + "다이아몬드 의존성은 무엇이
푸는가" 절 신설 + `Observer` 절 상호 참조), `base/architecture.md`,
`base/blocker-plan.md`("전파를 지연시키는 유일한 요소"로 위치 명문화),
`reference/comparison-fusion-vide.md`,
`research/framework-comparison-findings.md`, `ROADMAP.md` M0 체크리스트,
스파이크 `05-store-state-diamond-propagation.luau`(옛 모델을 통과 상태로
검증 중이었음 → `rewrite-required/`), `audit/luau-test-first-run-2026-08-13.md`.
**부수 확인 — `:With` 빌더 기각은 유지됨.** `base/source-state-plan.md`
"`:With`도 새 State 노드로 확정" 절 근거 2번이 폐기된 dedup 장치를
가리키고 있어 근거를 **캐시 공유**로 다시 씀. 근거 1·3번이 그대로
유효하고 결론(빌더 기각)도 안 바뀜 — 오히려 근거 강도는 올라감(예전
근거는 순회 비용 최적화였지만 지금은 실제 중복 *계산*이라서).
---
## 4. 의미론 — 지연되는 건 "전파"인가 "값"인가
두 갈래가 있고, 이게 이 문서에서 두 번째로 큰 결정.
### (A) emit-gate — `Blocker`와 완전히 동일
게이트는 자기 `invalid`를 즉시 세우되 아래로 전파만 미룸. 창이 열려 있는
동안 누가 `debounced:Get()`하면 **최신값**이 나옴.
### (B) value-hold — 값 자체가 지연됨 (권장)
게이트는 창이 닫혀 있는 동안 **자기를 invalid로 만들지 않음**. 즉 캐시된
직전 값을 계속 들고 있고, `debounced:Get()`은 **지연된 값**을 반환. 타이머가
커밋할 때 비로소 invalid + 전파.
**(B)를 권하는 이유**:
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)의 캐비엇 하나**: 노드가 아직 한 번도 계산된 적 없으면(캐시 없음)
붙잡고 있을 "이전 값"이 없어서 첫 `:Get()`은 그냥 최신값을 계산함.
"지연은 두 번째 값부터"라는 뜻 — 자연스럽고 무해하지만 문서에 명시할 것.
---
## 5. API 모양
### 5-1. 붙이는 방법 — `:Apply` (`Operator` 관용구와 동일)
`research/operator-sugar-plan.md`의 "왜 `:Apply`인가" 절이 확정한 규칙
("이름 붙여 재사용하는 콤비네이터는 전부 `factory(self)` 모양 + `:Apply`")에
그대로 맞음:
```lua
local debounced = text:Apply(Debounce{Time = 0.3})
local sampled = mousePos:Apply(Throttle{Time = 1 / 30})
```
- `Debounce{...}`는 **팩토리를 반환**하므로 이름 붙여 재사용 가능:
`local search = Debounce{Time = 0.3}` 후 여러 state에 `:Apply(search)`.
- **재사용해도 상태를 공유하지 않음**`:Apply`가 호출될 때마다 팩토리가
새 게이트 노드(자기 타이머/자기 pending 플래그)를 만듦. `Blocker`
"겹치는 배치엔 각자 새 인스턴스를 만들 것"으로 네스팅을 막은 것과 달리,
여기는 재사용이 원천적으로 안전함(공유되는 가변 상태가 없으므로).
### 5-2. 옵션 필드
```lua
type DebounceOptions = {
Time: number, -- 필수. 창 길이(초) — 신호마다 리셋됨
Leading: boolean?, -- 기본 false. 버스트의 첫 신호를 즉시 통과시킬지
Trailing: boolean?, -- 기본 true. 조용해진 뒤 한 번 통과시킬지
MaxTime: number?, -- 기본 nil. 신호가 안 끊겨도 최대 이 간격마다 강제 통과
}
type ThrottleOptions = {
Time: number, -- 필수. 창 길이(초) — 신호가 리셋하지 못함
Leading: boolean?, -- 기본 true. 창 밖 첫 신호를 즉시 통과시킬지
Trailing: boolean?, -- 기본 true. 창 안에 눌러둔 게 있으면 창 끝에 통과시킬지
}
```
- **모든 필드는 plain 값만**`State`를 못 받음. `base/tween-plan.md`
"옵션 값 모양" 절이 `Tween{...}`에 대해 확정한 것과 같은 규칙, 같은
이유(옵션 안에 두 번째 반응 경로를 만들지 않음). `Time`을 반응형으로
바꾸고 싶으면 `state:Apply(...)` 자체를 다시 만드는 상위 구조가 필요한데,
그건 이 프리미티브가 아니라 `State<State<T>>` 영역.
- `Leading = true, Trailing = false` → "버스트 시작에 한 번만"
- `Leading = false, Trailing = true` → 기본값, 일반적인 debounce
- 둘 다 `false`는 아무 일도 안 하는 설정 → **즉시 error** 권장(`Slot:Add`의
범위 밖 인덱스를 clamp 대신 error로 한 선례와 같은 결).
### 5-3. 공개 이름은 둘, 구현은 하나 — `Reset` 한 비트로 갈림 (2026-08-14 개정)
**[초안 정정]** 초안은 lodash를 따라
`Throttle{Time=t} == Debounce{Time=t, Leading=true, Trailing=true, MaxTime=t}`
("`Throttle`은 `Debounce`의 프리셋")로 적었는데, 1-1절에서 그 형태에
이중 발화 버그가 있다는 게 드러나 폐기. 정정된 관계는 훨씬 단순함:
| | `Reset` | `Leading` | `Trailing` |
|---|---|---|---|
| `Debounce{Time}` | **true**(신호가 창을 민다) | 기본 false | 기본 true |
| `Throttle{Time}` | **false**(창 길이 고정) | 기본 true | 기본 true |
- **공개 표면은 생성자 2개** — 사용자가 이미 아는 이름이고, 각자 다른
기본값을 갖는 게 자연스러움. "`Throttle`을 쓰려면 `Debounce`의 옵션
4개를 알아야 한다"는 프리셋 안의 단점이 사라짐.
- **구현은 하나** — 내부 공용 게이트가 `Reset` 불리언 하나로 분기.
quad가 반복해온 "같은 일 하는 두 번째 경로를 안 만든다"(`Effect`가
deps 배열 대신 `:With` 재사용, `Slot:List`가 Fusion 3분할을 흡수)를
그대로 지키면서, 두 이름이 미묘하게 갈라지는 버그도 원천 차단.
- **`Reset`은 공개 옵션으로 노출하지 않음** — 노출하면
`Debounce{Reset=false}`처럼 "이름과 동작이 어긋난 물건"을 만들 수 있게
됨. 내부 파라미터로만 둘 것.
- **`MaxTime``Debounce` 전용으로 역할 축소** — 1-1절대로 "신호가
끊이지 않으면 영원히 발화 안 함"이 디바운스의 정의라 그 안전장치가
필요하지만, 스로틀은 원래 주기적으로 발화하므로 무의미함.
→ 열린 질문 Q3(이 개정안 확인).
---
## 6. 패키지 경계 — quad-base + 주입 op 2개 (**사용자 확인됨, 2026-08-14**)
> **[2026-08-14] 이 절의 방향은 사용자가 직접 확인함** — "기본 구현은
> quad-base에 있는데, 엔진 따라 해당 태스크 부분만 배선해주면 되도록
> 만들어져야 한다 생각함". 아래는 그 결정의 근거와 구체 형태.
>
> 사용자가 같이 짚은 사실: **`task`는 Roblox 전용 전역이지 Luau 언어의
> 일부가 아님** — 순수 `luau` CLI엔 `task.delay`/`task.wait`가 아예 없고,
> 애초에 이벤트 루프 자체가 없음. 즉 **quad-base는 "동작하는 기본 스케줄러"를
> 제공할 수가 없음**(제공할 원시 재료가 없음). 그래서 base가 갖는 건
> 알고리즘 + 인터페이스이고, 미배선 상태에선 엔진 op 3개와 같은 관례대로
> **명확한 에러를 내는 스텁**이어야 함. `Throttle` 역시 trailing 때문에
> "나중에 처리해준다"가 반드시 필요하므로 **디바운스와 똑같이 이 배선에
> 의존함** — 스로틀만 타이머 없이 되는 게 아님.
### 왜 `Tween`처럼 통째로 quad-roblox에 두지 않는가
`base/tween-plan.md`의 "패키지 경계" 절이 `Tween`을 quad-roblox에 둔 건
`Tween`**TweenService라는 엔진 기계 자체**(보간 엔진, easing style,
per-instance Tween 객체)에 의존하기 때문. Debounce/Throttle이 엔진에서
필요로 하는 건 **시계 하나**뿐이고, 나머지(pending 플래그, 타이머 리셋,
leading/trailing 판정, MaxTime 부기)는 전부 순수 로직임.
이건 2026-08-13 열네 번째 세션이 `Tag`/`Attribute`에서 내린 판단과 정확히
같은 상황 — 부기 알고리즘을 백엔드마다 복제하지 않기 위해 알고리즘은
quad-base로 옮기고 엔진에 실제로 손대는 한 줄만 주입받게 했음
(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op"
절). **같은 논리를 그대로 적용하면 알고리즘은 quad-base.**
### 주입 op — `setTimeout`/`clearTimeout` (2026-08-14 사용자 지정)
`base/bind-system-plan.md`의 "base 유틸은 인터페이스, 실제 구현은 백엔드
팩토리가 주입" 절이 정한 경로에 2개 추가. **핸들러 op 3개
(`addTag`/`removeTag`/`setAttribute`)가 아니라 `bindLifetime`/`canExecute`와
같은 "base 범용 유틸" 그룹**임 — 특정 핸들러가 아니라 아무나 쓰는 배관.
```lua
setTimeout(func: () -> (), delay: number): Timeout
clearTimeout(handle: Timeout): ()
```
- **이름/인자 순서는 사용자 지정** — JS 관례대로 **함수가 먼저**. 케이싱은
탑레벨 소문자 유틸 관례(`bindLifetime`/`canExecute`/`unbindLifetime`)와
일치(`base/architecture.md`의 "코드 스타일 — 네이밍 케이싱" 절).
- **왜 `task.delay`/`task.cancel`을 안 따라갔는가 (사용자 근거)**
**`task`는 표준이 아니고 Luau의 것도 아닌, 한 엔진의 것**임. base는
"누가 실제로 그려주는지 모르는" 층이라(`base/module-lifecycle-plan.md`)
특정 백엔드의 어휘를 그 층에 새기면 그 엔진만 특별대우하는 셈이 됨.
그래서 **가장 대중적이고 엔진 중립적인 JS 어휘**를 가져옴 — 어느
백엔드 작성자가 봐도 즉시 알아보는 이름. (14차 세션이 엔진 op를
`addTag`/`setAttribute`로 정할 때 Roblox `CollectionService`와 웹
`className`/`data-*` 양쪽에 걸치는 이름을 고른 것과 같은 결.)
- ⚠️ **그 대가로 생기는 구현 함정**: Roblox `task.delay(duration, fn, ...)`
**반대로 시간이 먼저**라, 배선할 때 인자가 뒤집힘. 래퍼에서 한 번
뒤집어주는 게 전부지만 조용히 틀리기 좋은 자리 — 이름을 중립으로 두기로
한 이상 감수하는 비용이고, 배선 지점이 백엔드당 한 곳뿐이라 감당 가능.
- **가변인자(`...`)는 일부러 안 받음**`task.delay``fn`에 넘길 추가
인자를 받지만, 게이트의 콜백은 게이트당 하나씩 만들어져 재사용되는
안정된 클로저(`onWindowEnd`)라 호출마다 새로 만들 필요가 없음. 즉
varargs로 아낄 할당이 애초에 없어서 표면만 넓히는 셈.
- **미주입 백엔드에서는 base 스텁이 명확한 에러** — 엔진 op 3개와 동일한
관례. 순수 Luau엔 `task`도 이벤트 루프도 없어 base가 "적당한 기본값"을
만들어낼 수 없음.
- **`Debounce`/`Throttle` 둘 다 이 배선이 있어야 동작함** — 스로틀도
trailing 발화가 "창 끝에 다시 처리"라 태스크가 필수. 배선 안 된
백엔드에서 `Throttle`만 되는 식의 반쪽 동작은 없음.
- 이 두 op는 **게이트 프리미티브 전용이 아님** — 나중에 타이머가 필요한
다른 것(예: 10절의 `Sample`, 백엔드 중립 테스트 하네스)이 생기면 같은
배선을 재사용. 그래서 이름도 `Debounce`에 안 묶고 일반적으로 둠.
#### `Timeout` 핸들의 타입 — **전용 `Timeout` 타입으로 확정 (2026-08-14 사용자 결정)**
**[정정]** 에이전트가 `any`를 권했다가 사용자 반론으로 뒤집힘. 뒤집힌
이유가 명확해서 그대로 기록:
- **에이전트 논거 1(선례)이 틀렸음.** `bindLifetime(inst: any, value: any)`/
`canExecute(value: any)``any`인 건 **거기엔 진짜로 아무거나 오기
때문**임(사용자가 바인드하려는 임의의 값). 반면 `setTimeout`/`clearTimeout`은
**자기가 만들어낸 것만 주고받는 닫힌 루프**라 성격이 정반대 — 같은
선례로 묶을 수 없음.
- **논거 2(타입 안전이 사줄 게 없음)도 약함.** 닫힌 루프이기 때문에 오히려
**`clearTimeout(1)` 같은 걸 타입 에러로 잡아줄 수 있음** — `any`로 두면
그 공짜 검사를 스스로 꺼버리는 셈.
- **논거 3(비용)은 사용자가 더 나은 구현으로 무력화.** `Relate`를 걸 게
아니라 **네이티브 핸들을 그냥 `Timeout` 테이블의 필드에 넣으면 됨**
(타입은 캐스트로 맞춤). 그러면 릴레이션 층이 통째로 사라지고 남는 건
테이블 1개 할당인데, 애초에 `task.delay`가 **코루틴을 하나 만드는**
호출이라 작은 테이블 하나는 그 옆에서 노이즈 수준임. 즉 핫패스 논거가
성립 안 함.
- **런타임에 마커 필드를 넣는 것도 무방**(사용자 확인) — 그러면
analyze/런타임 불일치도 없고 `isTimeout` 판별이 공짜로 따라옴.
**확정 형태** (페이로드 자리를 미리 주는 것까지 사용자 동의):
```lua
export type Timeout = {
__type_timeout: true, -- 판별 마커. 런타임에도 실제로 넣음
_native: any, -- 백엔드 전용 페이로드. base는 절대 안 읽음
}
```
- **마커 필드가 필요한 이유**는 사용자가 짚은 그대로 — Luau에서
`type Timeout = {}`는 **구조적으로 모든 테이블과 호환**이라 아무
테이블이나 통과해버림. `true` 싱글턴 타입이 이걸 사실상 nominal하게
만들어, `clearTimeout(1)`이나 엉뚱한 테이블을 타입 단계에서 잡아줌.
- **`_native`를 타입에 미리 선언**해두면 백엔드가 `:: any` 캐스트 없이
그냥 대입할 수 있고, `any` 탈출이 **필드 하나에 갇혀 경계가 문서화됨**.
호출 지점마다 캐스트를 흩뿌리는 방식은 나중에 누가 다른 필드를 더
끼워넣어도 아무도 모르는 게 문제였음 — quad가 타입 검사를 조용히 끄는
걸 싫어해온 것(`base/typing-limits.md`)과 결이 맞는 쪽으로 정리됨.
- `_` 접두사는 코퍼스의 기존 private 필드 관례(`handle._observer`,
`slot._mountedInst`, `_fired`)와 일치.
- **`_native`에 뭘 담을지는 전적으로 백엔드 자유** — coroutine 하나일
수도, 취소 플래그를 담은 테이블일 수도 있음(아래 "취소를 제공하지 않는
엔진" 절). base는 이 필드를 **읽지도 쓰지도 않고** 그저 `setTimeout`
준 값을 `clearTimeout`에 되돌려줄 뿐임.
백엔드 구현(quad-roblox):
```lua
function setTimeout(func: () -> (), delay: number): Timeout
return {
__type_timeout = true,
_native = task.delay(delay, func), -- ⚠️ 인자 순서가 뒤집힘(위 참고)
}
end
function clearTimeout(timeout: Timeout)
task.cancel(timeout._native)
end
```
**`Brand` 편입은 불필요해 보임** — `base/brand-plan.md``Brand`
사용자가 값 종류를 판별하는 용도인데, `Timeout`은 사용자 표면에 안 나오는
배관임. 마커 필드 하나로 `clearTimeout`이 자체 검사하는 걸로 충분.
#### 취소를 제공하지 않는 엔진 (사용자 제기, 답 있음)
**"엔진이 cancel을 안 주면?"에 대한 답은 "그래도 구현 가능"** — 프로바이더가
함수를 래핑하고 유효 플래그를 밖에서 뒤집으면 됨:
```lua
-- 네이티브 취소가 없는 엔진의 프로바이더 구현 스케치
function setTimeout(func: () -> (), delay: number): Timeout
local cancelled = false
engineDelay(delay, function()
if not cancelled then func() end
end)
-- _native의 내용물은 백엔드 마음 — 여기선 취소 클로저를 담음
return { __type_timeout = true, _native = function() cancelled = true end }
end
function clearTimeout(timeout: Timeout) timeout._native() end
```
**`clearTimeout`은 base가 백엔드에 요구해도 되는 계약**임(못 지킬 엔진이
없음). 다만 이 방식은 스레드/타이머가 **실제로는 예정대로 깨어나서 아무
일도 안 하는** 형태라, 8절의 "대기 타이머가 게이트를 붙잡는다"는 성질은
그대로 남음(여전히 유계라 문제는 아님).
#### `os.clock()`은 주입 대상이 아님 (2026-08-14, 사용자 정보로 정정)
초안은 "시계가 필요해지면 `now()`를 세 번째 주입 op로 추가"라고 적어뒀는데,
**그럴 필요가 없음** — 사용자 지적대로 `os.clock()`은 Luau **표준
라이브러리**이지 `task`처럼 Roblox가 얹은 전역이 아님. 게다가 Lua 5.x가
리눅스에서 "프로세스가 소비한 CPU 시간"을 주는 것과 달리 **Luau는 일관되게
고정밀 값**을 주므로, base가 백엔드와 무관하게 그냥 불러 쓸 수 있음.
> **⚠️ 단, `os.clock()`은 "현재 시각"이 아님(사용자 재지적)** — 기준점이
> 정해지지 않은 고정밀 카운터라서 **두 값의 차이(diff)를 재는 용도로만**
> 유효함. 절대 시각으로 해석하거나, 저장해뒀다가 다른 시간 개념(`os.time()`
> 등)과 비교하면 안 됨.
>
> **이 게이트 설계는 이 제약을 원래 안 건드림** — 쓰는 곳이 전부
> `maxDeadline = os.clock() + MaxTime` 잡아두고 나중에
> `maxDeadline - os.clock()`으로 **남은 시간**을 구하는, 순수한 차이
> 계산뿐임. 절대 시각이 필요한 자리가 한 군데도 없음. (부수 효과로
> 벽시계 보정(NTP·사용자 시간 변경)에 영향받지 않는다는 장점도 있음 —
> 타이머엔 오히려 이쪽이 맞음.)
**정리**: 주입이 필요한 건 "미래에 뭔가를 실행시키는 능력"(`setTimeout`/
`clearTimeout`)뿐이고, "얼마나 지났나"(`os.clock` 차이)는 언어가 이미 줌.
그래서 아래 6-1의 최적화들이 **주입 표면을 늘리지 않고** 가능해짐.
### 6-1. 구현 세부 — 취소+재스케줄 vs 지연 타이머
기본안은 신호마다 `clearTimeout` + `setTimeout`(단순, 정확). Roblox에서
이건 신호마다 스레드 하나를 만들고 버리는 것이라, 텍스트 입력(초당 ~10회)
수준에선 무시해도 되지만 **Heartbeat 같은 고빈도 소스에 많은 노드가 붙으면**
스레드 처닝이 될 수 있음.
대안: 타이머를 취소하지 않고, 콜백에서 `os.clock()`으로 "진짜 마감이 더
뒤인가"를 보고 남은 시간만큼 다시 스케줄(고전적인 lazy timer). 위 절대로
`os.clock()`은 주입 없이 그냥 쓸 수 있으므로 **인터페이스 변경이 전혀
없음** — 순수 내부 최적화라 나중에 전환해도 안전함. **지금은 단순한
쪽으로 가고, 실측에서 문제가 드러나면 그때 전환**을 권함.
#### `MaxTime`을 타이머 2개가 아니라 1개로 (사용자 제기)
사용자가 짚은 두 갈래 — (a) 버스트 시작 시각을 `os.clock()`으로 잡아두고
계산, (b) 시작할 때 "리셋되는 타이머"와 "MaxTime 타이머" 둘을 걸기 —
중 7절 의사코드는 (b)를 씀(시계 없이 닫히고 읽기 쉬워서). 다만 `os.clock()`
그냥 쓸 수 있게 된 이상 **둘을 합쳐 타이머 1개로 줄이는 게 더 나음**:
```
-- 버스트 시작 시: maxDeadline = os.clock() + MaxTime
-- 매 신호마다: 타이머 마감 = min(Time, maxDeadline - os.clock())
-- ↑ 둘 다 "남은 시간"(차이)이라 os.clock()의
-- 기준점이 무엇이든 무관 — 위 주의 참고
```
한 타이머가 "조용해짐"과 "더 못 기다림" 둘 다를 표현하므로, 어느 쪽으로
깨어나든 그냥 커밋하면 됨. `MaxTime`이 없으면 `maxDeadline`이 무한대라
`min`이 항상 `Time`이 되어 자연히 같은 코드로 흡수됨(분기 없음).
**의사코드를 (b)로 남겨둔 이유**: 두 타이머가 각각 무슨 뜻인지가 눈에
보여서 검토하기 쉬움. 실제 구현은 위 `min` 형태를 권함 — 동작은 동일하고
타이머/스레드 수가 절반.
---
## 7. 의사코드
> **주의**: `Gate` 노드의 내부 훅(`onUpstreamSignal`, `commit`)은 아직
> 이름도 계약도 확정되지 않은 가칭 — `Blocker`의 게이티드 노드가 이미
> 필요로 하는 것과 같은 훅이라(1절), 실제 구현 시엔 그쪽과 같이 정의할 것.
```lua
-- quad-base — 공용 코어. Reset 한 비트가 Debounce/Throttle을 가름(5-3절).
local function makeGate(reset: boolean, opts)
if opts.Leading == false and opts.Trailing == false then
error("Leading/Trailing 둘 다 false면 아무것도 통과하지 않음")
end
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 전용)
local openWindow, onWindowEnd
function openWindow()
window = setTimeout(onWindowEnd, opts.Time)
end
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, opts.MaxTime)
end
end
return gate
end
end
function Debounce(opts: DebounceOptions) return makeGate(true, opts) end
function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end
```
**사용자가 짚은 시나리오 검증** (`Throttle{Time = 1}`, 0.0과 0.1에 입력):
```
0.0 window == nil → leading 통과 ● openWindow → 마감 1.0
0.1 window 살아있음 → pending = true
reset == false 라 창을 안 밂 → 마감은 여전히 1.0
1.0 onWindowEnd: pending && trailing
→ pending = false, passThrough ● ← 0.1 시점의 최신값이 여기서 적용됨 ✅
→ openWindow → 마감 2.0
1.05 window 살아있음 → pending = true (초안이 여기서 이중 발화했음, 이제 막힘 ✅)
2.0 통과 ● → openWindow → 3.0
3.0 pending 없음 → 창 안 엶, idle 복귀
3.5 입력 → window == nil → leading 즉시 통과 ●
```
**(B) value-hold 의미론이 붙는 자리**: `gate``onUpstreamSignal`에서
자기 `invalid`를 세우지 않고, 오직 `passThrough()`에서만 세움 — 그래서
창이 열려 있는 동안 `gate:Get()`은 캐시된 직전 값을 반환. (A)를 택하면
`onUpstreamSignal` 진입 시 invalid만 세우고 전파를 미루는 형태로 바뀜.
---
## 8. 라이프사이클 / GC 분석
`base/lifecycle-pattern.md`의 "정리(`retract`)는 기본적으로 GC에 위임"
원칙 위에서 확인한 것들:
- **대기 중인 타이머는 게이트 노드를 강하게 붙잡음.** Roblox `task.delay`
콜백을 들고 있고, 콜백이 게이트를 업밸류로 캡처하므로. 즉 **다운스트림이
전부 죽어도 최대 `Time`(또는 `MaxTime`)초 동안은 노드와 그 상류 체인,
그리고 (B)에서는 캐시된 값까지 살아 있음.**
- **유계이고 자가 치유됨** — 누수가 아니라 "지연된 GC". 문서화 대상.
- 위험해지는 조합은 **긴 `Time` + 빠른 생성/파괴**(예: `Slot:List` 항목
하나하나가 `Time = 60`짜리 게이트를 갖는 경우). 이건 문서 경고 +
(Q4에서 다루는) 제어 핸들의 `:Cancel()`로 대응.
- **타이머 콜백이 죽은 대상을 건드릴 위험은 없음.** 콜백이 하는 건 플래그
세팅과 무효화 전파뿐이고, 실제로 무언가를 하는 소비자(store-bind
핸들러/Observer)는 이미 `canExecute` 게이트를 통과해야만 실행되므로
기존 장치가 그대로 커버함. **Dispatch 쪽 변경 필요 없음.**
- **게이트 노드는 `inst`에 안 묶인 순수 값 계층**이라 `bindLifetime`/
`unbindLifetime` 배선이 필요 없음 — `Slot`/`Effect`가 겪은 복잡도가
여기엔 없음.
- **두 `Relate` 상호 순환 위험 없음**`Relate`를 아예 안 씀(게이트의
상태는 전부 클로저 업밸류). `base/relate-plan.md`의 "위험한 패턴" 절과
무관.
---
## 9. 이름
### 9-1. ⚠️ Roblox 커뮤니티의 "debounce"와 충돌함
Roblox 생태계에서 `debounce`는 압도적으로 **재진입 방지 불리언**을
가리킴(`local debounce = false ... if debounce then return end`). 웹
쪽 의미(시간 기반 합치기)와 이름만 같고 완전히 다른 물건임. quad의 주
사용자층이 Roblox 개발자라는 걸 감안하면 이건 실질적인 혼동 위험이고,
이 코퍼스가 `Brand` 후보에서 `Tag`를 뺀 것과 같은 종류의 문제
(`.claude/question.md` 1번의 `Brand` 항목).
후보:
| 이름 | 근거 | 문제 |
|---|---|---|
| `Debounce`/`Throttle` | RxJS/lodash/VueUse 전부 이 이름, 검색성 최고 | 위 충돌 |
| `Settle` / `Settled` | "값이 가라앉을 때까지 기다린다" | 선례 없음, Promise settle과 혼동 |
| `Quiet` / `Idle` | 동작을 직관적으로 서술 | 선례 없음 |
| `Coalesce` | "합친다"는 동작 자체 | nil 병합(`Alternative`)과 어휘 충돌 |
| `RateLimit` (Throttle 자리) | 의미 명확 | 서버 레이트 리밋 뉘앙스 |
**권고**: `Debounce`/`Throttle` 유지 + **사용자 문서 첫 줄에 Roblox 관용
"debounce"와 다르다는 걸 못박기**. 업계 표준 이름을 버리면 검색·이주
비용이 더 큼. 단 이건 사용자 취향이 강하게 걸리는 영역이라 Q1로 넘김.
### 9-2. `-ed`를 안 붙이는 이유 (이건 코퍼스 규칙으로 결정됨)
`Tag.Added`/`Modifier.Overridden`/`Sorted`의 `-ed`는 "clone 후 **즉시
확정된** 값"이라는 관례고, lazy한 것엔 안 붙임(`Compute`가 `Computed`
아닌 이유 — `base/source-state-plan.md`의 "네이밍" 절). 게이트가 반환하는
건 lazy State 노드이므로 **`Debounce`/`Throttle` 원형이 맞음**, `Debounced`
아님. 이건 열린 질문 아님.
---
## 10. 인접 후보 — 지금은 범위 밖
- **`Delay{Time}`** — 합치기 없이 그냥 미룸. 게이트로 표현 가능하지만
실사용 근거가 약함.
- **`Sample{Time}`** (RxJS `sampleTime`) — 상류 변경과 무관하게 주기적으로
최신값을 통과. 이건 상류 신호가 없어도 타이머가 도는 거라 **유일하게
진짜 eager**가 되는 변형 — 넣는다면 별도 판단 필요.
- **`Audit`** (RxJS) — `Throttle{Leading = false}`와 같음, 프리셋으로 흡수됨.
---
## 11. 다른 결정과의 상호작용 (확인 완료)
- **`Dispatch`**: 변경 없음. 게이트는 순수 값 계층이고 store-bind 핸들러는
평소처럼 무효화를 받아 `:Get()`할 뿐.
- **`Tween`**: 직교. `debounced:Apply(Animate{...})`처럼 겹쳐 쓸 수 있고,
둘 다 시간을 다루지만 층이 다름(하나는 값 보간, 하나는 전파 타이밍).
- **`Blocker`**: 직교하게 겹쳐 쓸 수 있음(`state:Apply(Debounce{...}):Block(b)`).
실사용 사례는 잘 안 떠오르지만 구조적으로 막을 이유도 없음.
- **`Effect`/`Observer`**: 게이트 아래에 붙으면 자동으로 debounce된
빈도로 재실행됨 — 별도 장치 불필요. `Effect`가 deps 배열을 안 만들고
`:With`를 재사용한 것과 같은 결로, "debounce된 Effect"라는 별도 API를
만들 필요가 없다는 뜻.
- **테스트/`quad-mock`**: 주입 op 2개 덕분에 **가상 시계로 결정론적 테스트가
공짜로 됨**(스케줄 큐를 손으로 진행). 이게 6절의 quad-base 배치를 미는
또 하나의 근거 — quad-roblox에 `task.delay`를 직접 박아넣으면 base
테스트 하네스에서 이 프리미티브를 테스트할 방법이 없어짐.
- **`typing-limits.md`**: 게이트는 `State<T> -> State<T>`(타입 인자 불변)라
0-Y가 걸렸던 "자기를 다른 타입 인자로 감싸 반환"에 해당하지 않음 —
타입 쪽 추가 위험 없음. 다만 `:Apply` 결과를 받는 자리에 명시 주석
바인딩을 하라는 일반 관례는 그대로 적용.
---
## 12. 사용자 판단 대기 — 열린 질문
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가 나음.
5. ~~**패키지 경계**~~ **[2026-08-14 해소]** — 사용자가 quad-base +
엔진별 태스크 배선으로 확정. 주입 표면이 3개→5개로 늘어나는 비용은
수용됨. op 이름/시그니처도 사용자가 지정
(`setTimeout(func, delay)` / `clearTimeout(handle)`). (6절)
6. ~~**`Debounce`를 파생 State 위에 걸었을 때**~~ **[2026-08-14 소멸]** —
전제(무효화 dedup이 신호를 삼킴)가 틀린 것으로 드러나 질문 자체가
없어짐. emit은 항상 재전파되므로 게이트는 어디에 걸든 정상 동작함.
(3절)
7. **`base/blocker-plan.md` 한 줄 명확화** — 그 문서가 인용하는
"`Get()`은 라이브 레퍼런스를 준다"는 실제로는 `base/source-state-plan.md`
**레퍼런스 의미론** 원칙이지 "항상 상류 최신값"이 아님. Q2에서 (B)를
택하면 두 문서가 모순돼 보이므로 그 인용에 한 줄 주석을 넣는 게 좋음.
(확정 문서 수정이라 사용자 승인 후 반영 — 이 세션에선 안 건드림.)
8. **`Time = 0`을 허용할지** — "이번 스텝 합치기" 용도로 유용하지만
스케줄러 타이밍에 의존하는 준-`Blocker`가 됨. 허용(권장) / 금지 /
별도 이름 부여 중 선택. (1절 인용문)
9. ~~**`Timeout` 핸들의 타입**~~ **[2026-08-14 완전 해소]** — 사용자 결정으로
**`type Timeout = { __type_timeout: true, _native: any }`**(마커는
런타임에도 실제로 넣고, 백엔드 페이로드 자리도 타입에 미리 선언).
에이전트의 `any` 권고는 철회됨 — `bindLifetime``any`인 건 거기
진짜로 아무거나 오기 때문이고, `setTimeout`/`clearTimeout`은 자기가
만든 것만 주고받는 **닫힌 루프**라 성격이 정반대이며 오히려
`clearTimeout(1)`을 타입 에러로 잡아주는 이득이 있음. 비용 논거도
무력화 — `Relate` 대신 네이티브 핸들을 필드에 직접 넣으면 되고,
`task.delay`가 코루틴을 만드는 옆에서 작은 테이블 하나는 노이즈.
더 판단할 것 없음. (6절)
10. ~~**`base/source-state-plan.md`의 무효화 dedup 문장 정정**~~
**[2026-08-14 해소·반영 완료]** 사용자가 "emit은 항상 전파함,
`Blocker`나 emit 전파 지연요소만 이를 지연할 수 있음"으로 확정하고
전체 정정을 지시 — 같은 세션에 base/reference/research/ROADMAP/
스파이크/audit까지 전부 반영했고, 역전 기록은
`archive/invalidate-dedup-propagation-reversed.md`. 상세는 3절.
---
## 13. 우선순위 / 마일스톤
**M0 착수를 막지 않음** — 이 문서의 어떤 결정도 디스패치/State 코어 계약을
바꾸지 않음(11절에서 확인). 다만 `Operator` 슈가와 달리 **순수 슈가가
아니라 실제 기능 갭**이라(타이머 없이는 사용자 코드로 재현하기 번거롭고,
재현하면 laziness를 깨기 쉬움) 우선순위는 그쪽보다 위로 두는 게 맞아 보임.
**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면. 그 둘이
서면 언제든 얹을 수 있고, `Blocker` 구현과 **같은 시점에 하는 게 확실히
쌈** — 1절에서 봤듯 게이트 노드를 공유하므로 따로 하면 같은 걸 두 번
설계하게 됨. **권고: `Blocker` 구현 시점(M3)에 게이트 노드를 공용으로
빼두고, Debounce/Throttle 자체는 그 위에 나중에 얹기.**

View file

@ -28,8 +28,16 @@
- **다이아몬드 의존성 재계산 dedup — Vide가 스스로 미해결로 남긴 문제를 - **다이아몬드 의존성 재계산 dedup — Vide가 스스로 미해결로 남긴 문제를
더 구조적으로 해결.** Vide `todo.md`가 diamond 그래프 중복 재평가 방지를 더 구조적으로 해결.** Vide `todo.md`가 diamond 그래프 중복 재평가 방지를
미해결로 인정했고, 실제로 `test/tests.luau`의 "recursive queue flush 미해결로 인정했고, 실제로 `test/tests.luau`의 "recursive queue flush
diamond" 테스트가 이상적 2회 대신 3회 실행됨을 재현함. quad의 `invalid` diamond" 테스트가 이상적 2회 대신 3회 실행됨을 재현함. quad는 **애초에
플래그 dedup은 이걸 원시 레벨에서 막도록 설계됨. push 시점에 계산을 안 하기 때문에**(pull-recompute + 노드별 캐시) 이
문제가 구조적으로 발생하지 않음 — 신호가 두 경로로 두 번 도착해도
계산은 `:Get()` 때 캐시를 통해 한 번뿐.
**[2026-08-14 정정]** 원래 이 자리는 "quad의 `invalid` 플래그 dedup이
원시 레벨에서 막도록 설계됨"이라고 적혀 있었으나, 그 dedup 장치 자체가
`Observer` 계약과 모순돼 폐기됨 — 막는 주체는 플래그가 아니라 캐시임
(`archive/invalidate-dedup-propagation-reversed.md`). **quad가 더 낫다는
결론은 안 바뀌고 오히려 근거가 단순해짐**, 다만 정확히는 quad도 중복
*통지*는 접지 않음(중복 *재평가*만 안 일어남).
- **fine-grained라 vdom 특유의 버그 클래스가 통째로 없음(vs react-lua).** - **fine-grained라 vdom 특유의 버그 클래스가 통째로 없음(vs react-lua).**
react-lua는 리스트 diffing을 위해 key 관리가 필요하고(불안정하면 자식 react-lua는 리스트 diffing을 위해 key 관리가 필요하고(불안정하면 자식
상태 유실), hooks 호출 순서 규칙이 있으며(위반 시 "Rendered fewer/more 상태 유실), hooks 호출 순서 규칙이 있으며(위반 시 "Rendered fewer/more

View file

@ -236,8 +236,14 @@ introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어
Roblox `task.delay` 등)가 필요해 `factory(self) -> State<U>` 순수 함수 Roblox `task.delay` 등)가 필요해 `factory(self) -> State<U>` 순수 함수
모양을 벗어남 — `Operator.*`(quad-base, 엔진 무종속)에 넣을 수 있는 게 모양을 벗어남 — `Operator.*`(quad-base, 엔진 무종속)에 넣을 수 있는 게
아니라 quad-roblox 쪽 별도 프리미티브(Tween과 비슷한 위치)로 다뤄야 할 아니라 quad-roblox 쪽 별도 프리미티브(Tween과 비슷한 위치)로 다뤄야 할
가능성이 큼. 이 문서 범위 밖의 별도 설계 질문으로 분리해서 판단 필요 — 가능성이 큼. 이 문서 범위 밖의 별도 설계 질문으로 분리해서 판단 필요.
지금 착수 안 함, 사용자 판단 대기. **[2026-08-14, 분리 완료]** 사용자 요청으로 `research/debounce-throttle-plan.md`
신설 — 이 항목은 그 문서로 이관됐고 여기선 더 이상 다루지 않음. 그
문서의 결론 두 가지만 여기 관련 있음: (1) 붙이는 모양은 이 카탈로그와
똑같이 `factory(self)` + `:Apply`라 위 "왜 `:Apply`인가" 규칙이 그대로
적용됨, (2) 다만 배치는 위 추측과 달리 **quad-base + 주입 op 2개**로
확정(부기 알고리즘이 순수 로직이라 백엔드마다 복제할 이유가 없음 —
`Tween`이 quad-roblox인 이유와 근거가 다름).
## 열린 질문 — 컬렉션 계열 후보: `Concat`/`Sorted`/`Filtered` ## 열린 질문 — 컬렉션 계열 후보: `Concat`/`Sorted`/`Filtered`

View file

@ -0,0 +1,341 @@
# 2026-08-14 여덟 번째 세션 — Debounce/Throttle 백로그 + "emit은 항상 전파" 정정
> **[읽기 전 주의 — 경로 표기]** 이 세션은 **워크트리에서** 진행됐고, 그
> 워크트리는 `10cd31b` 기준이라 **`bind/store/state` 3단계 분할(같은 날
> 일곱 번째 세션) 이전**이었음. 그래서 아래 서술은 전파 모델이
> `bind-system-plan.md`에 있다고 말하지만, **메인에 옮겨진 지금은
> `base/source-state-plan.md`의 "전파 모델 확정" 절**임. 원문 보존
> 원칙에 따라 본문은 당시 표기 그대로 두고 여기 한 줄로만 짚어둠.
**요청**: "`Blocker`와 유사하게 Debounce/Throttle를 만들어야 하는데, 이에
대한 백로그를 짜줘. 너가 일단 다 정의해보고 내가 그 정의를 보고 판단해볼게.
워크트리 하나 파서 작업해."
**에이전트가 먼저 전부 정의하고 사용자가 판정하는** 모드 — 확정 문서를
만드는 게 아니라 판단 재료를 만드는 세션. 산출물은
`research/debounce-throttle-plan.md` 하나 + 인덱스 레이어 반영.
## 워크트리 관련 시행착오 (기록)
`EnterWorktree`가 기본 설정(`worktree.baseRef = fresh`)대로 `origin/master`
에서 브랜치를 땄는데, 이 레포의 `origin/master`는 **quad v1 시절 원격의 옛
히스토리**라 `.claude/`가 통째로 없는 상태로 시작됐음(`4824bab Merge pull
request #7 ...`). 로컬 `main`(`10cd31b`)으로 `git reset --hard`해서
바로잡음. 다음에 워크트리를 팔 때도 같은 일이 생길 것 — **이 레포에서
워크트리를 만들면 항상 로컬 `main` 기준인지 먼저 확인할 것.**
## 읽은 것
`base/blocker-plan.md`(게이티드 State의 정확한 계약), `base/effect-plan.md`,
`base/lifecycle-pattern.md`(GC 위임 원칙/`bindLifetime`), `base/
module-lifecycle-plan.md`(백엔드 주입 경로), `base/purity-and-effects-plan.md`,
`base/bind-system-plan.md`의 온톨로지/전파 모델 절, `base/tween-plan.md`
헤딩(엔진 종속 프리미티브의 선례), `research/operator-sugar-plan.md`(이
항목이 원래 매달려 있던 자리), `research/additional-primitives-plan.md`.
## 실제로 새로 알아낸 것 세 가지
### 1. `Blocker`와 같은 자리, 다른 트리거
`blocker-plan.md`의 게이티드 노드 계약(블록 중이면 전파 안 하고
`HasBlockedEmit`만 세팅, 열릴 때 정확히 1회 전파)이 debounce/throttle이
필요로 하는 것과 **글자 그대로 같음**. 차이는 여는 주체뿐(사용자 `:Off()`
vs 타이머). 그래서 새 전파 메커니즘이 아니라 **릴리스 트리거 교체**로
정리했고, 구현 권고도 "`Blocker` 구현 시점에 게이트 노드를 공용으로
빼두라"가 됨.
부수적으로 확인한 것: **공개 `Blocker` API 위에 얹어서는 못 만듦**
"상류 신호가 지금 도착했다"는 통지가 공개 API에 없어서 타이머를
(재)시작할 시점을 알 방법이 없음. 그래서 내부 훅이 필요하고, 그 훅은
`Blocker`가 이미 갖고 있는 것과 같은 것.
### 2. ⭐ 무효화 dedup 때문에 파생 State 위에선 debounce가 throttle로 퇴화
이게 이번 세션의 진짜 발견. `bind-system-plan.md`의 확정 규칙 —
> 신호를 받은 State는 자기 `invalid` 플래그만 세우고, 이미 `invalid`였다면
> 그 아래로 더 전파하지 않는다
— 때문에, 게이트가 **파생 State 위**에 얹히면 버스트 중 두 번째 이후
신호를 아예 못 받음(중간 노드가 이미 invalid라 삼킴). 결과적으로 "마지막
변경 후 T초"가 아니라 "첫 변경 후 T초"가 되어 **주기 T의 throttle처럼**
동작함. 값 자체는 커밋 시점에 pull하므로 항상 최신 — 정확성 버그가
아니라 **타이밍 의미론이 조용히 바뀌는** 종류라 오히려 더 안 보임.
`Source` 바로 위에 얹으면 문제없음(Source는 `invalid` 개념이 없어 매
`:Set()`마다 신호). 그래서 규칙이 나옴 — **Debounce/Throttle은 Source에
가깝게, `Blocker`는 파이프라인 끝에.** 정확한 거울상이라 문서화하기 좋음.
"게이트가 신호 받을 때마다 상류를 `:Get()`해서 체인을 되살린다"는 우회는
laziness를 정면으로 깨고 debounce의 존재 이유와 반대라 기각 권고로 적음.
### 3. 배치 근거가 `operator-sugar-plan.md`의 추측과 다름
그 문서는 "타이머가 필요하니 `Tween`처럼 quad-roblox"로 추정해뒀는데,
`Tween`이 quad-roblox인 진짜 이유는 TweenService라는 **엔진 기계**(보간
엔진/easing/per-instance 객체)에 의존해서고, debounce가 엔진에서 필요로
하는 건 **시계 하나**뿐임. 14차 세션이 `Tag`/`Attribute`에서 내린 판단
(부기 알고리즘은 quad-base, 엔진에 손대는 한 줄만 주입)과 정확히 같은
상황이라 **quad-base + 주입 op 2개**를 권고로 뒤집음.
주입 op는 (당시 가칭) `scheduleAfter(seconds, fn) -> handle` /
`cancelScheduled(handle)` 둘 — **2차 리뷰에서 사용자가
`setTimeout(func, delay)` / `clearTimeout(handle)`로 지정, 인자 순서도
함수 먼저로 확정**. **`now()`는 일부러 뺐음**(이 판단도 2차 리뷰에서
정정됨, 아래 참고) — Debounce/Throttle/
MaxTime 전부 "창이 끝날 때 콜백"으로 표현돼서 시계 없이 닫힘. 부수 효과로
`quad-mock`에서 가상 시계 결정론적 테스트가 공짜(이것 자체가 base 배치를
미는 또 하나의 근거).
## 그 밖에 정리한 것
- **의미론 두 갈래**: (A) emit-gate(`Blocker`와 동일, `:Get()`은 최신값)
vs (B) value-hold(`:Get()`도 지연된 값). (B) 권장 — VueUse/RxJS 의미론과
일치하고 `Blocker`와 역할이 깔끔히 갈림. 확인 과정에서 **`blocker-plan.md`
인용하는 "`Get()`은 라이브 레퍼런스를 준다"가 실제로는 `store-semantics.md`
*레퍼런스 의미론*(테이블을 복사본 아닌 라이브 참조로 준다) 원칙이지
"항상 상류 최신값"이 아니라는 걸 확인** — 그래서 (B)가 확정 원칙과
충돌하지 않음. 다만 그 한 줄이 오해될 여지가 있어 열린 질문 Q7로 남김
(확정 문서라 임의 수정 안 함).
- **`Throttle``Debounce` 프리셋으로** 권고 —
`Throttle{Time=t} == Debounce{Time=t, Leading=true, Trailing=true, MaxTime=t}`
가 lodash의 실제 구현 관계 그대로. quad가 반복해온 "같은 일 하는 두 번째
경로 안 만들기"와 맞음.
- **이름 위험**: Roblox 커뮤니티의 `debounce`는 재진입 방지 불리언이라
정면 충돌. 그래도 업계 표준 이름 유지 + 문서 경고를 권고(검색/이주
비용). `-ed`를 안 붙이는 건 코퍼스 규칙으로 이미 결정됨(lazy한 건 원형,
`Compute``Computed`가 아닌 것과 같은 이유) — 열린 질문 아님.
- **GC**: 대기 중 타이머가 게이트를 강참조하므로 다운스트림이 다 죽어도
최대 `Time`초 생존 — 유계·자가치유라 누수 아님. 게이트는 `inst`에 안
묶인 순수 값 계층이라 `bindLifetime` 배선 불필요, `Relate`도 안 써서
두-`Relate` 상호 순환 위험과 무관. **Dispatch 변경 없음.**
- 열린 질문 8개를 문서 12번 절에 번호로 모으고, 그중 사용자 취향이 실제로
갈리는 넷(의미론/제어핸들/이름/파생 State 적용)만 `question.md` 3번에
요약.
## 1차 리뷰 라운드 (같은 세션, 사용자가 초안 읽고 지적)
사용자 지적: "스로틀은 '나중에 처리해준다'가 필요한 부분이다. 1초 스로틀에
0.0과 0.1에 누르면 1.0에 0.1 때의 값이 다시 적용돼야 한다. 즉 setTimeout이든
뭐든 태스크가 필요하다. 로블록스는 `task.wait`/`delay`가 있지만 다른 엔진은
다를 수 있고, **`task` 자체가 그냥 Luau에는 없다.** 기본 구현은 quad-base에
있고 엔진 따라 해당 태스크 부분만 배선하면 되도록 만들어져야 한다 생각함."
+ "디바운스가 정확히 보통 어떤 동작인지 알려달라."
세 가지가 바뀜:
1. **Q5(패키지 경계) 해소** — 사용자가 quad-base + 엔진별 배선으로 확정.
추가로 얻은 근거: 순수 Luau엔 `task`가 없을 뿐 아니라 이벤트 루프
자체가 없어서 **base가 "동작하는 기본 스케줄러"를 제공할 수가 없음**
엔진 op 3개와 같은 관례대로 미배선 시 명확한 에러를 내는 스텁이어야
함. 그리고 사용자가 짚은 대로 `Throttle`도 trailing 때문에 이 배선이
똑같이 필요함(스로틀만 타이머 없이 되는 게 아님).
2. ⭐ **초안 의사코드의 실제 버그 발견·수정.** 사용자의 0.0/0.1 시나리오를
트레이싱하다가, 초안이 lodash식 `MaxTime`(maxWait) 공식을 그대로 옮긴
탓에 **trailing 통과 직후 타이머를 전부 회수해버려 바로 뒤 신호가
"창 밖"으로 판정돼 또 즉시 발화**하는 걸 발견(1초 안에 두 번). 고치면서
훨씬 나은 정식화가 나옴 — **디바운스와 스로틀의 차이는 "신호가 창
타이머를 리셋하는가" 한 비트뿐**이고, leading/trailing/통과 후 창
재개방은 완전히 동일함. `maxWait` 트릭 없이 스로틀이 정확히 나오고
이중 발화 구멍도 구조적으로 사라짐. 그래서 구 Q3("Throttle은 Debounce의
프리셋")을 폐기하고 **공개 생성자 2개 + 내부 구현 1개(`Reset` 파라미터,
비공개)**로 개정. `MaxTime`은 디바운스 전용 안전장치로 역할 축소
("신호가 안 끊기면 영원히 발화 안 함"이 디바운스의 정의라서 필요한
것이고, 스로틀은 원래 주기 발화라 무의미).
3. **3절 발견의 범위 축소.** 무효화 dedup 퇴화는 **`Debounce`만** 겪음 —
스로틀은 창 안에서 "뭔가 바뀌었나" 불리언 하나만 알면 되고, leading
통과가 소비자의 `:Get()`을 유발해 체인을 되살리므로 사실상 면역.
초안이 둘 다 영향받는 것처럼 써놨던 걸 정정. Q6의 적용 범위도 같이 축소.
디바운스 동작 설명 요청에 대한 답은 문서 1-1절로 들어감(정의 + 타임라인
+ "버스트 중간/끝/무한 연속" 3열 비교표). 핵심은 **신호가 끊이지 않으면
디바운스는 영원히 발화하지 않는다**는 것이 버그가 아니라 정의 그 자체라는
점 — lodash `maxWait`의 존재 이유이기도 함.
## 2차 리뷰 라운드 (같은 세션) — 주입 op 시그니처 확정
사용자가 구현 방식까지 구체적으로 지정: `os.clock()`으로 버스트 시작
시각을 잡아 maxWait을 처리하거나, 아니면 처음에 maxWait 타이머와
리셋 가능한 타이머 둘을 걸고 변경마다 후자를 재시작하는 식. 그리고
**"set/clear timeout 둘 다 quad-base에서 프로바이더가 구현해야 할
사항으로 넣자"** — `setTimeout(func, delay) -> Timeout` /
`clearTimeout(Timeout)`. Roblox는 `task.delay(duration, fn, ...)`/
`task.cancel(thread)`로 배선하고, quad-roblox가 간단한 릴레이션으로
`Timeout -> coroutine`를 얻어내면 됨.
반영하면서 정리된 것 넷:
1. **`os.clock()`은 주입 대상이 아님 — 초안 판단 정정.** 초안은
"시계가 필요해지면 `now()`를 세 번째 주입 op로 추가"라고 적어뒀는데,
사용자가 알려준 사실로 무효가 됨 — `os.clock()`은 **Luau 표준
라이브러리**이지 `task`처럼 Roblox가 얹은 전역이 아니고, Lua 5.x가
리눅스에서 "프로세스가 소비한 CPU 시간"을 주는 것과 달리 **Luau는
일관되게 고정밀 값**을 줌. 그래서 결론이 깔끔해짐: **주입이 필요한
건 "미래에 실행시키는 능력"뿐, "얼마나 지났나"는 언어가 이미 준다.**
덕분에 6-1절의 최적화들(lazy timer, `MaxTime` 단일 타이머화)이 주입
표면을 안 늘리고 가능해짐.
**[같은 라운드 재지적]** 단 `os.clock()`**"현재 시각"이 아니라
기준점 없는 카운터라 diff 전용** — 절대 시각으로 해석하거나 다른 시간
개념과 비교하면 안 됨. 이 설계는 원래 `maxDeadline - os.clock()` 같은
**남은 시간 계산**만 하므로 제약을 안 건드리고, 부수적으로 벽시계
보정(NTP 등)에 영향받지 않는다는 장점까지 있음. 문서 6절에 경고
박스로 명시.
2. **`MaxTime`을 타이머 1개로.** 사용자가 제시한 두 갈래 중 의사코드는
(b)(타이머 둘)를 쓰지만, `os.clock()`을 그냥 쓸 수 있으니 마감을
`min(Time, maxDeadline - os.clock())`로 잡으면 **한 타이머가 "조용해짐"과
"더 못 기다림" 둘 다를 표현**함. `MaxTime`이 없으면 `maxDeadline`
무한대라 `min`이 항상 `Time`이 되어 분기 없이 흡수됨. 의사코드는
읽기 쉬운 (b)로 남기고 이 최적화는 구현 권고로 적어둠.
3. **`Timeout` 타입 — 에이전트가 `any`를 권했다가 사용자 반론으로 뒤집힘.**
에이전트 근거 셋은 (a) `bindLifetime(inst: any, value: any)` 선례,
(b) base 내부 배관이라 타입 안전이 사줄 게 없음, (c) 전용 타입이면
Roblox가 타이머마다 테이블 + `Relate` 엔트리를 강제당함(디바운스는
신호마다 거는 핫패스)였는데, 사용자가 셋 다 반박:
- **(a)가 핵심 오류** — `bindLifetime``any`인 건 **거기 진짜로
아무거나 오기 때문**이고, `setTimeout`/`clearTimeout`은 **자기가
만들어낸 것만 주고받는 닫힌 루프**라 성격이 정반대. 같은 선례로
묶을 수 없음.
- **(b)도 뒤집힘** — 닫힌 루프이기 때문에 오히려 `clearTimeout(1)`
**타입 에러로 잡아줄 수 있음**. `any`는 그 공짜 검사를 스스로 끄는 것.
- **(c)는 더 나은 구현으로 무력화** — `Relate`를 걸 게 아니라
**네이티브 핸들을 `Timeout` 테이블 필드에 직접 넣으면 됨**(타입은
캐스트로 맞춤). 릴레이션 층이 사라지고 남는 건 테이블 1개 할당인데,
`task.delay`**코루틴을 하나 만드는** 호출이라 그 옆에서 노이즈
수준 — 핫패스 논거가 성립 안 함.
- 런타임에 마커 필드를 넣는 것도 무방하다고 확인해줌.
**확정**(후속 한 왕복으로 페이로드 자리까지 합의):
```lua
export type Timeout = {
__type_timeout: true, -- 판별 마커. 런타임에도 실제로 넣음
_native: any, -- 백엔드 전용 페이로드. base는 절대 안 읽음
}
```
`_native`를 타입에 미리 선언해두는 쪽을 에이전트가 제안하고 사용자가
동의 — 백엔드가 `:: any` 캐스트 없이 그냥 대입할 수 있고 `any` 탈출이
필드 하나에 갇혀 경계가 문서화됨(캐스트 방식은 탈출구가 호출 지점마다
흩어져서, 나중에 누가 다른 필드를 더 끼워넣어도 아무도 모르는 게
문제였음 — `typing-limits.md`가 싫어하는 "조용히 타입 검사 끄기"). `_`
접두사는 `handle._observer`/`slot._mountedInst`/`_fired` 관례와 일치.
부수 효과로 "취소 없는 엔진" 스케치도 깔끔해짐 — 그 백엔드는 `_native`
coroutine 대신 취소 클로저를 담으면 되고, base는 어느 쪽이든 모름.
`Brand` 편입은 불필요 — `Brand`는 사용자가 값 종류를 판별하는 용도인데
`Timeout`은 사용자 표면에 안 나옴.
4. **취소 없는 엔진 대응이 확인됨.** 사용자가 제시한 래핑+유효 플래그
트릭으로 어떤 엔진에서도 `clearTimeout`을 구현할 수 있으므로,
**`clearTimeout`은 base가 백엔드에 요구해도 되는 계약**. 단 이 방식은
타이머가 예정대로 깨어나 아무 일도 안 하는 형태라 8절의 "대기 타이머가
게이트를 붙잡는다"는 성질은 그대로 남음(여전히 유계).
**`setTimeout`/`clearTimeout`이라는 이름을 고른 근거(사용자 명시)**:
`task`는 표준도 아니고 Luau의 것도 아닌 **한 엔진의 것**이라, base처럼
"누가 실제로 그려주는지 모르는" 층에 그 어휘를 새기면 특정 백엔드만
특별대우하는 셈이 됨 — 그래서 가장 대중적이고 엔진 중립적인 JS 어휘를
가져옴. (14차 세션이 엔진 op를 `addTag`/`setAttribute`로 정할 때 Roblox
`CollectionService`와 웹 `className`/`data-*` 양쪽에 걸치는 이름을 고른
것과 같은 결.) 에이전트가 처음엔 이걸 단순 "함정"으로만 적었다가,
사용자가 "내부 배선에서 틀리면 안 되는 건 맞지만 이유는 있었다"고
짚어줘 근거를 같이 기록.
부수적으로 기록한 구현 함정 둘: **`task.delay`는 시간이 먼저**라
`setTimeout(func, delay)`로 배선할 때 인자가 뒤집힘(중립 이름을 택한
대가이고, 배선 지점이 백엔드당 한 곳뿐이라 감당 가능), 그리고
`task.delay``...`(추가 인자 전달)은 **일부러 안 받음** — 게이트
콜백은 게이트당 하나씩 만들어져 재사용되는 안정된 클로저라 varargs로
아낄 할당이 애초에 없음.
## 3차 리뷰 라운드 (같은 세션) — 3절 전제 붕괴, base 모순 발견
사용자 지적: **"emit은 항상 재전파되고. 정확히 저 동작은 Blocker가
하는거야. 아니면 너가 잘못 읽었을지도."** — 이 문서 3절("가장 중요한
발견")이 인용하던 `bind-system-plan.md`의 무효화 dedup 문장에 대해.
원문 확인 결과 **인용 자체는 정확**했음(분할 전 `bind-system-plan.md` 708-710행(현 `source-state-plan.md`),
verbatim). 즉 잘못 읽은 게 아니라 **그 base 문장 자체가 문제**. 확인한
근거 셋:
1. **다이아몬드 근거가 요구하는 범위를 넘어섬** — 다이아몬드는 *한 번의
변경*이 여러 경로로 같은 노드에 닿는 것이라 **전파 파동 안에서만**
접으면 되는데, 문장은 `invalid`를 시간에 걸쳐 유지되는 상태로 써서
("이미 `invalid`였다면") 이후의 모든 변경까지 삼킴.
2. **`architecture.md`와 어긋남** — 그쪽은 같은 문제를 "pull-recompute
(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드 의존성 중복
재계산 문제가 풀림"이라고 서술. 즉 중복 *평가*를 막는 주체는
pull-recompute 자체고, `invalid` dedup이 아낄 수 있는 건 순회 비용뿐.
base 안에서 같은 문제의 해결 주체를 두 문서가 다르게 지목 중이었음.
3. **⭐ 확정된 `Observer` 계약과 정면 충돌** — 같은 파일이 `fn`에서
`:Get()`을 안 부르는 걸 명시적으로 허용해뒀는데("`Get()` 호출 여부를
작성자가 직접 결정하게 열어둔 것"), dedup 문장을 액면대로 적용하면
그런 Observer는 **한 번 울고 영구 침묵**함. 취향이 아니라 실제 모순.
사용자의 "저건 Blocker가 하는 것"도 정확 — `HasBlockedEmit`이 바로 그
동작이고 `Blocker`는 그걸 **opt-in 게이트**로 제공함. 모든 State에
암묵적으로 심으면 `Blocker`의 존재 의의가 절반 사라짐.
**이 문서에 미친 영향(큼)**: 3절 전체 철회. 파생 State 위 퇴화 없음,
"`Source`에 가깝게 걸어라" 규칙 폐기(그 "Blocker의 정확한 거울상"이라는
예쁜 대칭도 틀린 전제 위였음), Q6 소멸, 2차 리뷰의 "Throttle은 면역"
범위 축소도 무의미. **두 라운드를 버틴 "가장 중요한 발견"이 세 번째
라운드에 통째로 무너진 사례**로 기록해둠 — 확정 문서를 인용할 때
"그 문장이 다른 확정 문장과 모순되지 않는가"까지 확인하지 않으면
발견이 통째로 헛돈다는 교훈.
**대신 생긴 것**: base 정정 항목 `question.md` **0-E** 신설(당시엔 확정
문서라 안 고치고 승인 대기) — 아래 4차 라운드에서 사용자 확정으로
**같은 세션에 해소·전면 반영됨**.
## 4차 리뷰 라운드 (같은 세션) — 사용자 확정 후 코퍼스 전면 정정
사용자가 세 항목에 각각 답하며 모델을 확정:
> 2(재계산 방지)는 맞음 — 한 곳에서 `Get`이 되면, `invalid`하다면 위로
> 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음 `invalid`
> 꺼짐. 1: **emit은 항상 전파함. `Blocker`나 emit 전파 지연요소만 이를
> 지연할 수 있음.** 3: 맞음, 내가 원래 emit은 항상 전파라 했는데 어떤
> 엉뚱한 에이전트가 이상한짓 하고 간듯. **다 다시 써야 해.**
즉 에이전트가 3차 라운드에서 제시한 두 갈래 중 (a)에 가깝되, "파동 단위
dedup"은 **지금 넣지 않고** 순수 구현 최적화로만 남기는 쪽. 확정 모델:
- `invalid` = **"내 캐시가 낡았다"는 표시 하나뿐**, 전파 제어 장치 아님.
- **emit은 자기 `invalid` 상태와 무관하게 항상 전파.**
- 중복 **재계산**은 pull-recompute + 노드별 캐시가 막음.
- 중복 **통지**는 안 접음 — 다이아몬드에서 아래쪽 Observer가 한 사이클에
두 번 우는 건 의도된 동작. 접으려면 `Blocker` 같은 **명시적 게이트**.
### 고친 곳 (코퍼스 전체)
| 파일 | 무엇을 |
|---|---|
| `base/source-state-plan.md` | 전파 규칙 재작성, **"다이아몬드 의존성은 무엇이 푸는가" 절 신설**(캐시가 주체임을 3단계 트레이싱으로), `Observer` 절에 "이 허용은 항상-전파에 의존한다"는 상호 참조 추가, 플래튼 기각 근거와 `:With` 빌더 기각 근거 2번을 **캐시 공유**로 재작성(두 결론 모두 유지, 근거 강도는 오히려 상승 — 예전엔 순회 비용이었지만 지금은 실제 중복 계산) |
| `base/architecture.md` | 원래도 맞는 서술("pull-recompute가 다이아몬드를 푼다")이었으나 주체가 캐시임을 명시 보강 + 역전 포인터 |
| `base/blocker-plan.md` | **"emit 전파를 지연시킬 수 있는 유일한 요소"**로 위치 명문화 — 옛 서술이 사실상 Blocker의 일을 모든 State에 암묵적으로 심어 존재 의의를 반쯤 지웠다는 점까지 |
| `reference/comparison-fusion-vide.md` | Vide 대비 "전파를 끊어서"가 아니라 "애초에 push 시점에 계산을 안 해서"로 |
| `research/framework-comparison-findings.md` | 같은 정정. quad가 더 낫다는 결론은 유지, 다만 중복 *통지*는 안 접음을 명시 |
| `ROADMAP.md` | M0 체크리스트의 "이미 invalid면 전파 중단되는지"를 **정반대**로 교체 + `:Get()` 안 하는 Observer 확인 항목 추가 |
| `luau-test` `05-store-state-diamond-propagation.luau` | **옛 모델을 통과 상태로 검증 중이었음**`rewrite-required/`. `STATUS.md` 개수(done 14→13, rewrite 5→6)/`README.md`도 동기화 |
| `audit/luau-test-first-run-2026-08-13.md` | `05` 행 정정 + **"런타임 12개 전원 통과"를 액면대로 읽지 말라**는 경고 추가(검증 대상이 바뀐 게 이제 셋이라 현행 설계 기준으론 9개) |
| `archive/invalidate-dedup-propagation-reversed.md` | **신설** — 원문·역전 근거 셋·영향 범위 표·교훈 |
| `question.md``archive/question-resolved.md` | 0-E 해소 이전(신설된 날 해소) |
**교훈으로 남긴 것**: `doc-check.py`는 참조가 *존재하는지*는 보지만
*서로 모순되는지*는 못 봄. 이 건은 인용이 verbatim으로 정확했는데
**인용된 문장 자체가 틀린** 경우였고, 그 위에 두 라운드에 걸쳐 설계가
쌓였다가 통째로 무너졌음.
## 반영한 인덱스 레이어
`README.md` research 표 새 행, `question.md` 3번, `ROADMAP.md` 백로그
(+"M3에서 `Blocker` 구현할 때 게이트를 공용으로 빼둘 것" 지시),
`operator-sugar-plan.md`(이관 완료 표시 + 배치 추측 정정),
`additional-primitives-plan.md`(2026-08-06의 "빈 자리 아님" 판정이 뒤집힘
표시). `doc-check.py` — 이번 변경으로 새로 생긴 ERROR/WARN 0건(워크트리엔
`initreq/`가 gitignore로 없어서 그 참조 1건이 ERROR로 뜨지만 워크트리
아티팩트, 본 레포에선 정상).

View file

@ -255,6 +255,14 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
(`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`/`lifecycle-hooks-plan.md`). `operator-sugar-plan.md`/`lifecycle-hooks-plan.md`).
**[2026-08-14 추가, 성격이 다름]** 시간 기반 전파 게이트
`Debounce`/`Throttle`(`research/debounce-throttle-plan.md`)도 백로그이긴
하나 위 항목들과 달리 **사용자가 직접 요청한 실제 기능 갭**이고 순수
슈가가 아님 — M0/M3를 막지는 않지만, **M3에서 `Blocker`를 구현할 때
게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 해야 함**(따로
하면 같은 설계를 두 번 함). 주입 op 2개(`setTimeout`/`clearTimeout`)가
백엔드 팩토리 표면에 추가될 예정이라는 것도 M1 설계 시 인지. 설계는
네 라운드로 대부분 확정됐고 남은 열린 질문 4개는 `question.md` 3번.
5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중
(`HUMAN_TODO.md` 2번 항목). (`HUMAN_TODO.md` 2번 항목).
@ -1266,3 +1274,38 @@ PreRef pre-pass 한 스윕에서 `isPostRef`도 같이 소진해 `postRefList`
`OURS` 패턴이 `-plan`류 접미사 기준이라 store-semantics.md 같은 이름은 `OURS` 패턴이 `-plan`류 접미사 기준이라 store-semantics.md 같은 이름은
삭제해도 ERROR가 아니라 WARN으로만 잡힘, 그래서 ERROR 목록만 믿지 말고 삭제해도 ERROR가 아니라 WARN으로만 잡힘, 그래서 ERROR 목록만 믿지 말고
grep 전수를 같이 돌려야 함. 최종 ERROR 0 / WARN 85(작업 전 101). grep 전수를 같이 돌려야 함. 최종 ERROR 0 / WARN 85(작업 전 101).
**2026-08-14 여덟 번째 세션 — Debounce/Throttle 백로그 신설 + "emit은 항상
전파" 정정(base 역전)**
(`session/2026-08-14-08-debounce-throttle-backlog.md`)
사용자 요청("`Blocker`와 유사하게 만들어야 함, 너가 먼저 다 정의해봐라")으로
**워크트리**에서 `research/debounce-throttle-plan.md`를 만들고, 네 번의 리뷰
왕복으로 다듬은 뒤 메인에 필요한 변경만 이식. **확정된 것**: (1)
Debounce/Throttle은 `Blocker`가 이미 쓰는 게이티드 노드의 **릴리스 트리거만
타이머로 바꾼 것** — 새 전파 메커니즘이 아니고, 공개 `Blocker` API엔 "상류
신호 도착" 통지가 없어 그 위엔 못 얹으므로 **M3에서 게이트를 공용으로 뺄 것**,
(2) **두 도구의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐**(공개
생성자 2개 + 내부 구현 1개) — 초안이 옮겨온 lodash식 `maxWait` 공식엔 trailing
통과 직후 **이중 발화** 버그가 있었고 이 정식화로 구조적으로 사라짐,
(3) 알고리즘은 **quad-base + 주입 op 2개**(`setTimeout(func, delay) -> Timeout`
/ `clearTimeout`, Roblox는 `task.delay`/`task.cancel` — **인자 순서 반대 주의**;
`task`가 표준도 Luau의 것도 아닌 한 엔진의 것이라 엔진 중립 JS 어휘를 택함),
`os.clock()`은 Luau 표준 라이브러리라 주입 대상 아님(단 **절대 시각이 아니라
diff 전용**), 취소 없는 엔진도 래핑+유효 플래그로 대응 가능,
`Timeout = { __type_timeout: true, _native: any }`.
**⭐ 가장 큰 수확은 부수 발견** — 사용자가 **"emit은 항상 재전파된다"**고
지적해, `source-state-plan.md`의 무효화 dedup 서술("이미 invalid였다면 그
아래로 더 전파하지 않는다", 다이아몬드 중복 워크 방지)이 **확정된 `Observer` 계약(`fn`이 `:Get()`
안 불러도 됨)과 정면 충돌**함이 드러남 — 액면대로면 `:Get()` 안 하는 Observer는
**한 번 울고 영구 침묵**. `architecture.md`가 같은 문제를 pull-recompute로
설명하는 것과도 어긋나 있었음. 정정 모델: `invalid`는 **캐시 낡음 표시**일
뿐이고 emit은 자기 상태와 무관하게 **항상 전파**, 중복 **재계산**은
pull-recompute+캐시가 막고 중복 **통지**는 안 접음(접으려면 `Blocker` 같은
명시적 게이트). base/reference/research/ROADMAP/스파이크/audit 전면 정정,
`05-store-state-diamond-propagation.luau`는 옛 모델을 통과 상태로 검증
중이었어서 `rewrite-required/`로 이동. 역전 기록은
`archive/invalidate-dedup-propagation-reversed.md`. **교훈** — 이 오류 위에
그 문서의 "가장 중요한 발견"(파생 State 위 debounce 퇴화)이 두 라운드나
쌓였다가 통째로 철회됨. **확정 문서의 한 문장을 근거로 새 설계를 세울 땐,
그 문장이 *같은 문서의 다른 확정 문장*과 모순되지 않는지까지 확인할 것** —
`doc-check.py`는 참조 존재는 봐도 서술 간 모순은 못 봄.

View file

@ -30,7 +30,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
실패가 아니라 이 단계의 목적. 실패가 아니라 이 단계의 목적.
- [ ] Store/State push-invalidate → pull-recompute propagation을 실제로 - [ ] Store/State push-invalidate → pull-recompute propagation을 실제로
짜보기(다이아몬드 의존성 케이스 포함 — 이미 invalid면 전파 중단되는지) 짜보기(다이아몬드 의존성 케이스 포함 — **[2026-08-14 정정]** 확인할
것은 "이미 invalid면 전파 중단되는지"가 **아니라** 그 반대:
**emit은 자기 invalid 상태와 무관하게 항상 전파되고**, 중복 재계산은
`:Get()` 시점 캐시로만 막히는지. 특히 `:Get()`을 안 부르는
`Observer`가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터
침묵했음(`archive/invalidate-dedup-propagation-reversed.md`).
스파이크 `05-store-state-diamond-propagation.luau`는 옛 모델을
검증 중이라 `rewrite-required/`에 있음)
- [ ] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self: - [ ] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self:
Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이 Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이
Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션, Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션,
@ -750,3 +757,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`
(`research/debounce-throttle-plan.md`) — **M3에서 `Blocker`를 구현할
때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할 것**
(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함).
프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음.
주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`,
Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라
주의**)가 `bindLifetime`/`canExecute`와 같은 base 범용 유틸 그룹에
추가될 예정이라는 것만 M1 설계 시 인지. `os.clock()`은 Luau 표준
라이브러리라 주입 대상 아님(단 절대 시각이 아니라 diff 전용)