From c932206ebf269873566aaeb6cd395abd047fc303 Mon Sep 17 00:00:00 2001 From: qwreey-agent-selene Date: Sat, 29 Aug 2026 02:04:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(m2):=20=EB=8B=A8=EC=9C=84=204=20=E2=80=94?= =?UTF-8?q?=20GateNode(state:Gate)=20/=20Blocker=20+=20Apply=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=ED=83=80=EC=9E=85(=EA=B5=90?= =?UTF-8?q?=EC=A7=91=ED=95=A9=20=EC=98=A4=EB=B2=84=EB=A1=9C=EB=93=9C,=20H-?= =?UTF-8?q?179)=20+=20spec=202=EA=B0=9C;=20M2=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EC=A0=84=EB=B6=80=20[x]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/base/blocker-plan.md | 2 +- .claude/base/source-state-plan.md | 2 +- .claude/base/typing-limits.md | 6 + .claude/luau-test/README.md | 3 +- .claude/luau-test/STATUS.md | 7 +- .../05-store-state-diamond-propagation.luau | 0 ...26-type-apply-object-factory-overload.luau | 24 ++ .../pre-implementation-handtrace-round11.md | 39 +++ .claude/session-summary.md | 2 + .../2026-08-28-03-m2-unit1-common-base.md | 10 +- .claude/todos.md | 8 +- ROADMAP.md | 8 +- quad-base/src/Blocker.luau | 111 ++++++++ quad-base/src/State.luau | 78 +++++- quad-base/src/init.luau | 2 + quad-base/test/spec.blocker.luau | 155 +++++++++++ quad-base/test/spec.gate.luau | 246 ++++++++++++++++++ quad-base/test/spec.state.luau | 2 +- quad-types/src/init.luau | 28 +- 19 files changed, 711 insertions(+), 22 deletions(-) rename .claude/luau-test/{rewrite-required => done}/05-store-state-diamond-propagation.luau (100%) create mode 100644 .claude/luau-test/done/26-type-apply-object-factory-overload.luau create mode 100644 quad-base/src/Blocker.luau create mode 100644 quad-base/test/spec.blocker.luau create mode 100644 quad-base/test/spec.gate.luau diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index 1550dc6..b55074e 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -169,7 +169,7 @@ gated state의 동작: ## 사용 예시 -`state1`/`state2` 각각이 아니라 **결합된 결과(`state3`) 하나에만** `:Block`을 +`state1`/`state2` 각각이 아니라 **결합된 결과(`state3`) 하나에만** `:Apply(blocker)`(**[2026-08-29]** 옛 `:Block`)를 건다: ```lua diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index 08d7a0e..33f3ac3 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -481,7 +481,7 @@ gc되긴 하지만.)"* | 하류 State → 상류 State/Source (`_hold`) | **강함** | | 상류 → 하류 (구독자 집합) | weak-키 | -- **모든 파생 노드**(`:With`/`:Compute`/`:Gate`/`:Block`)가 자기 상류를 +- **모든 파생 노드**(`:With`/`:Compute`/`:Gate` — `state:Apply(blocker)`도 `:Gate`다, **[2026-08-29]** 옛 `:Block` 표기 정리)가 자기 상류를 `_hold`에 강하게 담는다 — `:Compute`처럼 클로저가 **우연히** 캡처하는 것에 기대지 않는다(`:With`의 pass-through 노드엔 그 우연이 없다). - **⭐ [2026-08-26 보강, 8라운드 `H-110`] 말단 핸들도 마찬가지다.** diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index 703db64..61eb13c 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -168,6 +168,12 @@ export type State = StateData & { 손으로 쓰는 타입 선언이 하나 늘 뿐입니다. (한때 검토했던 "T별로 구워서 인라이닝"은 채택 안 함 — 0번 대전제 위반이고, 제네릭을 없애버려서 나중에 Luau가 고쳐져도 수혜를 못 받음.) +- **[2026-08-29 M2 단위 4 실측] `:Apply`의 파라미터는 교집합 오버로드로 선언한다** — + `((self, fn: (State) -> U) -> U) & ((self, obj: { __apply: (any, any) -> any }) -> any)`. + 유니온 하나(`((State) -> U) | { __apply: … -> U }`)로 두면 `Blocker`처럼 필드가 + 더 있는 객체가 제네릭 `U` 자리에서 너비 서브타이핑을 못 받아 `state:Apply(blocker)`가 + strict에서 막힌다(인덱서 `[string]: any`로 열어도 같다 — `luau-test/done/26-*`). + 객체 쪽 반환이 `any`라 결과는 명시 주석(①과 같은 관례). `round11.md` `H-179`. - **캐비엇**: 콜백이 받는 `s`는 `StateData`라 `Compute`/`With`가 없습니다. 콜백 안에서 다시 `s:Compute(...)`를 부르는 자리 (`:Apply`의 factory가 대표적)는 이 방식으로 못 풀고 diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 20d67c6..11313a0 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -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 | | `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>` **정상 동작**, 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 | | `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`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `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` 선언으로 바꿔 쓰기만 하면 된다 | | `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`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<>`) 요구도 추가로 실측 확인 | `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 실측) | ## 공통 유틸리티 diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index b45c601..8d759d9 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -46,9 +46,9 @@ | 폴더 | 뜻 | 개수 | 누가 처리 | |---|---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | -| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 9 | 에이전트 | +| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 8 | 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | -| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — | +| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — | **⚠️ [2026-08-25 신설] 타입 스파이크는 `./scripts/test.sh`가 하는 리링크를 먼저 거쳐야 한다.** `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`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 | -| `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` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | | `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) | @@ -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.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`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `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` 흐름, 무한재귀 없이 종료 | diff --git a/.claude/luau-test/rewrite-required/05-store-state-diamond-propagation.luau b/.claude/luau-test/done/05-store-state-diamond-propagation.luau similarity index 100% rename from .claude/luau-test/rewrite-required/05-store-state-diamond-propagation.luau rename to .claude/luau-test/done/05-store-state-diamond-propagation.luau diff --git a/.claude/luau-test/done/26-type-apply-object-factory-overload.luau b/.claude/luau-test/done/26-type-apply-object-factory-overload.luau new file mode 100644 index 0000000..a211405 --- /dev/null +++ b/.claude/luau-test/done/26-type-apply-object-factory-overload.luau @@ -0,0 +1,24 @@ +--!strict +-- [2026-08-29 M2 단위 4] `state:Apply(factory)`의 파라미터 타입 표기 실측 — 결론: 교집합 오버로드(N/O). +-- 유니온 하나(`((State) -> U) | { __apply: ... -> U }`)는 `Blocker`처럼 필드가 더 있는 객체를 제네릭 U 자리에서 +-- 받지 못한다(너비 서브타이핑 실패, 인덱서 `[string]: any`로도 안 됨). 원문 스파이크 4개 중 통과한 마지막 것. +-- 소스: `qa-request/pre-implementation-handtrace-round11.md` `H-179`. 기대: 진단 2건(마지막 두 줄 음성 대조군)만. +export type StateData = { Get: (self: StateData) -> T } +-- N: 교집합 오버로드 — 함수 팩토리는 제네릭 U, 객체 팩토리는 비제네릭 any 반환 +export type StateN = StateData & { + Apply: ((self: StateData, factory: (StateN) -> U) -> U) & ((self: StateData, 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 +local n1: StateN = n:Apply(b) +local n3: number = n:Apply(function(s: StateN): number return s:Get() end) +local nneg: string = n:Apply(function(s: StateN): number return s:Get() end) +-- O: 순서 반대(객체 오버로드 먼저) +export type StateO = StateData & { + Apply: ((self: StateData, factory: { __apply: (self: any, state: any) -> any }) -> any) & ((self: StateData, factory: (StateO) -> U) -> U), +} +local o = (nil :: any) :: StateO +local o1: StateO = o:Apply(b) +local o3: number = o:Apply(function(s: StateO): number return s:Get() end) +local oneg: string = o:Apply(function(s: StateO): number return s:Get() end) diff --git a/.claude/qa-request/pre-implementation-handtrace-round11.md b/.claude/qa-request/pre-implementation-handtrace-round11.md index 9410dd4..0195018 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round11.md +++ b/.claude/qa-request/pre-implementation-handtrace-round11.md @@ -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-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-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-175` | ① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬**일 때만(컴파일러 `shouldShareClosure`). 함수 인자·지역을 잡는 클로저(단위 3 `Effect`의 콜백 모양)는 정상 GC됨을 실측 | ✅ §5·`spec.ref` 주석 좁힘 | @@ -169,6 +171,28 @@ ### 단위 3 — `Observer` → `Effect` (2026-08-29) +### 단위 4 — `GateNode` → `Blocker` (2026-08-29) + +### `H-179` 🟡 — `:Apply`의 파라미터 타입 표기 (①) + +- **어디서**: `quad-types/src/init.luau` `State.Apply` / `base/source-state-plan.md` + "`state:Apply(factory)`"(`H-94`: *"함수 또는 그 필드를 가진 객체를 받고 반환 `U`만 + 열어둔다"*) / `base/typing-limits.md` §1②. +- **무엇이**: 단위 2가 `((State) -> U) | { __apply: (self, State) -> 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` 🟢 — 사적 필드의 `_` 접두 (①) - **어디서**: `effect-plan.md` 생성자 의사코드의 `d.valueEpochMap` / `state-epoch-plan.md` §4의 @@ -274,6 +298,21 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복 - 테스트 작성 함정: `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`는 그 파일 자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`. diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 64d3d1c..2b89b99 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -2035,3 +2035,5 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs **2026-08-29 새벽(컨테이너 이사 뒤)**: 단위 2 감사 4·5라운드 반영(`H-177` 포함), **단위 3 구현** — `Observer`(레지스트리 소유, 네 진입점)/`Effect`(`rawRerun`·홀드·cleanup 세 자리· 네 진입점 자기 본문)/`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]`. diff --git a/.claude/session/2026-08-28-03-m2-unit1-common-base.md b/.claude/session/2026-08-28-03-m2-unit1-common-base.md index c86aa93..7e62f95 100644 --- a/.claude/session/2026-08-28-03-m2-unit1-common-base.md +++ b/.claude/session/2026-08-28-03-m2-unit1-common-base.md @@ -93,7 +93,13 @@ `Observer`·`EffectHandle`·`Quad.Effect`. spec.observer 8절·spec.effect 9절 ALL PASS, analyze 0. 발견 `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. -그다음 단위 4(`GateNode` → `Blocker` + 탑레벨 마무리). +단위 3·4 끝 절차(감사 루프 병렬 2 → `/code-review high` → 탐사자 → §4), 그 뒤 M2 전체 +마무리 보고. M3은 이 자율 구간의 범위 밖(규약 §1은 M2 단위 넷까지). diff --git a/.claude/todos.md b/.claude/todos.md index a693106..3f1f60b 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -9,10 +9,10 @@ `qa-request/pre-implementation-handtrace-round11-brief.md`(세 갈래 분류 / 단위 넷 / 관여 시점), 발견과 배치 문항은 `-round11.md`(§4 표가 사용자가 읽을 유일한 자리). **진행 상태의 소스는 `ROADMAP.md` M2 체크박스**, 여기서 - 세지 않는다. **[2026-08-28 저녁 기준]** 단위 1 완료(배치 회신까지 반영), 단위 2 - (`EpochMap`/`State`/`Source`/`Store`) 구현·감사 3라운드까지 완료 — **재개 지점은 - 단위 2 끝 절차의 감사 4라운드부터**(그다음 `/code-review high` → 탐사자), 소스는 - `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절. + 세지 않는다. **[2026-08-29 새벽 기준]** 단위 1·2 완료(단위 2 감사 6라운드 수렴), + 단위 3(`Observer`/`Effect`) 구현 완료, 단위 4(`GateNode`/`Blocker`) 구현 중 — + 진행 원문은 `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절. 밤샘 자율 + 구간(사용자 허용, 병렬 2)이라 이 줄은 자주 낡는다 — `ROADMAP.md` 체크박스가 소스. 아래는 착수 전(2026-08-26) 서술: **[2026-08-26] 8라운드까지 전부 처리 완료 — M2 착수 게이트가 0이다.** diff --git a/ROADMAP.md b/ROADMAP.md index d36566c..df6b1b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -598,7 +598,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `archive/canexecute-inst-arg-reversed.md`. 부수 효과로 **바인딩이 죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를 통과** (살아있는 바인딩만 막는 게 의도, 안 바뀜) -- [ ] **`state:Gate(setup)` + `GateNode`** (**[2026-08-24]** 위 `EpochMap`과 같이 되돌아옴) — +- [x] **[2026-08-29 완료 — 단위 4]** **`state:Gate(setup)` + `GateNode`** (**[2026-08-24]** 위 `EpochMap`과 같이 되돌아옴) — emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드 (`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦). **[2026-08-28 10라운드 `H-152`] 조립 첫 줄은 `StateBrand:register(node)`** — @@ -620,7 +620,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 그리고 **수신 시점 판정은 `emitEpochMap:Peek`**을 쓴다 — `:Update`는 덮으므로 "유보 중엔 아직 안 던졌다"는 맵의 뜻과 양립하지 않는다. 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 핸들 보관 세 자리**: (1) **weak-키 해시맵 셋**(`__mode = "k"`) — 값-weak 배열이면 구멍에서 @@ -634,7 +634,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy 조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch 쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 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`에 **이 마일스톤이 얹는 탑레벨 값 전부** 추가 — `Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형, 2026-08-27 `H-128`) / @@ -665,7 +665,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 되면 동종 타입 dep 1개로 한정 — 실측 결과 채택된 건 이 대안이 아니라 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`는 각각 체크박스가 있는데 이 셋만 빠져 있었다 - [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-25 신설, `H-81`; 2026-08-26 자리 정정 `H-122`]** diff --git a/quad-base/src/Blocker.luau b/quad-base/src/Blocker.luau new file mode 100644 index 0000000..cf17425 --- /dev/null +++ b/quad-base/src/Blocker.luau @@ -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 diff --git a/quad-base/src/State.luau b/quad-base/src/State.luau index 8330d7c..c772721 100644 --- a/quad-base/src/State.luau +++ b/quad-base/src/State.luau @@ -35,6 +35,12 @@ advances the value only — never notifies). - `.claude/base/modifier-plan.md` 7번: a `:Compute` result that is a 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") @@ -80,10 +86,10 @@ local function createImpl(module: any) Impl._emitDown = emitDown -- ── 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 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 local self = setmetatable({ @@ -95,8 +101,8 @@ local function createImpl(module: any) _cacheTargetCount = 0, _cacheCurrCount = nil, -- never computed yet → always differs _cache = nil, - }, Impl) - StateBrand:register(self) + }, mt or Impl) + StateBrand:register(self) -- `H-152`: a GateNode is a State too — first line of its assembly -- §4 seeding: valueEpochMap takes every upstream epoch, live. for _, dep in ipairs(deps) do dep._subs[self] = true @@ -180,6 +186,70 @@ local function createImpl(module: any) return newNode(deps, fn) -- one node: edges only, no combining node 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 (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 -- (`source-state-plan.md` "`state:Observer(fn)`"). Body lives in `Observer.luau`. function Impl.Observer(self: any, fn: any?): any diff --git a/quad-base/src/init.luau b/quad-base/src/init.luau index b8a9e0f..670fc44 100644 --- a/quad-base/src/init.luau +++ b/quad-base/src/init.luau @@ -25,6 +25,7 @@ local InitSource = require("@self/Source") local InitStore = require("@self/Store") local Observer = require("@self/Observer") local Effect = require("@self/Effect") +local Blocker = require("@self/Blocker") type Quad = QuadTypes.Quad @@ -41,6 +42,7 @@ local function New(): Quad Relate = Relate, Void = Void, Ref = Ref, + Blocker = Blocker, -- 단위 4 — 게이트 정책 잎 모듈(인스턴스 간 공유) isEpoch = Brand.isEpoch, isSource = Brand.isSource, isState = Brand.isState, diff --git a/quad-base/test/spec.blocker.luau b/quad-base/test/spec.blocker.luau new file mode 100644 index 0000000..3bf5a21 --- /dev/null +++ b/quad-base/test/spec.blocker.luau @@ -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 = QuadTypes.State +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 = 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 = 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 = 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 = s:Apply(b) + local gt: State = 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 = 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 = 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 = 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 ===") diff --git a/quad-base/test/spec.gate.luau b/quad-base/test/spec.gate.luau new file mode 100644 index 0000000..1330bf4 --- /dev/null +++ b/quad-base/test/spec.gate.luau @@ -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 = QuadTypes.State +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 = 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 = 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 = 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 = 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 = s:Compute(function(x): number + return x:Get() + end) + local b: State = s:Compute(function(x): number + return x:Get() + end) + local w: State = a:With(b) + local policyRuns = 0 + local g: State = 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 = 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 = 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 = s:With(t):Gate(outerCtl.setup) + local inner: State = 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 = s:Gate(ctl.setup) + local runs = 0 + local d: State = 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 = 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 ===") diff --git a/quad-base/test/spec.state.luau b/quad-base/test/spec.state.luau index 635aad1..b8788ef 100644 --- a/quad-base/test/spec.state.luau +++ b/quad-base/test/spec.state.luau @@ -258,7 +258,7 @@ do self.calls += 1 return st 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)") print("PASS") end diff --git a/quad-types/src/init.luau b/quad-types/src/init.luau index 1744ef5..2d382c2 100644 --- a/quad-types/src/init.luau +++ b/quad-types/src/init.luau @@ -80,6 +80,24 @@ export type 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)를 돌려준다 — 호출부가 결과 타입을 명시 + -- (`State.Apply`의 객체 오버로드 주석 참고). + __apply: (self: any, state: any) -> any, +} + export type State = StateData & { -- deps는 `...any`다 — 타입팩 `D...`로 위치 인자를 좁히는 형태는 strict에서 콜백 dep -- 추론이 `{read Get: ...}`로 뒤틀려 정상 호출까지 막힌다(M2 단위 2 실측, 스파이크 15가 @@ -87,9 +105,15 @@ export type State = StateData & { Compute: (self: StateData, fn: (self: StateData, previous: U?, ...any) -> U, ...any) -> State, With: (self: StateData, ...any) -> State, -- 애플리커티브 팩토리는 함수이거나 메소드형 `__apply`를 가진 객체(`H-94`/`H-158`). - Apply: (self: StateData, factory: ((State) -> U) | { __apply: (self: any, state: State) -> U }) -> U, + -- **교집합 오버로드**로 선언한다(M2 단위 4 실측, `luau-test/done/26-*`): 유니온 하나로 + -- 두면 `Blocker`처럼 필드가 더 있는 객체가 제네릭 `U` 자리에서 너비 서브타이핑을 못 + -- 받아 `state:Apply(blocker)`가 strict에서 막힌다. 객체 쪽은 `any`를 돌려주므로 결과는 + -- `local g: State = s:Apply(b)`로 명시(§1①의 파생 State 명시 주석 관례와 같다). + Apply: ((self: StateData, factory: (State) -> U) -> U) & ((self: StateData, factory: { __apply: (self: any, state: any) -> any }) -> any), -- 등록 즉시 1회 실행. 무인자면 "항상 관측" 유틸. Observer: (self: StateData, fn: ObserverFn?) -> Observer, + -- 전파 경로에 GateNode 하나(값은 안 가린다, 통지만 유보). 같은 T. + Gate: (self: StateData, setup: GateSetup) -> State, } -- `Source`는 `State`를 구조적으로 만족하고 동시에 `Epoch`다(다중 태깅). export type Source = State & { @@ -140,6 +164,8 @@ export type Quad = { Store: (defaults: T?) -> Store, -- 단위 3. deps는 State/Source/Ref(`H-70`), `fn`엔 안 넘어간다. Effect: (fn: EffectFn, ...any) -> EffectHandle, + -- 단위 4. + Blocker: () -> Blocker, -- 생명주기 4종 — quad-base는 인터페이스(에러 스텁)만, 백엔드가 주입 -- (`.claude/base/lifecycle-pattern.md`). `canBound(v) == not canExecute(v)`.