feat(m2): 단위 4 — GateNode(state:Gate) / Blocker + Apply 파라미터 타입(교집합 오버로드, H-179) + spec 2개; M2 체크박스 전부 [x]

- State.luau: GateImpl(Impl 상속) — _receive(emit 맵 Peek, 규칙 3 삼킴, unfold 합류, 정책 호출),
  _flush(빈 배치 false → weak 스왑 → Sync(batch) → _emitDown(batch); emit(false) 버리기),
  Impl.Gate(setup 검증, newNode(..., GateImpl)로 StateBrand·시딩·_hold 공유, 반환 검증).
- Blocker.luau(잎): On/Off/OffWithoutEmit(스냅샷 순회)/IsOn/Policy(weak-key 핸들, 강한 주인은
  onUpstreamEmit 클로저)/__apply(메소드형 → state:Gate). init.luau·quad-types에 Blocker.
- quad-types: State.Apply를 교집합 오버로드로(H-179 — 유니온은 필드 있는 객체를 못 받음,
  스파이크 luau-test/done/26-*), GateEmit/GateSetup/Blocker 타입, State.Gate.
- spec.gate 9절·spec.blocker 7절 ALL PASS, analyze 0. 스파이크 05 → done/(spec.state/effect 3번이 대체).
- 문서: :Block 잔재 정정(H-180), typing-limits §1②·ROADMAP·round11·STATUS/README·세션·요약.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey-agent-selene 2026-08-29 02:04:58 +09:00
parent a8731d7a1a
commit c932206ebf
No known key found for this signature in database
19 changed files with 711 additions and 22 deletions

View file

@ -169,7 +169,7 @@ gated state의 동작:
## 사용 예시 ## 사용 예시
`state1`/`state2` 각각이 아니라 **결합된 결과(`state3`) 하나에만** `:Block`을 `state1`/`state2` 각각이 아니라 **결합된 결과(`state3`) 하나에만** `:Apply(blocker)`(**[2026-08-29]** 옛 `:Block`)를
건다: 건다:
```lua ```lua

View file

@ -481,7 +481,7 @@ gc되긴 하지만.)"*
| 하류 State → 상류 State/Source (`_hold`) | **강함** | | 하류 State → 상류 State/Source (`_hold`) | **강함** |
| 상류 → 하류 (구독자 집합) | weak-키 | | 상류 → 하류 (구독자 집합) | weak-키 |
- **모든 파생 노드**(`:With`/`:Compute`/`:Gate`/`:Block`)가 자기 상류를 - **모든 파생 노드**(`:With`/`:Compute`/`:Gate``state:Apply(blocker)``:Gate`다, **[2026-08-29]** 옛 `:Block` 표기 정리)가 자기 상류를
`_hold`에 강하게 담는다 — `:Compute`처럼 클로저가 **우연히** 캡처하는 `_hold`에 강하게 담는다 — `:Compute`처럼 클로저가 **우연히** 캡처하는
것에 기대지 않는다(`:With`의 pass-through 노드엔 그 우연이 없다). 것에 기대지 않는다(`:With`의 pass-through 노드엔 그 우연이 없다).
- **⭐ [2026-08-26 보강, 8라운드 `H-110`] 말단 핸들도 마찬가지다.** - **⭐ [2026-08-26 보강, 8라운드 `H-110`] 말단 핸들도 마찬가지다.**

View file

@ -168,6 +168,12 @@ export type State<T> = StateData<T> & {
손으로 쓰는 타입 선언이 하나 늘 뿐입니다. (한때 검토했던 "T별로 손으로 쓰는 타입 선언이 하나 늘 뿐입니다. (한때 검토했던 "T별로
구워서 인라이닝"은 채택 안 함 — 0번 대전제 위반이고, 제네릭을 구워서 인라이닝"은 채택 안 함 — 0번 대전제 위반이고, 제네릭을
없애버려서 나중에 Luau가 고쳐져도 수혜를 못 받음.) 없애버려서 나중에 Luau가 고쳐져도 수혜를 못 받음.)
- **[2026-08-29 M2 단위 4 실측] `:Apply`의 파라미터는 교집합 오버로드로 선언한다** —
`(<U>(self, fn: (State<T>) -> U) -> U) & ((self, obj: { __apply: (any, any) -> any }) -> any)`.
유니온 하나(`((State<T>) -> U) | { __apply: … -> U }`)로 두면 `Blocker`처럼 필드가
더 있는 객체가 제네릭 `U` 자리에서 너비 서브타이핑을 못 받아 `state:Apply(blocker)`
strict에서 막힌다(인덱서 `[string]: any`로 열어도 같다 — `luau-test/done/26-*`).
객체 쪽 반환이 `any`라 결과는 명시 주석(①과 같은 관례). `round11.md` `H-179`.
- **캐비엇**: 콜백이 받는 `s``StateData<T>``Compute`/`With`가 - **캐비엇**: 콜백이 받는 `s``StateData<T>``Compute`/`With`가
없습니다. 콜백 안에서 다시 `s:Compute(...)`를 부르는 자리 없습니다. 콜백 안에서 다시 `s:Compute(...)`를 부르는 자리
(`:Apply`의 factory가 대표적)는 이 방식으로 못 풀고 (`:Apply`의 factory가 대표적)는 이 방식으로 못 풀고

View file

@ -77,7 +77,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가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-19 재작성 → 2026-08-21 다시 `rewrite-required/`]** 2026-08-19엔 당시 모델("emit은 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 짜서 통과했으나, **소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드 두 번째 통지가 접히게 되어** 핵심 assert가 정반대가 됨 — 이제 Observer는 변경당 **1회**만 울어야 한다. 상태의 소스는 `STATUS.md` | ROADMAP M0-1 | | `05-store-state-diamond-propagation.luau` | **[2026-08-29 폐기 — `done/`로 이동, `spec.state`/`spec.effect` 3번이 대체]** push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-19 재작성 → 2026-08-21 다시 `rewrite-required/`]** 2026-08-19엔 당시 모델("emit은 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 짜서 통과했으나, **소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드 두 번째 통지가 접히게 되어** 핵심 assert가 정반대가 됨 — 이제 Observer는 변경당 **1회**만 울어야 한다. 상태의 소스는 `STATUS.md` | 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 |
@ -96,6 +96,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 **⚠️ [2026-08-25 이동, `rewrite-required/`]** `16``ProcessStoreType`을 재사용하므로 같이 낡았다. **검증 대상(미선언 키는 타입 에러)은 그대로 유효**하고 새 모양에서도 성립함이 확인됐다(`store.nope` 거부) — 새 `Store<T>` 선언으로 바꿔 쓰기만 하면 된다 | | `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 **⚠️ [2026-08-25 이동, `rewrite-required/`]** `16``ProcessStoreType`을 재사용하므로 같이 낡았다. **검증 대상(미선언 키는 타입 에러)은 그대로 유효**하고 새 모양에서도 성립함이 확인됐다(`store.nope` 거부) — 새 `Store<T>` 선언으로 바꿔 쓰기만 하면 된다 |
| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef``true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지. **[2026-08-21] `rewrite-required/`로 이동** — 파일이 직접 구현해 쓰는 `Brand.set`/`Brand.get`이 인스턴스 브랜드 재작성으로 역전된 옛 API가 됐다(검증 대상 자체는 그대로 유효, 상태와 재작성 지침은 `STATUS.md`가 소스) | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 "⭐ 구현 — 인스턴스 브랜드" 절 | | `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef``true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지. **[2026-08-21] `rewrite-required/`로 이동** — 파일이 직접 구현해 쓰는 `Brand.set`/`Brand.get`이 인스턴스 브랜드 재작성으로 역전된 옛 API가 됐다(검증 대상 자체는 그대로 유효, 상태와 재작성 지침은 `STATUS.md`가 소스) | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 "⭐ 구현 — 인스턴스 브랜드" 절 |
| `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad<T, Pattern>`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<<A,B>>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 | | `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad<T, Pattern>`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<<A,B>>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 |
| `26-type-apply-object-factory-overload.luau` (타입체크 전용) | `state:Apply(factory)`의 파라미터 타입 — 함수 팩토리와 `__apply` 객체 팩토리를 한 시그니처로 받을 때 유니온/오버로드/인덱서 중 어느 표기가 strict에서 `state:Apply(blocker)`(필드가 더 있는 객체)를 통과시키는지 | `qa-request/pre-implementation-handtrace-round11.md` `H-179`(M2 단위 4 실측) |
## 공통 유틸리티 ## 공통 유틸리티

View file

@ -46,9 +46,9 @@
| 폴더 | 뜻 | 개수 | 누가 처리 | | 폴더 | 뜻 | 개수 | 누가 처리 |
|---|---|---|---| |---|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 9 | 에이전트 | | `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 8 | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — | | `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — |
**⚠️ [2026-08-25 신설] 타입 스파이크는 `./scripts/test.sh`가 하는 리링크를 **⚠️ [2026-08-25 신설] 타입 스파이크는 `./scripts/test.sh`가 하는 리링크를
먼저 거쳐야 한다.** `luau` CLI가 심볼릭 링크를 못 타는데(디렉토리·파일 둘 먼저 거쳐야 한다.** `luau` CLI가 심볼릭 링크를 못 타는데(디렉토리·파일 둘
@ -145,7 +145,6 @@
| 파일 | 상태 | 무엇을 고쳐야 하나 | | 파일 | 상태 | 무엇을 고쳐야 하나 |
|---|---|---| |---|---|---|
| `01-two-pass-array-hash-order.luau` | 옛 형태 기준으로는 ✅ 통과였음 | 숫자 `for` + 일반화 `for` **두 루프**로 짜여 있는데, 구현은 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — Luau의 일반화 `for`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 | | `01-two-pass-array-hash-order.luau` | 옛 형태 기준으로는 ✅ 통과였음 | 숫자 `for` + 일반화 `for` **두 루프**로 짜여 있는데, 구현은 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — Luau의 일반화 `for`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 |
| `05-store-state-diamond-propagation.luau` | 2026-08-19 재작성분은 그 시점 모델 기준 ✅ 통과였음 | **[2026-08-21] 모델이 또 바뀌었다** — 소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드에서 **두 번째 통지가 접힌다**. 그래서 이 스파이크의 핵심 assert("`:Get()`을 안 부르는 Observer가 변경당 경로 수(2)만큼 운다")가 **정반대**가 됐다 — 이제 **변경당 1회**여야 한다. **살릴 것**: `invalid` 기반 dedup이면 두 번째 변경부터 침묵하는 것을 잡는 음성 대조군(그 금지는 지금도 유효). **새로 넣을 것**: DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch가 에포크로 사라지는지(그 문서 §1의 시나리오) |
| `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는 손댈 것 없음 |
| `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) | | `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) |
@ -182,6 +181,8 @@
| 파일 | 확인된 것 | | 파일 | 확인된 것 |
|---|---| |---|---|
| `26-type-apply-object-factory-overload.luau` (타입체크 전용) | ✅ **[2026-08-29 M2 단위 4]** `state:Apply(factory)`의 파라미터 타입은 **교집합 오버로드**(함수 팩토리 제네릭 `U` / `__apply` 객체 `any` 반환) — 유니온 하나는 필드가 더 있는 객체(`Blocker`)를 못 받는다. 기대 진단 2건(음성 대조군)만 | `round11.md` `H-179`, `quad-types/src/init.luau` `State<T>.Apply` |
| `05-store-state-diamond-propagation.luau` | **[2026-08-29 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2·3의 `quad-base/test/spec.state.luau` 3번(다이아몬드에서 두 번째 도착이 규칙 3으로 접힘, 조인 1회 계산)·`spec.effect.luau` 3번(Effect도 1회)이 실제 구현에서 같은 것을 고정한다 — 이 스파이크가 물으려던 "변경당 1회"의 답. 아래는 폐기 전 상태: 2026-08-19 재작성분은 그 시점 모델 기준 ✅ 통과였음 | **[2026-08-21] 모델이 또 바뀌었다** — 소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드에서 **두 번째 통지가 접힌다**. 그래서 이 스파이크의 핵심 assert("`:Get()`을 안 부르는 Observer가 변경당 경로 수(2)만큼 운다")가 **정반대**가 됐다 — 이제 **변경당 1회**여야 한다. **살릴 것**: `invalid` 기반 dedup이면 두 번째 변경부터 침묵하는 것을 잡는 음성 대조군(그 금지는 지금도 유효). **새로 넣을 것**: DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch가 에포크로 사라지는지(그 문서 §1의 시나리오) | |
| `15-type-compute-trailing-deps-typepack.luau` | **[2026-08-28 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2가 실제 `quad-types` 선언에서 타입팩 형태를 실측해 기각했다(`round11.md` `H-176`: strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힘 → deps 자리 `...any`). 이 스파이크가 물으려던 (B)의 답이 나왔으므로 파일은 역사로만 `done/`에 남긴다. 아래는 폐기 전 상태: **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `15-type-compute-trailing-deps-typepack.luau` | **[2026-08-28 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2가 실제 `quad-types` 선언에서 타입팩 형태를 실측해 기각했다(`round11.md` `H-176`: strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힘 → deps 자리 `...any`). 이 스파이크가 물으려던 (B)의 답이 나왔으므로 파일은 역사로만 `done/`에 남긴다. 아래는 폐기 전 상태: **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 **당시의** Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인. **[2026-08-24]** 그 대비의 한쪽(Ref 콜백)은 6라운드 `H-7`**해시맵 셋**이 되어 사라졌지만, 이 스파이크가 실제로 확인한 것(**일반 Lua 테이블에서 `nil` 구멍과 `None` 채움의 거동 차이**)은 그대로 유효하다 — `sourceList`/`flattened`처럼 순서가 중요한 배열이 여전히 그 결론 위에 선다 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 **당시의** Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인. **[2026-08-24]** 그 대비의 한쪽(Ref 콜백)은 6라운드 `H-7`**해시맵 셋**이 되어 사라졌지만, 이 스파이크가 실제로 확인한 것(**일반 Lua 테이블에서 `nil` 구멍과 `None` 채움의 거동 차이**)은 그대로 유효하다 — `sourceList`/`flattened`처럼 순서가 중요한 배열이 여전히 그 결론 위에 선다 |
| `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 |

View file

@ -0,0 +1,24 @@
--!strict
-- [2026-08-29 M2 단위 4] `state:Apply(factory)`의 파라미터 타입 표기 실측 — 결론: 교집합 오버로드(N/O).
-- 유니온 하나(`((State<T>) -> U) | { __apply: ... -> U }`)는 `Blocker`처럼 필드가 더 있는 객체를 제네릭 U 자리에서
-- 받지 못한다(너비 서브타이핑 실패, 인덱서 `[string]: any`로도 안 됨). 원문 스파이크 4개 중 통과한 마지막 것.
-- 소스: `qa-request/pre-implementation-handtrace-round11.md` `H-179`. 기대: 진단 2건(마지막 두 줄 음성 대조군)만.
export type StateData<T> = { Get: (self: StateData<T>) -> T }
-- N: 교집합 오버로드 — 함수 팩토리는 제네릭 U, 객체 팩토리는 비제네릭 any 반환
export type StateN<T> = StateData<T> & {
Apply: (<U>(self: StateData<T>, factory: (StateN<T>) -> U) -> U) & ((self: StateData<T>, factory: { __apply: (self: any, state: any) -> any }) -> any),
}
type BlockerLike = { __apply: (self: any, state: any) -> any, IsBlocked: boolean, On: (self: any) -> any }
local b: BlockerLike = { IsBlocked = false, __apply = function(_s: any, st: any) return st end, On = function(s: any) return s end }
local n = (nil :: any) :: StateN<number>
local n1: StateN<number> = n:Apply(b)
local n3: number = n:Apply(function(s: StateN<number>): number return s:Get() end)
local nneg: string = n:Apply(function(s: StateN<number>): number return s:Get() end)
-- O: 순서 반대(객체 오버로드 먼저)
export type StateO<T> = StateData<T> & {
Apply: ((self: StateData<T>, factory: { __apply: (self: any, state: any) -> any }) -> any) & (<U>(self: StateData<T>, factory: (StateO<T>) -> U) -> U),
}
local o = (nil :: any) :: StateO<number>
local o1: StateO<number> = o:Apply(b)
local o3: number = o:Apply(function(s: StateO<number>): number return s:Get() end)
local oneg: string = o:Apply(function(s: StateO<number>): number return s:Get() end)

View file

@ -26,6 +26,8 @@
| `H-174` | **②** | 1→2 | 🔴 | 생명주기 4종은 **`New()` 인스턴스마다 다른 필드**(이 단위가 그렇게 만들었고 `spec.lifetime` 8이 고정)인데, 단위 2·3의 `base/` 의사코드(`Observer:_receive`의 `canExecute(self)`, `Subscribe` 넷의 `canBound(self)`, `EffectHandle.rawRerun`)는 **자유 함수**로 부른다 — `Observer.luau`/`Source.luau`가 자기 인스턴스의 필드에 어떻게 닿는지 어느 문서도 안 정했다. 결정 없이는 단위 2의 `_receive`를 쓸 수 없다 | ✅ (a) 사용자 확정 — 팩토리형, `module.canExecute(self)`를 발화 시점에 늦게 읽음(`lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체) | | `H-174` | **②** | 1→2 | 🔴 | 생명주기 4종은 **`New()` 인스턴스마다 다른 필드**(이 단위가 그렇게 만들었고 `spec.lifetime` 8이 고정)인데, 단위 2·3의 `base/` 의사코드(`Observer:_receive`의 `canExecute(self)`, `Subscribe` 넷의 `canBound(self)`, `EffectHandle.rawRerun`)는 **자유 함수**로 부른다 — `Observer.luau`/`Source.luau`가 자기 인스턴스의 필드에 어떻게 닿는지 어느 문서도 안 정했다. 결정 없이는 단위 2의 `_receive`를 쓸 수 없다 | ✅ (a) 사용자 확정 — 팩토리형, `module.canExecute(self)`를 발화 시점에 늦게 읽음(`lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체) |
| `H-176` | ① | 2 | 🟡 | `:Compute`의 trailing deps를 타입팩 `D...`로 좁히는 선언은 strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힌다(스파이크 15가 "미검증"으로 남긴 자리) | ✅ `...any` + 콜백 주석으로 확정, `source-state-plan.md` 실측 기록 | | `H-176` | ① | 2 | 🟡 | `:Compute`의 trailing deps를 타입팩 `D...`로 좁히는 선언은 strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힌다(스파이크 15가 "미검증"으로 남긴 자리) | ✅ `...any` + 콜백 주석으로 확정, `source-state-plan.md` 실측 기록 |
| `H-177` | ① | 2 | 🟢 | `InitSource`/`InitStore`가 `State.implFor`만 불러 `New()``RunInit` 순서에 의존했다 — `module-lifecycle-plan.md`는 "각 `InitXxx``require`처럼 멱등하게 자기 의존성을 당겨온다"로 확정 | ✅ 각 Init이 `module:RunInit(dep)`를 직접 호출(감사 3라운드) | | `H-177` | ① | 2 | 🟢 | `InitSource`/`InitStore`가 `State.implFor`만 불러 `New()``RunInit` 순서에 의존했다 — `module-lifecycle-plan.md`는 "각 `InitXxx``require`처럼 멱등하게 자기 의존성을 당겨온다"로 확정 | ✅ 각 Init이 `module:RunInit(dep)`를 직접 호출(감사 3라운드) |
| `H-179` | ① | 4 | 🟡 | `state:Apply(factory)`의 파라미터를 유니온 하나로 선언하면 `state:Apply(blocker)`가 strict에서 막힌다(필드가 더 있는 객체는 제네릭 `U` 자리에서 너비 서브타이핑 실패) | ✅ 교집합 오버로드로 확정(`luau-test/done/26-*`, `typing-limits.md` §1②) |
| `H-180` | ① | 4 | 🟢 | 폐기된 `state:Block` 표기가 라이브 문서 둘에 남아 있었다(`source-state-plan.md` `_hold` 파생 노드 목록, `blocker-plan.md` 사용 예시) | ✅ 정정 |
| `H-178` | ① | 3 | 🟢 | 코드의 사적 필드는 `_` 접두(`_valueEpochMap`·`_emitEpochMap`·`_subs`·`_hold`…)인데 `base/` 의사코드는 `valueEpochMap`처럼 접두 없이 쓴다 — 이름은 1:1이고 밑줄만 다르다 | ✅ 기록만(문서 무변경 — 코드 관례, `H-174` 조립 세부와 같은 급) | | `H-178` | ① | 3 | 🟢 | 코드의 사적 필드는 `_` 접두(`_valueEpochMap`·`_emitEpochMap`·`_subs`·`_hold`…)인데 `base/` 의사코드는 `valueEpochMap`처럼 접두 없이 쓴다 — 이름은 1:1이고 밑줄만 다르다 | ✅ 기록만(문서 무변경 — 코드 관례, `H-174` 조립 세부와 같은 급) |
| `H-175` | ① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬**일 때만(컴파일러 `shouldShareClosure`). 함수 인자·지역을 잡는 클로저(단위 3 `Effect`의 콜백 모양)는 정상 GC됨을 실측 | ✅ §5·`spec.ref` 주석 좁힘 | | `H-175` | ① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬**일 때만(컴파일러 `shouldShareClosure`). 함수 인자·지역을 잡는 클로저(단위 3 `Effect`의 콜백 모양)는 정상 GC됨을 실측 | ✅ §5·`spec.ref` 주석 좁힘 |
@ -169,6 +171,28 @@
### 단위 3 — `Observer``Effect` (2026-08-29) ### 단위 3 — `Observer``Effect` (2026-08-29)
### 단위 4 — `GateNode``Blocker` (2026-08-29)
### `H-179` 🟡 — `:Apply`의 파라미터 타입 표기 (①)
- **어디서**: `quad-types/src/init.luau` `State<T>.Apply` / `base/source-state-plan.md`
"`state:Apply(factory)`"(`H-94`: *"함수 또는 그 필드를 가진 객체를 받고 반환 `U`
열어둔다"*) / `base/typing-limits.md` §1②.
- **무엇이**: 단위 2가 `((State<T>) -> U) | { __apply: (self, State<T>) -> U }` 유니온으로
적었고 함수 팩토리·`__apply`만 가진 객체는 통과했는데, 단위 4의 실제 `Blocker`(필드가
더 있음)를 `s:Apply(b)`에 넣자 *"Expected this to be t1 where t1 = … | { __apply: … }"*.
스파이크 4개(유니온·인덱서·제네릭 `__apply`·`any` 파라미터)로 좁힌 결과 유니온-제네릭
파라미터에서 추가 필드가 있는 객체의 너비 서브타이핑이 안 된다. **교집합 오버로드**
(함수 쪽 제네릭 `U` / 객체 쪽 `-> any`)만 양쪽을 다 받고 음성 대조군도 잡는다.
- **처리**: 그 표기로 확정, 스파이크를 `luau-test/done/26-*`로 보존, `typing-limits.md`
§1②에 기록. `H-94`의 뜻(둘 다 받고 반환은 열어둔다)은 그대로 — 객체 쪽 반환이 `any`
호출부가 결과 타입을 명시한다(§1① 관례).
### `H-180` 🟢 — `state:Block` 잔재 (①)
- **처리**: `source-state-plan.md``_hold` 파생 노드 목록과 `blocker-plan.md` 사용 예시에서
`:Apply(blocker)`로. `gate-plan.md` 149-151은 정정 문구가 이미 뒤따라 그대로.
### `H-178` 🟢 — 사적 필드의 `_` 접두 (①) ### `H-178` 🟢 — 사적 필드의 `_` 접두 (①)
- **어디서**: `effect-plan.md` 생성자 의사코드의 `d.valueEpochMap` / `state-epoch-plan.md` §4의 - **어디서**: `effect-plan.md` 생성자 의사코드의 `d.valueEpochMap` / `state-epoch-plan.md` §4의
@ -274,6 +298,21 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
- 테스트 작성 함정: `table.insert(t, nil)`은 길이를 안 늘린다 — `emitFrom == nil` 발화를 셀 땐 - 테스트 작성 함정: `table.insert(t, nil)`은 길이를 안 늘린다 — `emitFrom == nil` 발화를 셀 땐
래퍼로 기록할 것(한 번 오진했다). 래퍼로 기록할 것(한 번 오진했다).
**단위 4 (메인 세션, 2026-08-29)**:
- `GateNode``State.luau` 안(`architecture.md` 소스 트리가 `:Gate``State.luau` 소속으로
둠) — `newNode({self}, passThrough, GateImpl)`로 만들어 `StateBrand` 등록·시딩·`_hold`를
그대로 받고, `_receive`(emit 맵은 `Peek`, unfold 합류, 정책 호출)와 `_flush`(빈 배치
false → 스왑 → `Sync(batch)``_emitDown(batch)`; `emit(false)`는 버리기)만 덮어쓴다.
`Get`은 상속(pass-through + 카운터). 값은 안 가린다(3번) — `spec.gate.luau` 9절.
- `Blocker.luau`는 잎 모듈(게이트 표면을 안 만짐 — §6의 상속 우려는 해당 없음): `On`/`Off`/
`OffWithoutEmit`/`IsOn`/`Policy`/`__apply`, 핸들 weak-key 셋 + 강한 주인은 `onUpstreamEmit`
클로저(그 클로저가 `handle(true)`로 통과시켜 업밸류를 산 채로), `Off`류는 스냅샷 순회 —
`spec.blocker.luau` 7절(GC 실측 포함).
- 단위 3 감사 1라운드 잔여: `Observer`/`Effect` 생성자의 `fn` 타입 검사(`error(…, 2)`)는
문서에 없고 코드가 더한 입력 검증 — `newNode` dep 검증과 같은 급(`architecture.md` 계약의
예), 기록만. 스파이크 `05``spec.state`/`spec.effect` 3번이 대체 → `done/`.
- `Debounce`/`Throttle`·`setTimeout`/`clearTimeout` 주입 op는 백로그 — 이번 단위에 없음.
**툴링 사실 둘**(설계 아님, 다음 단위가 알아야 함): **툴링 사실 둘**(설계 아님, 다음 단위가 알아야 함):
- `require("@self/X")`**`init.luau`에서만** 통한다 — 일반 파일에서 `@self`는 그 파일 - `require("@self/X")`**`init.luau`에서만** 통한다 — 일반 파일에서 `@self`는 그 파일
자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`. 자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`.

View file

@ -2035,3 +2035,5 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
**2026-08-29 새벽(컨테이너 이사 뒤)**: 단위 2 감사 4·5라운드 반영(`H-177` 포함), **단위 3 **2026-08-29 새벽(컨테이너 이사 뒤)**: 단위 2 감사 4·5라운드 반영(`H-177` 포함), **단위 3
구현** — `Observer`(레지스트리 소유, 네 진입점)/`Effect`(`rawRerun`·홀드·cleanup 세 자리· 구현** — `Observer`(레지스트리 소유, 네 진입점)/`Effect`(`rawRerun`·홀드·cleanup 세 자리·
네 진입점 자기 본문)/`onDestroying` 스텁·mock, spec 17절 ALL PASS. `H-178`(`_` 접두) 기록. 네 진입점 자기 본문)/`onDestroying` 스텁·mock, spec 17절 ALL PASS. `H-178`(`_` 접두) 기록.
**단위 4 구현**`GateNode`(`State.luau` `:Gate`)/`Blocker.luau`, spec 16절, `H-179`(`Apply`
타입은 교집합 오버로드)·`H-180`(`:Block` 잔재). M2 체크박스 전부 `[x]`.

View file

@ -93,7 +93,13 @@
`Observer`·`EffectHandle`·`Quad.Effect`. spec.observer 8절·spec.effect 9절 ALL PASS, analyze 0. `Observer`·`EffectHandle`·`Quad.Effect`. spec.observer 8절·spec.effect 9절 ALL PASS, analyze 0.
발견 `H-178`(사적 필드 `_` 접두, 기록만). 발견 `H-178`(사적 필드 `_` 접두, 기록만).
- 단위 2 감사 6라운드 수렴(0건). 단위 3 감사 1라운드: `todos.md` stale 1 + 의심 2(반영).
- **단위 4 구현**`State.luau``GateImpl`/`:Gate`, `Blocker.luau`(잎), `quad-types`
`GateSetup`/`Blocker`/`State.Gate`/`Quad.Blocker`, `spec.gate` 9절·`spec.blocker` 7절.
발견 `H-179`(`Apply` 파라미터는 교집합 오버로드 — 스파이크 4개, `luau-test/done/26-*`),
`H-180`(`:Block` 잔재). ROADMAP M2 체크박스 전부 `[x]`(`H-80` 포함).
## 다음 ## 다음
단위 2·3 합쳐 끝 절차: 감사 루프(6라운드 진행 중) → `/code-review high` → 탐사자 → §4. 단위 3·4 끝 절차(감사 루프 병렬 2 → `/code-review high` → 탐사자 → §4), 그 뒤 M2 전체
그다음 단위 4(`GateNode` → `Blocker` + 탑레벨 마무리). 마무리 보고. M3은 이 자율 구간의 범위 밖(규약 §1은 M2 단위 넷까지).

View file

@ -9,10 +9,10 @@
`qa-request/pre-implementation-handtrace-round11-brief.md`(세 갈래 분류 / `qa-request/pre-implementation-handtrace-round11-brief.md`(세 갈래 분류 /
단위 넷 / 관여 시점), 발견과 배치 문항은 `-round11.md`(§4 표가 사용자가 단위 넷 / 관여 시점), 발견과 배치 문항은 `-round11.md`(§4 표가 사용자가
읽을 유일한 자리). **진행 상태의 소스는 `ROADMAP.md` M2 체크박스**, 여기서 읽을 유일한 자리). **진행 상태의 소스는 `ROADMAP.md` M2 체크박스**, 여기서
세지 않는다. **[2026-08-28 저녁 기준]** 단위 1 완료(배치 회신까지 반영), 단위 2 세지 않는다. **[2026-08-29 새벽 기준]** 단위 1·2 완료(단위 2 감사 6라운드 수렴),
(`EpochMap`/`State`/`Source`/`Store`) 구현·감사 3라운드까지 완료 — **재개 지점은 단위 3(`Observer`/`Effect`) 구현 완료, 단위 4(`GateNode`/`Blocker`) 구현 중 —
단위 2 끝 절차의 감사 4라운드부터**(그다음 `/code-review high` → 탐사자), 소스는 진행 원문은 `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절. 밤샘 자율
`session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절. 구간(사용자 허용, 병렬 2)이라 이 줄은 자주 낡는다 — `ROADMAP.md` 체크박스가 소스.
아래는 착수 전(2026-08-26) 서술: 아래는 착수 전(2026-08-26) 서술:
**[2026-08-26] 8라운드까지 전부 처리 완료 — M2 착수 게이트가 0이다.** **[2026-08-26] 8라운드까지 전부 처리 완료 — M2 착수 게이트가 0이다.**

View file

@ -598,7 +598,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`archive/canexecute-inst-arg-reversed.md`. 부수 효과로 **바인딩이 `archive/canexecute-inst-arg-reversed.md`. 부수 효과로 **바인딩이
죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를 통과** 죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를 통과**
(살아있는 바인딩만 막는 게 의도, 안 바뀜) (살아있는 바인딩만 막는 게 의도, 안 바뀜)
- [ ] **`state:Gate(setup)` + `GateNode`** (**[2026-08-24]** 위 `EpochMap`과 같이 되돌아옴) — - [x] **[2026-08-29 완료 — 단위 4]** **`state:Gate(setup)` + `GateNode`** (**[2026-08-24]** 위 `EpochMap`과 같이 되돌아옴) —
emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드 emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드
(`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦). (`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦).
**[2026-08-28 10라운드 `H-152`] 조립 첫 줄은 `StateBrand:register(node)`** — **[2026-08-28 10라운드 `H-152`] 조립 첫 줄은 `StateBrand:register(node)`** —
@ -620,7 +620,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
그리고 **수신 시점 판정은 `emitEpochMap:Peek`**을 쓴다 — `:Update` 그리고 **수신 시점 판정은 `emitEpochMap:Peek`**을 쓴다 — `:Update`
덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻과 양립하지 않는다. 덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻과 양립하지 않는다.
flush 순서는 **빈 배치 얼리리턴 → 스왑 → `:Sync(batch)` → 전파** flush 순서는 **빈 배치 얼리리턴 → 스왑 → `:Sync(batch)` → 전파**
- [ ] **`Blocker.luau`** (**[2026-08-24]** 위 둘과 같이 되돌아옴) — 위 `GateNode` 위에 - [x] **[2026-08-29 완료 — 단위 4]** **`Blocker.luau`** (**[2026-08-24]** 위 둘과 같이 되돌아옴) — 위 `GateNode` 위에
얹히는 **정책**(다시 노드를 만들지 말 것). 얹히는 **정책**(다시 노드를 만들지 말 것).
**⭐ [2026-08-25 추가, 7라운드 `H-63`] onunblock 핸들 보관 세 자리**: **⭐ [2026-08-25 추가, 7라운드 `H-63`] onunblock 핸들 보관 세 자리**:
(1) **weak-키 해시맵 셋**(`__mode = "k"`) — 값-weak 배열이면 구멍에서 (1) **weak-키 해시맵 셋**(`__mode = "k"`) — 값-weak 배열이면 구멍에서
@ -634,7 +634,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy 호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch 조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구** 쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
- [ ] **[2026-08-28 부분 — 단위 1·2·3분(`Relate`/`Void`/`Ref`/`is*`/생명주기 4종+`onDestroying`/`Source`/`Store`/`Effect` + `State`·`Source`·`Store`·`Observer`·`EffectHandle` 타입)은 `quad-types` `Quad`에 추가됨, `Blocker`는 단위 4에서]** **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]** - [x] **[2026-08-29 완료 — 단위 1~4분 전부(`Relate`/`Void`/`Ref`/`is*`/생명주기 4종+`onDestroying`/`Source`/`Store`/`Effect`/`Blocker` + `State`·`Source`·`Store`·`Observer`·`EffectHandle`·`Blocker`·`GateSetup` 타입)가 `quad-types` `Quad`에 추가됨 — M2 몫은 닫힘]** **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
`quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 — `quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 —
`Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형, `Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형,
2026-08-27 `H-128`) / 2026-08-27 `H-128`) /
@ -665,7 +665,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
되면 동종 타입 dep 1개로 한정 — 실측 결과 채택된 건 이 대안이 아니라 되면 동종 타입 dep 1개로 한정 — 실측 결과 채택된 건 이 대안이 아니라
deps 자리 `...any` + 콜백 주석이다) deps 자리 `...any` + 콜백 주석이다)
- [ ] **[2026-08-28 부분 — 단위 2에서 `:With`/`Source:Emit()` 완료, `state:Apply(blocker)``Apply``__apply` 경로로 단위 4에서 실제 Blocker와 합류할 때 닫힘]** **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Apply(blocker)` / - [x] **[2026-08-29 완료 — 단위 2에서 `:With`/`Source:Emit()`, 단위 4에서 `state:Apply(blocker)`(`Blocker.__apply` → `state:Gate`, `spec.blocker` 2번)]** **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Apply(blocker)` /
`Source:Emit()``:Compute`/`:Apply`/`:Observer`는 각각 체크박스가 `Source:Emit()``:Compute`/`:Apply`/`:Observer`는 각각 체크박스가
있는데 이 셋만 빠져 있었다 있는데 이 셋만 빠져 있었다
- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-25 신설, `H-81`; 2026-08-26 자리 정정 `H-122`]** - [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-25 신설, `H-81`; 2026-08-26 자리 정정 `H-122`]**

111
quad-base/src/Blocker.luau Normal file
View file

@ -0,0 +1,111 @@
--[[
Blocker — value-based emit deferral, as a POLICY on top of `state:Gate`.
`.claude/base/blocker-plan.md` "메커니즘 (확정)", `.claude/base/gate-plan.md`
5번 (`blocker:Policy(emit)`), as-is.
Blocker() -> blocker
blocker:On() -- IsBlocked = true, nothing else
blocker:Off() -- IsBlocked = false FIRST, then every registered handle with emit=true
blocker:OffWithoutEmit() -- same path with emit=false: the withheld batch is DISCARDED
blocker:IsOn() -> boolean -- thin read of `IsBlocked`
blocker:Policy(emit) -> onUpstreamEmit
-- this blocker's gate policy as a value; registers the
-- onunblock handle at THIS call (weak-key set)
state:Apply(blocker) -- == state:Gate(function(emit) return blocker:Policy(emit) end)
-- via the method-form `__apply` (`H-158`; old `state:Block` is gone)
Ownership (`H-63`): the handle set is weak-key; the strong owner of a handle
is the `onUpstreamEmit` closure the policy returns (upvalue), so a handle
lives exactly as long as its GateNode. A Blocker holding handles strongly
was rejected — it would pin every gated state and its upstream chain.
`Off`/`OffWithoutEmit` snapshot the set before walking (a flush can create
new gates mid-walk). `IsBlocked` is a plain boolean — nesting the same
Blocker is deliberately unsupported; make a new one per overlapping batch.
"HasBlockedEmit" has no field here: it IS `next(gate._withheld) ~= nil`, and
the gate's `emit(commit) -> boolean` is the only channel to it.
Dependency-free leaf (`Brand` only) — it never touches lifetime gates, so it
is shared across quad instances like `Ref`.
]]
local Brand = require("./Brand")
local QuadTypes = require("../roblox_packages/quad_types")
export type Blocker = QuadTypes.Blocker
local BlockerBrand = Brand.BlockerBrand
local WEAK_KEY_MT = { __mode = "k" }
local BlockerImpl = {}
BlockerImpl.__index = BlockerImpl
function BlockerImpl.IsOn(self: any): boolean
return self.IsBlocked
end
function BlockerImpl.On(self: any): any
self.IsBlocked = true
return self
end
-- Shared by Off/OffWithoutEmit — the only difference is the flag passed on.
local function runHandles(self: any, doEmit: boolean)
local snapshot: { (boolean) -> () } = {}
for handle in pairs(self._handles) do -- `H-63` (3): snapshot, then walk
snapshot[#snapshot + 1] = handle
end
for _, handle in ipairs(snapshot) do
handle(doEmit)
end
end
function BlockerImpl.Off(self: any): any
self.IsBlocked = false -- first, so a flush that re-enters sees the blocker open
runHandles(self, true)
return self
end
function BlockerImpl.OffWithoutEmit(self: any): any
self.IsBlocked = false
runHandles(self, false)
return self
end
-- The gate policy, as a value. `emit` is the gate's flush: `emit()` propagates
-- the withheld batch, `emit(false)` discards it, both no-ops on an empty batch
-- (so the handle is idempotent for free — gate-plan 8번).
function BlockerImpl.Policy(self: any, emit: (boolean?) -> boolean): () -> ()
local function handle(doEmit: boolean)
if doEmit then
emit()
else
emit(false)
end
end
self._handles[handle] = true -- `H-63` (1): weak-key set
return function() -- onUpstreamEmit — `H-63` (2): THIS closure is the handle's strong owner
if self.IsBlocked then
return -- the gate already put the source into its withheld set; nothing to do
end
handle(true) -- open: pass through — going through `handle` is what keeps it alive (upvalue)
end
end
-- Applicative factory, method form (`H-158`): `state:Apply(blocker)`.
function BlockerImpl.__apply(self: any, state: any): any
return state:Gate(function(emit)
return self:Policy(emit)
end)
end
local function Blocker(): Blocker
local self = setmetatable({
IsBlocked = false,
_handles = setmetatable({}, WEAK_KEY_MT),
}, BlockerImpl)
BlockerBrand:register(self)
return (self :: any) :: Blocker
end
return Blocker

View file

@ -35,6 +35,12 @@
advances the value only — never notifies). advances the value only — never notifies).
- `.claude/base/modifier-plan.md` 7번: a `:Compute` result that is a - `.claude/base/modifier-plan.md` 7번: a `:Compute` result that is a
Modifier errors before it is cached. Modifier errors before it is cached.
- `.claude/base/gate-plan.md` "`GateNode` 조립" (unit 4): `state:Gate(setup)`
makes a `GateNode` — a pass-through State node whose `_receive` judges
with `Peek` on the emit map, merges the source into a weak-key
`_withheld` set, and hands control to the policy; `_flush(commit)` is
the `emit` the policy holds (empty → false; swap → `Sync(batch)` →
`_emitDown(batch)`; `emit(false)` discards). Value is never gated (3번).
]] ]]
local Brand = require("./Brand") local Brand = require("./Brand")
@ -80,10 +86,10 @@ local function createImpl(module: any)
Impl._emitDown = emitDown Impl._emitDown = emitDown
-- ── node creation ───────────────────────────────────────────────── -- ── node creation ─────────────────────────────────────────────────
local function newNode(deps: { any }, fn: any): any local function newNode(deps: { any }, fn: any, mt: any?): any
for i, dep in ipairs(deps) do for i, dep in ipairs(deps) do
if not isState(dep) then if not isState(dep) then
error(`State: dep #{i} is not a State/Source`, 3) -- 3: past Compute/With to the user's call error(`State: dep #{i} is not a State/Source`, 3) -- 3: past Compute/With/Gate to the user's call
end end
end end
local self = setmetatable({ local self = setmetatable({
@ -95,8 +101,8 @@ local function createImpl(module: any)
_cacheTargetCount = 0, _cacheTargetCount = 0,
_cacheCurrCount = nil, -- never computed yet → always differs _cacheCurrCount = nil, -- never computed yet → always differs
_cache = nil, _cache = nil,
}, Impl) }, mt or Impl)
StateBrand:register(self) StateBrand:register(self) -- `H-152`: a GateNode is a State too — first line of its assembly
-- §4 seeding: valueEpochMap takes every upstream epoch, live. -- §4 seeding: valueEpochMap takes every upstream epoch, live.
for _, dep in ipairs(deps) do for _, dep in ipairs(deps) do
dep._subs[self] = true dep._subs[self] = true
@ -180,6 +186,70 @@ local function createImpl(module: any)
return newNode(deps, fn) -- one node: edges only, no combining node return newNode(deps, fn) -- one node: edges only, no combining node
end end
-- ── GateNode (`gate-plan.md` "`GateNode` 조립") — same layer as a ComputeNode ──
local GateImpl = setmetatable({}, { __index = Impl })
GateImpl.__index = GateImpl
local function newWithheld()
return setmetatable({}, WEAK_KEY_MT) -- `H-9`: every withheld table is weak-keyed, the swapped-in one too
end
-- The ONLY exception to §4: the emit map is Peeked at receive time and
-- written at flush (`Sync(batch)`), so "what I have sent downstream" stays true.
function GateImpl._receive(self: any, from: any)
local valueChanged = self._valueEpochMap:Update(from)
local emitChanged = self._emitEpochMap:Peek(from) -- Peek, not Update (`H-72`)
if valueChanged then
self:_invalidate()
end
if not (valueChanged or emitChanged) then
return -- rule 3: swallowed — no policy, not added to the set
end
-- Merge the source(s) into the withheld set. A batch is UNFOLDED (never held
-- by reference — an upstream gate's batch is a one-shot snapshot).
local withheld = self._withheld
if isEpoch(from) then
withheld[from] = true
else
for epoch in pairs(from) do
withheld[epoch] = true
end
end
self._onUpstreamEmit() -- the policy decides: emit() / emit(false) / nothing
end
-- This IS the `emit(commit) -> boolean` the policy holds.
function GateImpl._flush(self: any, commit: boolean?): boolean
local batch = self._withheld
if next(batch) == nil then
return false -- (1) empty batch: nothing at all (8번)
end
self._withheld = newWithheld() -- (2) swap, never clear — nested waves get a fresh table
if commit == false then
return true -- discard (`H-55`): no Sync, no propagation
end
self._emitEpochMap:Sync(batch) -- (3) BEFORE propagating, all at once
emitDown(self, batch) -- (4) the detached batch is the payload — the gate itself never is
return true
end
-- `state:Gate(setup)`; `setup(emit) -> onUpstreamEmit`. Returns a State<T> (same T).
function Impl.Gate(self: any, setup: any): any
if type(setup) ~= "function" then
error("State: Gate setup must be a function (emit) -> onUpstreamEmit", 2)
end
local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node
node._withheld = newWithheld()
local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean
return node:_flush(commit)
end)
if type(onUpstreamEmit) ~= "function" then
error("State: Gate setup must return the onUpstreamEmit function", 2)
end
node._onUpstreamEmit = onUpstreamEmit -- strong: the policy closure (and, through it, the Blocker handle)
return node
end
-- `state:Observer(fn?)` — leaf subscriber, fires once at registration -- `state:Observer(fn?)` — leaf subscriber, fires once at registration
-- (`source-state-plan.md` "`state:Observer(fn)`"). Body lives in `Observer.luau`. -- (`source-state-plan.md` "`state:Observer(fn)`"). Body lives in `Observer.luau`.
function Impl.Observer(self: any, fn: any?): any function Impl.Observer(self: any, fn: any?): any

View file

@ -25,6 +25,7 @@ local InitSource = require("@self/Source")
local InitStore = require("@self/Store") local InitStore = require("@self/Store")
local Observer = require("@self/Observer") local Observer = require("@self/Observer")
local Effect = require("@self/Effect") local Effect = require("@self/Effect")
local Blocker = require("@self/Blocker")
type Quad = QuadTypes.Quad type Quad = QuadTypes.Quad
@ -41,6 +42,7 @@ local function New(): Quad
Relate = Relate, Relate = Relate,
Void = Void, Void = Void,
Ref = Ref, Ref = Ref,
Blocker = Blocker, -- 단위 4 — 게이트 정책 잎 모듈(인스턴스 간 공유)
isEpoch = Brand.isEpoch, isEpoch = Brand.isEpoch,
isSource = Brand.isSource, isSource = Brand.isSource,
isState = Brand.isState, isState = Brand.isState,

View file

@ -0,0 +1,155 @@
--[[
Blocker 계약 — `.claude/base/blocker-plan.md` "메커니즘 (확정)"(On/Off/OffWithoutEmit/IsOn/Policy/`__apply`,
onunblock 핸들 보관 `H-63` 셋) / "재진입(네스팅) — 의도적으로 미지원", `.claude/base/gate-plan.md` 5번·8번.
]]
local Quad = require("../src")
local QuadTypes = require("../roblox_packages/quad_types")
type State<T> = QuadTypes.State<T>
type Probe = { count: number, last: any, _receive: (self: Probe, from: any) -> () }
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
local Source, Blocker = Quad.Source, Quad.Blocker
local function probe(target: any): Probe
local p = { count = 0, last = nil :: any } :: Probe
function p._receive(self: Probe, from: any)
self.count += 1
self.last = from
end
target._subs[p] = true
return p
end
print("=== 1. 생성·상태 — IsOn/On/Off/OffWithoutEmit은 self 반환, 브랜드, 단순 불리언(카운터 아님) ===")
do
local b = Blocker()
assert(Quad.isBlocker(b) and not Quad.isState(b), "brand")
assert(b:IsOn() == false, "starts open")
assert(b:On() == b and b:IsOn() == true, "On")
b:On() -- twice: still just true
assert(b:Off() == b and b:IsOn() == false, "Off — no counting: one Off closes two Ons")
assert(b:OffWithoutEmit() == b and b:IsOn() == false, "idempotent")
print("PASS")
end
print()
print("=== 2. state:Apply(blocker) — __apply 메소드형(H-158)으로 GateNode 하나, 열려 있으면 투명 통과 ===")
do
local s = Source(1)
local b = Blocker()
local g: State<number> = s:Apply(b)
assert(Quad.isState(g) and g ~= s and g:Get() == 1, "a new gated State (GateNode)")
local p = probe(g)
s:Set(2)
assert(p.count == 1 and p.last[s] == true, "open: passes through as a batch")
assert(g:Get() == 2, "value follows")
print("PASS")
end
print()
print("=== 3. On → 유보(HasBlockedEmit = withheld 비어있지 않음), Off → 정확히 1회 flush, 이미 비었으면 no-op ===")
do
local s = Source(1)
local b = Blocker()
local g: State<number> = s:Apply(b)
local p = probe(g)
b:On()
s:Set(2)
s:Set(3)
assert(p.count == 0 and g:Get() == 3, "blocked: no notification, value still visible")
b:Off()
assert(p.count == 1 and p.last[s] == true, "Off flushed exactly once")
b:On()
b:Off()
assert(p.count == 1, "nothing withheld → Off does nothing (idempotent)")
print("PASS")
end
print()
print("=== 4. OffWithoutEmit — 밀린 전파를 버리며 끈다, 다음 진짜 emit은 정상 ===")
do
local s = Source(1)
local b = Blocker()
local g: State<number> = s:Apply(b)
local p = probe(g)
b:On()
s:Set(2)
b:OffWithoutEmit()
assert(p.count == 0 and next((g :: any)._withheld) == nil, "discarded, set emptied")
s:Set(3)
assert(p.count == 1, "next real emit propagates")
print("PASS")
end
print()
print("=== 5. 하나의 Blocker가 여러 gated state를 — Off가 전부 풀고, 순회는 스냅샷(풀리는 중 새 등록 안전) ===")
do
local s, t = Source(1), Source(1)
local b = Blocker()
local gs: State<number> = s:Apply(b)
local gt: State<number> = t:Apply(b)
local ps, pt = probe(gs), probe(gt)
b:On()
s:Set(2)
t:Set(2)
local newGate: any = nil
local pn: any = nil
local sub = {}
function sub._receive(_self: any, _from: any)
if newGate == nil then
newGate = s:Apply(b) -- created mid-Off (mid-walk): must not break the walk
pn = probe(newGate)
end
end
;(gs :: any)._subs[sub] = true
b:Off()
assert(ps.count == 1 and pt.count == 1, "both gated states flushed")
assert(newGate ~= nil and pn.count == 0, "gate created mid-walk was not visited (snapshot) and had nothing withheld")
print("PASS")
end
print()
print("=== 6. H-63 — 핸들은 weak-key, 강한 주인은 게이트의 onUpstreamEmit 클로저: 게이트가 죽으면 핸들도 사라진다 ===")
do
local b = Blocker()
local s = Source(1)
local function make()
local g: State<number> = s:Apply(b)
g:Get()
end
make()
collectgarbage()
collectgarbage()
assert(next((b :: any)._handles) == nil, "dead gate → its handle left the weak set (Blocker does not pin gates)")
local g2: State<number> = s:Apply(b)
local n = 0
for _ in pairs((b :: any)._handles) do
n += 1
end
assert(n == 1 and g2:Get() == 1, "a live gate keeps its handle")
print("PASS")
end
print()
print("=== 7. Policy(emit)를 직접 Gate에 배선 — Apply와 같은 모양, 여러 노드가 정책을 공유 ===")
do
local s = Source(1)
local b = Blocker()
local g: State<number> = s:Gate(function(emit)
return b:Policy(emit)
end)
local p = probe(g)
b:On()
s:Set(2)
assert(p.count == 0, "withheld via the shared blocker")
b:Off()
assert(p.count == 1, "released")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,246 @@
--[[
Gate 계약 — `.claude/base/gate-plan.md` "제안된 모양"(setup 2단) / 2번(`emit(commit) -> boolean`) /
3번(값은 안 가린다) / 4번(흡수 집합 스냅샷·unfold·`Peek`·flush 순서) / 8번(빈 배치 no-op) /
"`GateNode` 조립" / "계약 — 게이트는 emit 경로만 미룬다", `.claude/base/state-epoch-plan.md` §4 게이트 예외.
]]
local Quad = require("../src")
local Brand = require("../src/Brand")
local QuadTypes = require("../roblox_packages/quad_types")
type State<T> = QuadTypes.State<T>
type Probe = { count: number, last: any, _receive: (self: Probe, from: any) -> () }
local Source = Quad.Source
local function probe(target: any): Probe
local p = { count = 0, last = nil :: any } :: Probe
function p._receive(self: Probe, from: any)
self.count += 1
self.last = from
end
target._subs[p] = true
return p
end
-- 켜고 끌 수 있는 최소 정책: 열려 있으면 바로 flush, 닫혀 있으면 쌓아둠
local function switchPolicy()
local open = true
local emitRef: any
local function setup(emit: (boolean?) -> boolean): () -> ()
emitRef = emit
return function()
if open then
emit()
end
end
end
local ctl = {
setup = setup,
close = function()
open = false
end,
open = function()
open = true
end,
flush = function(commit: boolean?): boolean
return emitRef(commit)
end,
}
return ctl
end
print("=== 1. 조립 — setup은 생성 시 1회, StateBrand 등록, State 노드로 위임(Get은 pass-through) ===")
do
local s = Source(1)
local setups = 0
local g: State<number> = s:Gate(function(emit)
setups += 1
assert(type(emit) == "function", "setup receives the flush handle")
return function()
emit()
end
end)
assert(setups == 1, "setup ran once")
assert(Brand.StateBrand:is(g) and Quad.isState(g) and not Quad.isEpoch(g), "a GateNode is a State (H-152), not an Epoch")
assert(g:Get() == 1, "value passes through")
s:Set(2)
assert(g:Get() == 2, "…and follows the upstream")
local bad = pcall(function()
s:Gate(5 :: any)
end)
assert(not bad, "setup must be a function")
local bad2 = pcall(function()
s:Gate(function()
return nil :: any
end)
end)
assert(not bad2, "setup must return the onUpstreamEmit function")
print("PASS")
end
print()
print("=== 2. 열린 게이트 — 통과하되 출처는 게이트의 배치(집합)로 바뀐다 ===")
do
local s = Source(1)
local ctl = switchPolicy()
local g: State<number> = s:Gate(ctl.setup)
local p = probe(g)
s:Set(2)
assert(p.count == 1, "passed through once")
assert(type(p.last) == "table" and p.last[s] == true and next(p.last, s) == nil, "payload is the detached batch set {[s]=true}, not the source itself")
print("PASS")
end
print()
print("=== 3. 닫힌 게이트 — 유보: 통지 없음, 값은 Get으로 보인다(3번), 풀면 배치 1회 ===")
do
local s = Source(1)
local ctl = switchPolicy()
local g: State<number> = s:Gate(ctl.setup)
local p = probe(g)
ctl.close()
s:Set(2)
s:Set(3)
assert(p.count == 0, "withheld: no downstream notification")
assert(g:Get() == 3, "…but the value is visible through Get (gate is emit-only)")
assert(ctl.flush() == true and p.count == 1 and p.last[s] == true, "flush: one batch with the source; returned true")
assert(ctl.flush() == false and p.count == 1, "empty batch: nothing happens, returns false (8번)")
print("PASS")
end
print()
print("=== 4. emit(false) — 배치를 버린다: 전파도 Sync도 없음, 다음 진짜 emit이 스스로 낫는다 ===")
do
local s = Source(1)
local ctl = switchPolicy()
local g: State<number> = s:Gate(ctl.setup)
local p = probe(g)
ctl.close()
s:Set(2)
assert(ctl.flush(false) == true and p.count == 0, "discarded: had something (true) but nothing propagated")
assert(ctl.flush(false) == false, "…and now it is empty")
ctl.open()
s:Set(3)
assert(p.count == 1, "the next real emit propagates normally")
print("PASS")
end
print()
print("=== 5. 규칙 3 — 같은 리비전이 두 경로로 와도 정책은 한 번(다이아몬드), 유보 중엔 Peek이라 재도착은 정책 재실행(무해) ===")
do
local s = Source(1)
local a: State<number> = s:Compute(function(x): number
return x:Get()
end)
local b: State<number> = s:Compute(function(x): number
return x:Get()
end)
local w: State<number> = a:With(b)
local policyRuns = 0
local g: State<number> = w:Gate(function(emit)
return function()
policyRuns += 1
emit()
end
end)
local p = probe(g)
s:Set(2)
assert(policyRuns == 1 and p.count == 1, "one Set through a diamond → policy once, downstream once")
-- 유보 중 같은 리비전 재도착: emitEpochMap을 Peek만 하므로 규칙 2로 정책이 한 번 더 돈다(집합엔 이미 있어 무해)
local runs2 = 0
local held: any
local g2: State<number> = s:Gate(function(emit)
held = emit
return function()
runs2 += 1
end
end)
local p2 = probe(g2)
s:Set(3)
;(g2 :: any):_receive(s) -- 같은 리비전이 다른 경로로 또 옴
assert(runs2 == 2, "while withheld, the same revision re-arriving runs the policy again (Peek, not Update)")
assert(held() == true and p2.count == 1 and p2.last[s] == true, "…but the batch still carries the source once (set)")
print("PASS")
end
print()
print("=== 6. flush 순서 — 스왑 → Sync(배치) → 전파: 전파 중 재진입 flush는 새 테이블에 쌓인다 ===")
do
local s = Source(1)
local ctl = switchPolicy()
local g: State<number> = s:Gate(ctl.setup)
local seen: { any } = {}
local reenter = true
local sub = {}
function sub._receive(_self: any, from: any)
table.insert(seen, from)
if reenter then
reenter = false
s:Set(99) -- downstream sets upstream while the outer propagation is on the stack
end
end
;(g :: any)._subs[sub] = true
s:Set(2)
assert(#seen == 2, "outer batch and the nested batch each arrived once")
assert(seen[1] ~= seen[2], "the nested wave used a NEW withheld table — the outer batch was not cleared under it")
assert(next((g :: any)._withheld) == nil, "nothing left withheld")
print("PASS")
end
print()
print("=== 7. 게이트 안 게이트 — 받은 배치를 풀어 자기 집합에 합친다(참조를 들고 있지 않는다) ===")
do
local s, t = Source(1), Source(1)
local outerCtl, innerCtl = switchPolicy(), switchPolicy()
local outer: State<number> = s:With(t):Gate(outerCtl.setup)
local inner: State<number> = outer:Gate(innerCtl.setup)
local p = probe(inner)
innerCtl.close()
s:Set(2) -- outer passes {s}; inner withholds
t:Set(2) -- outer passes {t}; inner withholds
assert(p.count == 0, "inner withheld both")
local w = (inner :: any)._withheld
assert(w[s] == true and w[t] == true, "inner unfolded both batches into its own set")
innerCtl.flush()
assert(p.count == 1 and p.last[s] == true and p.last[t] == true, "one merged batch downstream")
print("PASS")
end
print()
print("=== 8. 유보 중 Get으로 앞당겨 읽은 하류 — 풀릴 때 통지만 오고 재계산 없음(규칙 2) ===")
do
local s = Source(1)
local ctl = switchPolicy()
local g: State<number> = s:Gate(ctl.setup)
local runs = 0
local d: State<number> = g:Compute(function(x): number
runs += 1
return x:Get() * 10
end)
local p = probe(d)
assert(d:Get() == 10 and runs == 1, "seed")
ctl.close()
s:Set(2)
assert(d:Get() == 20 and runs == 2, "Get walks through the gate (value not gated)")
ctl.flush()
assert(p.count == 1, "the released batch notifies downstream (rule 2)")
assert(d:Get() == 20 and runs == 2, "…without recomputing — value was already current")
print("PASS")
end
print()
print("=== 9. 흡수 집합은 weak-key, 스왑된 새 테이블도 weak (H-9) ===")
do
local ctl = switchPolicy()
local root = Source(1)
local g: State<number> = root:Gate(ctl.setup)
ctl.close()
root:Set(2)
ctl.flush(false)
assert(getmetatable((g :: any)._withheld).__mode == "k", "swapped-in table is weak-keyed too")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -258,7 +258,7 @@ do
self.calls += 1 self.calls += 1
return st return st
end end
local viaObj = s:Apply(factory :: any) local viaObj = s:Apply(factory) -- 객체 오버로드: 추가 필드(`calls`)가 있어도 통과해야 한다
assert(viaObj == s and factory.calls == 1, "__apply is called as a METHOD on the factory object (H-158)") assert(viaObj == s and factory.calls == 1, "__apply is called as a METHOD on the factory object (H-158)")
print("PASS") print("PASS")
end end

View file

@ -80,6 +80,24 @@ export type EffectHandle = {
} }
export type EffectFn = (self: EffectHandle) -> ...(() -> ()) export type EffectFn = (self: EffectHandle) -> ...(() -> ())
-- `state:Gate(setup)`의 정책 계약(`gate-plan.md` 2번): 바깥은 생성 시 1회, 반환 클로저는 상류
-- emit마다. `emit()`/`emit(true)` = 흡수 집합 flush, `emit(false)` = 버리기, 반환 = 쌓인 게 있었나.
export type GateEmit = (commit: boolean?) -> boolean
export type GateSetup = (emit: GateEmit) -> () -> ()
-- `Blocker` — `GateNode` 위의 정책(`blocker-plan.md`). `state:Apply(blocker)`가 `__apply`로 배선.
export type Blocker = {
IsBlocked: boolean,
IsOn: (self: Blocker) -> boolean,
On: (self: Blocker) -> Blocker,
Off: (self: Blocker) -> Blocker,
OffWithoutEmit: (self: Blocker) -> Blocker,
Policy: (self: Blocker, emit: GateEmit) -> () -> (),
-- `state:Apply(b)`는 gated `State<T>`(같은 T)를 돌려준다 — 호출부가 결과 타입을 명시
-- (`State<T>.Apply`의 객체 오버로드 주석 참고).
__apply: (self: any, state: any) -> any,
}
export type State<T> = StateData<T> & { export type State<T> = StateData<T> & {
-- deps는 `...any`다 — 타입팩 `D...`로 위치 인자를 좁히는 형태는 strict에서 콜백 dep -- deps는 `...any`다 — 타입팩 `D...`로 위치 인자를 좁히는 형태는 strict에서 콜백 dep
-- 추론이 `{read Get: ...}`로 뒤틀려 정상 호출까지 막힌다(M2 단위 2 실측, 스파이크 15가 -- 추론이 `{read Get: ...}`로 뒤틀려 정상 호출까지 막힌다(M2 단위 2 실측, 스파이크 15가
@ -87,9 +105,15 @@ export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>, previous: U?, ...any) -> U, ...any) -> State<U>, Compute: <U>(self: StateData<T>, fn: (self: StateData<T>, previous: U?, ...any) -> U, ...any) -> State<U>,
With: (self: StateData<T>, ...any) -> State<T>, With: (self: StateData<T>, ...any) -> State<T>,
-- 애플리커티브 팩토리는 함수이거나 메소드형 `__apply`를 가진 객체(`H-94`/`H-158`). -- 애플리커티브 팩토리는 함수이거나 메소드형 `__apply`를 가진 객체(`H-94`/`H-158`).
Apply: <U>(self: StateData<T>, factory: ((State<T>) -> U) | { __apply: (self: any, state: State<T>) -> U }) -> U, -- **교집합 오버로드**로 선언한다(M2 단위 4 실측, `luau-test/done/26-*`): 유니온 하나로
-- 두면 `Blocker`처럼 필드가 더 있는 객체가 제네릭 `U` 자리에서 너비 서브타이핑을 못
-- 받아 `state:Apply(blocker)`가 strict에서 막힌다. 객체 쪽은 `any`를 돌려주므로 결과는
-- `local g: State<number> = s:Apply(b)`로 명시(§1①의 파생 State 명시 주석 관례와 같다).
Apply: (<U>(self: StateData<T>, factory: (State<T>) -> U) -> U) & ((self: StateData<T>, factory: { __apply: (self: any, state: any) -> any }) -> any),
-- 등록 즉시 1회 실행. 무인자면 "항상 관측" 유틸. -- 등록 즉시 1회 실행. 무인자면 "항상 관측" 유틸.
Observer: (self: StateData<T>, fn: ObserverFn<T>?) -> Observer, Observer: (self: StateData<T>, fn: ObserverFn<T>?) -> Observer,
-- 전파 경로에 GateNode 하나(값은 안 가린다, 통지만 유보). 같은 T.
Gate: (self: StateData<T>, setup: GateSetup) -> State<T>,
} }
-- `Source`는 `State`를 구조적으로 만족하고 동시에 `Epoch`다(다중 태깅). -- `Source`는 `State`를 구조적으로 만족하고 동시에 `Epoch`다(다중 태깅).
export type Source<T> = State<T> & { export type Source<T> = State<T> & {
@ -140,6 +164,8 @@ export type Quad = {
Store: <T>(defaults: T?) -> Store<T>, Store: <T>(defaults: T?) -> Store<T>,
-- 단위 3. deps는 State/Source/Ref(`H-70`), `fn`엔 안 넘어간다. -- 단위 3. deps는 State/Source/Ref(`H-70`), `fn`엔 안 넘어간다.
Effect: (fn: EffectFn, ...any) -> EffectHandle, Effect: (fn: EffectFn, ...any) -> EffectHandle,
-- 단위 4.
Blocker: () -> Blocker,
-- 생명주기 4종 — quad-base는 인터페이스(에러 스텁)만, 백엔드가 주입 -- 생명주기 4종 — quad-base는 인터페이스(에러 스텁)만, 백엔드가 주입
-- (`.claude/base/lifecycle-pattern.md`). `canBound(v) == not canExecute(v)`. -- (`.claude/base/lifecycle-pattern.md`). `canBound(v) == not canExecute(v)`.