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:
parent
a8731d7a1a
commit
c932206ebf
19 changed files with 711 additions and 22 deletions
|
|
@ -169,7 +169,7 @@ gated state의 동작:
|
||||||
|
|
||||||
## 사용 예시
|
## 사용 예시
|
||||||
|
|
||||||
`state1`/`state2` 각각이 아니라 **결합된 결과(`state3`) 하나에만** `:Block`을
|
`state1`/`state2` 각각이 아니라 **결합된 결과(`state3`) 하나에만** `:Apply(blocker)`(**[2026-08-29]** 옛 `:Block`)를
|
||||||
건다:
|
건다:
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
|
|
|
||||||
|
|
@ -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`] 말단 핸들도 마찬가지다.**
|
||||||
|
|
|
||||||
|
|
@ -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가 대표적)는 이 방식으로 못 풀고
|
||||||
|
|
|
||||||
|
|
@ -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 실측) |
|
||||||
|
|
||||||
## 공통 유틸리티
|
## 공통 유틸리티
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` 흐름, 무한재귀 없이 종료 |
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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/...`.
|
||||||
|
|
|
||||||
|
|
@ -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]`.
|
||||||
|
|
|
||||||
|
|
@ -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 단위 넷까지).
|
||||||
|
|
|
||||||
|
|
@ -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이다.**
|
||||||
|
|
|
||||||
|
|
@ -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
111
quad-base/src/Blocker.luau
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
155
quad-base/test/spec.blocker.luau
Normal file
155
quad-base/test/spec.blocker.luau
Normal 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 ===")
|
||||||
246
quad-base/test/spec.gate.luau
Normal file
246
quad-base/test/spec.gate.luau
Normal 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 ===")
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)`.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue