From 1e070e3f1f3967b3239ff51bdb8e4069f2880f32 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 28 Aug 2026 20:33:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(m2):=20=EB=8B=A8=EC=9C=84=202=20=E2=80=94?= =?UTF-8?q?=20EpochMap=20/=20State(Init=20=ED=8C=A9=ED=86=A0=EB=A6=AC,=20H?= =?UTF-8?q?-174)=20/=20Source=20/=20Store=20+=20quad-types=20=EC=B5=9C?= =?UTF-8?q?=EC=A2=85=ED=98=95=20=ED=83=80=EC=9E=85=20+=20spec=204=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EpochMap.luau: Update/Peek/Refresh/Sync/TrackFrom, EpochSet은 집합, 키 weak (state-epoch-plan §3). - State.luau: InitState(module) → 인스턴스별 임플(implFor로 Source/Store에 전달). _emitDown은 스냅샷 후 sub:_receive(from)(H-163), _receive 규칙 1~3, 시딩 Sync/TrackFrom, 카운터 쌍(H-85), Get의 Refresh 순회(값만), fn(self=리시버 lazy 핸들, previous?, ...deps), With pass-through, Apply(fn | __apply 객체, H-158), _hold 강참조. Compute 결과 isModifier 가드, dep isState 검증. - Source.luau: Set 동일값도 emit(H-68)/Emit/isModifier 가드, SourceBrand+EpochBrand. - Store.luau: 그림자=store 자신, defaults isSource 화이트리스트 + RESERVED 가드(H-122/H-153), Of(모듈의 Source를 호출 시점에)/Names. - quad-types: StateData/State/Source/Store 타입(ty11 최종형), export type function CheckReservedKeys, Quad에 Source/Store. Compute deps는 ...any — 타입팩 D...는 strict에서 기각(H-176, 스파이크 15 닫힘). - spec.{epochmap,source,state,store}.luau, spec.init에 Source/Store. ROADMAP 단위 2 체크박스, round11 §5 단위 2 확인 목록, 세션 원문·요약. Co-authored-by: qwreey --- .claude/base/source-state-plan.md | 7 + .../pre-implementation-handtrace-round11.md | 35 ++ .claude/session-summary.md | 4 +- .../2026-08-28-03-m2-unit1-common-base.md | 14 +- ROADMAP.md | 34 +- quad-base/src/EpochMap.luau | 127 +++++++ quad-base/src/Source.luau | 90 +++++ quad-base/src/State.luau | 202 +++++++++++ quad-base/src/Store.luau | 87 +++++ quad-base/src/init.luau | 8 + quad-base/test/spec.epochmap.luau | 125 +++++++ quad-base/test/spec.init.luau | 2 + quad-base/test/spec.source.luau | 101 ++++++ quad-base/test/spec.state.luau | 326 ++++++++++++++++++ quad-base/test/spec.store.luau | 123 +++++++ quad-types/src/init.luau | 52 +++ 16 files changed, 1318 insertions(+), 19 deletions(-) create mode 100644 quad-base/src/EpochMap.luau create mode 100644 quad-base/src/Source.luau create mode 100644 quad-base/src/State.luau create mode 100644 quad-base/src/Store.luau create mode 100644 quad-base/test/spec.epochmap.luau create mode 100644 quad-base/test/spec.source.luau create mode 100644 quad-base/test/spec.state.luau create mode 100644 quad-base/test/spec.store.luau diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index 15f2d66..8497804 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -836,6 +836,13 @@ deps만 받고 싶어도 `previous`가 2번째 자리를 차지하므로, 그 비워둬야 함 — deps만 쓰는 흔한 케이스가 약간 불편해지지만, Luau 문법 제약상 다른 선택지가 없음(대안은 애초에 이 확장 자체를 안 하는 것뿐). +**⭐ [2026-08-28 실측, M2 단위 2 `H-176`] (B)는 안 된다** — `Compute: (self, fn: +(self, U?, D...) -> U, D...) -> State`로 선언하면 `luau-analyze --!strict`가 trailing dep의 +콜백 파라미터를 `{ read Get: (t1) -> (number, ...unknown) }`로 뒤틀어 **정상 호출까지** +`Expected … but got …`로 막는다(`quad-base/test/spec.state.luau` 3·7·9에서 재현 뒤 철회). +확정 선언은 **deps 자리 `...any`**, 콜백 안에서 dep 파라미터에 `dep: StateData` 주석 — +런타임 계약(위치·순서·lazy)은 그대로다. 스파이크 `15`는 이 결과로 닫힌다 +(`luau-test/STATUS.md`). 아래는 실측 전 서술: **실측 필요 — `luau-test`의 `15-type-compute-trailing-deps-typepack.luau` 신규(ROADMAP.md M2 반영).** 순서 문제 자체는 위 정정으로 구조적으로 풀렸으므로, 스파이크가 실제로 확인할 진짜 불확실성은 (B) 하나로 좁혀짐 — diff --git a/.claude/qa-request/pre-implementation-handtrace-round11.md b/.claude/qa-request/pre-implementation-handtrace-round11.md index 5034673..fec6d9e 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round11.md +++ b/.claude/qa-request/pre-implementation-handtrace-round11.md @@ -24,6 +24,7 @@ | `H-172` | ① | 1 | 🟡 | mock `Destroy`가 자손을 안 죽이고(조상 파괴 계약 검증 불가), `Parent` 변경 시그널이 연결 해제 뒤라 관측 불가, 재귀 Destroy 무한 루프 | ✅ mock: Destroying → Parent nil → 자손 Destroy → 연결 해제, 이중 Destroy no-op(`spec.lifetime` 6c) | | `H-173` | ① | 1 | 🟢 | `ROADMAP.md` M7 체크박스·`tween-plan.md` 352가 `isTween`/`TweenBrand`를 `Tween.luau`에 둔다고 아직 서술(감사 3라운드가 두 곳만 고침) | ✅ 반영 | | `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-175` | ① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬**일 때만(컴파일러 `shouldShareClosure`). 함수 인자·지역을 잡는 클로저(단위 3 `Effect`의 콜백 모양)는 정상 GC됨을 실측 | ✅ §5·`spec.ref` 주석 좁힘 | ## 상세 @@ -140,6 +141,19 @@ 등"*이 방향만 준다. 어느 모양이든 **새 조립 메커니즘**이라 §4. - **갈래**: §4. 단위 1 코드는 어느 선택지에서도 그대로다(③ 아님). +### 단위 2 — `EpochMap` → `Source`/`State`/`Store` (2026-08-28) + +### `H-176` 🟡 — 타입팩 deps 선언은 strict에서 안 산다 (①) + +- **어디서**: `quad-types/src/init.luau` `State.Compute` / `base/source-state-plan.md` "trailing + deps를 `fn`에 lazy positional 인자로도 노출"의 "(B) 이형 다중 deps를 제네릭 팩으로". +- **무엇이**: `(self, fn: (self, U?, D...) -> U, D...)`로 선언하자 `spec.state.luau`의 + 정상 호출 셋(3·7·9)이 *"Expected `{ read Get: (t1) -> (number, ...unknown) }` but got + `Source`"*로 막혔다 — 팩이 콜백 파라미터 쪽으로 역추론되며 `Get`을 read-only + `...unknown` 반환으로 뒤튼다. 문서가 "실측 필요"로 남겨둔 바로 그 (B)다. +- **처리**: deps 자리 `...any`, 콜백 안에서 `dep: StateData` 주석(콜백 파라미터 주석 관례 + 그대로). 런타임 계약 무변경. 문서에 실측 기록, 스파이크 `15` 닫힘. + ### `H-175` 🟢 — 클로저 캐시 규칙의 범위 (①) - **어디서**: 이 파일 §5 "툴링 사실 둘"의 둘째 항, `spec.ref.luau` 7번 주석. @@ -193,6 +207,27 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복 - `brand-plan.md` 합성 술어(`isState = isSource or StateBrand`, `isRef = isPreRef or isPostRef or RefBrand`)와 weak-key 멤버십 — 성립. +**단위 2 (메인 세션, 2026-08-28)**: +- `EpochMap` 6 연산 ↔ `state-epoch-plan.md` §3 일치(`spec.epochmap`). `Update`의 "한 번 다름을 + 찾으면 나머지는 쓰기만" 최적화 포함. 키 weak 실측. +- `State:_receive` 규칙 1~3, 시딩(`Sync`/`TrackFrom` 분기), 카운터 쌍(`curr = nil` 시작, 재계산 + 도중 무효화·`fn` 예외 케이스), `Get`의 `Refresh` 순회(값만, 통지 없음), 다이아몬드 접힘 — + 전부 `spec.state`가 고정. `_hold` 불변식(하류→상류 강, 상류→하류 weak) GC 실측 통과 — + `luau-test/STATUS.md`의 "만들어야 할 스파이크"(중간 State GC)는 이걸로 대신한다. +- `Source:Set` 동일값 갱신(`H-68`), `Emit`, `isModifier` 가드 셋(생성자/`Set`/`Compute` 캐싱). +- `Store`: `H-122`/`H-153`/`H-83` 전부 `spec.store`. `Names()`는 `pairs(self)` — 메소드가 + `__index`라 안 센다는 `H-153` 전제가 실측으로 성립. +- **조립 세부**(`H-174`의 구현, 새 표면 아님): `State.luau`는 `Init(module)`이 인스턴스별 임플을 + 만들고 `implFor(module)`로 `Source`/`Store` Init에 건넨다(`RunInit` 순서 의존 — 어기면 + `implFor`가 level 1 error). `Source`의 `__index` 체인이 그 임플을 가리켜 `With`/`Compute`/ + `Apply`가 위임된다. `Ref`/`Void`/`Relate`/`Brand`만 인스턴스 간 공유 잎. +- **입력 검증 하나 추가**(`architecture.md` error 계약의 예 *"dep #3 is not a State/Source/Ref"* + 그대로): `newNode`가 dep이 `isState`가 아니면 `error(…, 3)` — 없으면 `dep._subs` nil 인덱스로 + quad 내부 줄에서 죽는다. 새 메커니즘이 아니라 계약이 이미 요구하는 자리라 ①로 둔다. +- `Apply`의 팩토리 파라미터가 유니온(함수 | `__apply` 객체)이라 **콜백 파라미터 무주석 + 추론이 안 된다** — `typing-limits.md` §1②가 이미 캐비엇으로 적어둔 자리(`:Apply`의 factory는 + 주석 필요). 테스트는 주석으로. + **툴링 사실 둘**(설계 아님, 다음 단위가 알아야 함): - `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 81d7b6b..228fc8e 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -2029,4 +2029,6 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs §4로, ① 셋(mock lazy claim·`Destroy` 의미론·M7 `TweenBrand` 잔재) 반영. 탐사자 🔴 `H-174`(생명주기 필드 조립 형태). **같은 날 배치 회신으로 넷 다 확정**: `H-174` 팩토리형 + `module.canExecute` 늦게 읽기 / `H-169` 사용자 안(리비전 바뀌면 파동을 - 놓음, 권고안 기각) / `H-168` `Ref<>()` / `H-170` 즉시 실패만 re-raise. 단위 2 게이트 0. + 놓음, 권고안 기각) / `H-168` `Ref<>()` / `H-170` 즉시 실패만 re-raise. **이어서 단위 2 + 구현** — `EpochMap`/`State`(`Init(module)`+`implFor`)/`Source`/`Store` + `quad-types` + 최종형 타입(`ty11`) + spec 4개, ALL PASS. `H-176` 타입팩 deps 선언 기각(`...any`). 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 dc647ef..1e7432f 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 @@ -62,6 +62,18 @@ 재진입 절·`:Wait` 정정·"제네릭 시그니처" 규칙, `lifecycle-hooks`/`debounce-throttle` 배너, `lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체에 `H-174`. +## 단위 2 — `EpochMap` → `Source`/`State`/`Store` (같은 날, 이어서) + +- 입력: 탐사자 색인으로 `base/` 의사코드 범위를 읽고, 10라운드 참조 구현 + `audit/handtrace-round10-reference-impl/spikes/core10.luau`를 출발점으로(단, `H-163` 이전 + 모양이라 `_emitDown`은 `sub:_receive` 단일 인터페이스로 옮김), 타입은 `ty11_store_final.luau` + 최종형 그대로 `quad-types`에. +- 구현: `EpochMap.luau`(잎) / `State.luau`(`Init(module)` + `implFor`, `H-174`) / `Source.luau` / + `Store.luau` / `init.luau` 배선 / spec 4개(epochmap·source·state·store). `./scripts/test.sh` + ALL PASS, analyze 0. +- 발견 `H-176`(타입팩 deps 선언은 strict에서 기각 → `...any`), §5에 조립 세부·dep 검증·`Apply` + 유니온 추론 캐비엇 기록. + ## 다음 -단위 2(`EpochMap` → `Source`/`State`/`Store`) 착수 — 게이트 없음. +단위 2 끝 절차(감사 루프 → `/code-review high` → 탐사자 → "§4를 보라"). diff --git a/ROADMAP.md b/ROADMAP.md index 3ac0cd3..37539fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -375,14 +375,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 > 금지 — 백엔드가 `New()` 뒤에 덮어쓴다). 의사코드의 `canExecute(self)`는 전부 그 뜻. > 소스는 `base/lifecycle-pattern.md`의 `H-174` 문단. -- [ ] **`EpochMap.luau`** (**[2026-08-24]** 2026-08-22에 디스패치로 옮겼다가 순서 교체로 되돌아옴) — 재사용 가능한 에포크 +- [x] **[2026-08-28 완료 — 단위 2]** **`EpochMap.luau`** (**[2026-08-24]** 2026-08-22에 디스패치로 옮겼다가 순서 교체로 되돌아옴) — 재사용 가능한 에포크 부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합**). `State.luau`에 묻지 말고 별도 모듈로 낼 것 — `GateNode`(아래)와 `State`/`Effect`가 전부 같은 것을 쓴다. `Epoch` 인터페이스 자체(`{ Revision: number }`)와 리비전 갱신(`bit32.bnot(-rev)`)도 여기서 확정 — `base/state-epoch-plan.md` -- [ ] **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의 +- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의 재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`) — `invalid` 플래그가 아니다. **아래 `Source.luau`/`State.luau`가 이걸 전제로 짜여야 하므로 `EpochMap.luau`(위 항목)가 State 본체보다 먼저 @@ -397,11 +397,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 (`luau-test/STATUS.md`). -- [ ] `Source.luau`/`State.luau`/`Store.luau` -- [ ] **[2026-08-28 10라운드 `H-153`]** Store 생성자의 `isSource` 순회와 `store:Of(name)`에 +- [x] **[2026-08-28 완료 — 단위 2]** `Source.luau`/`State.luau`/`Store.luau` — `InitState`/`InitSource`/`InitStore` 팩토리(`H-174`), `test/spec.{epochmap,source,state,store}.luau` +- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-28 10라운드 `H-153`]** Store 생성자의 `isSource` 순회와 `store:Of(name)`에 **예약 이름 런타임 가드**(`error(…, 2)`) — 동적 키는 타입이 못 막는다; 그림자 = store 자신(`base/store-plan.md`). -- [ ] **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<>(name): Source` — +- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<>(name): Source` — 런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은 기각). **콜론 메소드로 확정**했고, 예약 키 (`Of`/`Names`/**`__reservedCheck`** — **[2026-08-26 `/code-review high`]** @@ -412,10 +412,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 돈다 — `base/store-plan.md`의 "타입 추론 문제" 절). `<>`가 값 호출부에서 실제로 `T`를 묶는 것도 실측 확인됨. **[2026-08-25] 옛 이름은 `GetDynamic`이었다 — `Of`가 흡수했다** -- [ ] **[2026-08-25 신설]** `store:Names(): { string }` — 선언된 키 집합 +- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-25 신설]** `store:Names(): { string }` — 선언된 키 집합 열거(그림자 테이블의 키). 그룹 `Attribute(...)`/`attr:NameMap()`이 요구한다(`base/attribute-plan.md`) -- [ ] **[2026-08-25 신설, 2026-08-26 배선 정정 `H-112`]** `CheckReservedKeys` +- [x] **[2026-08-28 완료 — 단위 2]** `quad-types`에 `export type function`으로]** **[2026-08-25 신설, 2026-08-26 배선 정정 `H-112`]** `CheckReservedKeys` 타입 함수 — **`T`가 아니라 `keyof`**(키 싱글톤 유니온)를 받아 예약 키를 검증만 하고, 팬텀 필드 `__reservedCheck`로 격리한다. `error()`가 아니라 `print(...)` + `return types.never`를 써야 한다. @@ -424,11 +424,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `*error-type*`을 품어 **유효한 Store 전부**에 스퓨리어스 타입 함수 에러가 뜬다(실측). 근거·통과 배선은 `base/store-plan.md`. **타입 함수는 이 용도(진단)까지만 쓴다** — `base/typing-limits.md` §0 -- [ ] **[2026-08-25 신설]** **명시적 초기화** — 타입 인자에 `Source`를 +- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-25 신설]** **명시적 초기화** — 타입 인자에 `Source`를 직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index` (없는 키를 그 자리에서 만들어 저장)는 **폐기**. 그래서 `defaults`가 곧 선언 키 집합이고 `Names()`가 성립한다 -- [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅** +- [x] **[2026-08-28 완료 — 단위 2: `State.luau`의 `_emitDown`, 구독자 전부 `sub:_receive(from)`; Observer 쪽 `_receive`는 단위 3]** **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅** (2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제 호출부" 절) — State는 구독자를 **weak-키로만** 담고, 살려두는 책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음 @@ -467,7 +467,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 "`store.key` 레코드 필드 타이핑" 절이 소스. 스파이크 `16`/`21`은 폐기된 접근을 검증한 것이라 재작성 대기 (`luau-test/STATUS.md`) -- [ ] `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar +- [x] **[2026-08-28 완료 — 단위 2]** `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar (2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`" 절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드 자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M2 @@ -477,7 +477,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 걸면 합치는 노드 자체가 안 생겨 감출 비용이 없다. **기각으로 남은 건 `Observer` 하나**이고 근거도 새로 쓰였다("Observer는 리시버 State 하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다") -- [ ] `state:Apply(factory)`(`base/source-state-plan.md` "`state:Apply(factory)`" +- [x] **[2026-08-28 완료 — 단위 2]** `state:Apply(factory)`(`base/source-state-plan.md` "`state:Apply(factory)`" 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로 부르는 순수 설탕, `factory: (State) -> U): U`로 열린 타입. Source도 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함 @@ -572,7 +572,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (`H-7`). 캐치업은 바인드 직후 **`_rerunRequired`면 1회**(`if self._rerunRequired then self:Rerun() end` — **[2026-08-28 `H-151`/`H-159`]** 옛 `_epochs:Refresh()`는 폐기, `_epochs`는 emit 받을 때만 갱신하되 실행 불가 상태의 변경은 홀드 — `H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스 -- [ ] **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로 +- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-24 `H-23`]** State 전파 루프는 구독자 집합을 **배열로 스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서 미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로 누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약 @@ -634,7 +634,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy 조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch 쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구** -- [ ] **[2026-08-28 부분 — 첫 단위분(`Relate`/`Void`/`Ref`/`is*`/생명주기 4종)은 `quad-types` `Quad`에 추가됨, `Source`/`Store`/`Effect`/`Blocker`는 각 단위에서]** **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]** +- [ ] **[2026-08-28 부분 — 단위 1·2분(`Relate`/`Void`/`Ref`/`is*`/생명주기 4종/`Source`/`Store` + `State`·`Source`·`Store` 타입)은 `quad-types` `Quad`에 추가됨, `Effect`/`Blocker`는 각 단위에서]** **[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`) / @@ -652,7 +652,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 이 마일스톤이 그 규칙의 첫 적용 지점**이고, 규칙의 정본은 `base/quad-types-plan.md`의 "`Quad` 타입 — 확정된 표면" 절 (M3의 `H-25` 항목이 같은 규칙을 `Dispatch` 기준으로 서술한다) -- [ ] trailing deps를 `fn`에 lazy positional 인자로도 노출(**⚠️ [2026-08-24 +- [x] **[2026-08-28 단위 2 — 런타임은 완료(`fn(self, previous?, ...deps)`), 타입은 `...any`: 타입팩 `D...`로 좁히는 형태는 strict에서 콜백 dep 추론이 깨져 실측 기각(`round11.md` `H-176`)]** trailing deps를 `fn`에 lazy positional 인자로도 노출(**⚠️ [2026-08-24 `H-14`] 이 항목은 `:Compute` 한정이다** — `Effect`의 `fn`엔 deps가 안 넘어간다) — (`fn(self, previous?, dep1, ..., depN)` — 순서는 Luau 값 레벨 `...`가 파라미터 @@ -663,10 +663,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `luau-test`의 `15-type-compute-trailing-deps-typepack.luau`로 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 되면 동종 타입 dep 1개로 한정) -- [ ] **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Apply(blocker)` / +- [x] **[2026-08-28 단위 2 — `:With`/`Source:Emit()` 완료, `state:Apply(blocker)`는 `Apply`의 `__apply` 경로로 단위 4에서 실제 Blocker와 합류]** **[2026-08-25 신설, `H-84`]** `:With(...)` / `state:Apply(blocker)` / `Source:Emit()` — `:Compute`/`:Apply`/`:Observer`는 각각 체크박스가 있는데 이 셋만 빠져 있었다 -- [ ] **[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`]** `isModifier` 런타임 가드 — 적용 지점이 **전부 이 마일스톤의 코드**다: **`Source(...)` 생성자** / `Source:Set` / State의 `:Compute` 결과 캐싱. 체크박스가 M7에만 있었다. @@ -675,7 +675,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (**[2026-08-26 정밀화]** 동적 키 창구 `store:Of(name)`은 여전히 만든다) — 가드를 `Source` 생성자에 두면 그 둘이 **한 번에 커버**된다 (`base/modifier-plan.md` 7번이 소스) -- [ ] **[2026-08-26 신설, `H-122`]** `Store` 생성자의 `defaults` 런타임 +- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-26 신설, `H-122`]** `Store` 생성자의 `defaults` 런타임 검증 — 값 전량에 `isSource` 화이트리스트, 거짓이면 `error(..., 2)` (영어 메시지). 타입은 `Source`를 요구하지만 `--!nocheck`/동적 코드가 raw 값을 넘기면 지금 스케치(`table.clone`)는 조용히 받고 첫 diff --git a/quad-base/src/EpochMap.luau b/quad-base/src/EpochMap.luau new file mode 100644 index 0000000..13b36a7 --- /dev/null +++ b/quad-base/src/EpochMap.luau @@ -0,0 +1,127 @@ +--[[ + EpochMap — reusable epoch bookkeeping object. + `.claude/base/state-epoch-plan.md` §3 "`EpochMap` — 컴포지션 가능한 부기 객체", + as-is. `State` composes two of these (`valueEpochMap` / `emitEpochMap`), + `GateNode` and `Effect` use the same object (§6) — so it lives in its own + leaf module, not inside `State.luau`. + + EpochMap:Update(Epoch | EpochSet) -> boolean -- "does this need to propagate?" + -- read, compare, overwrite; true if any differed + EpochMap:Peek(Epoch | EpochSet) -> boolean -- same comparison, no write (`H-72`) + EpochMap:Refresh() -> boolean -- re-read every key it already tracks (= Update with no args) + EpochMap:Sync(Epoch | EpochSet) -- write only, no read, no return + EpochMap:TrackFrom(other: EpochMap) -- take over other's keys, filled with LIVE revisions + -- (not other's stored values; other is untouched) + + `EpochSet = {[Epoch]: true}` is a SET, not an array — a gate batch is + exactly the swapped-out `withheld` table (`base/gate-plan.md` 4번), and + iterating it with `ipairs` would see zero elements. Single-vs-set is + decided at runtime with `isEpoch` (§5 "런타임 분기는 `isEpoch`로"). + + Keys are weak: when an `Epoch` dies its entry goes with it. Values are + plain numbers, so `relate-plan.md`'s "value must not refer back to the + key" caveat does not apply. +]] + +local Brand = require("./Brand") +local QuadTypes = require("../roblox_packages/quad_types") + +type Epoch = QuadTypes.Epoch +export type EpochSet = { [Epoch]: true } +export type EpochMap = { + Update: (self: EpochMap, from: Epoch | EpochSet) -> boolean, + Peek: (self: EpochMap, from: Epoch | EpochSet) -> boolean, + Refresh: (self: EpochMap) -> boolean, + Sync: (self: EpochMap, from: Epoch | EpochSet) -> (), + TrackFrom: (self: EpochMap, other: EpochMap) -> (), +} + +local isEpoch = Brand.isEpoch + +local WEAK_KEY_MT = { __mode = "k" } + +local EpochMapImpl = {} +EpochMapImpl.__index = EpochMapImpl + +-- Read-compare-write over one epoch. Returns whether it differed. Once a +-- caller already knows the answer is `true` it can keep writing without +-- reading (the "내부 최적화" in §3) — `Update` below does exactly that. +local function updateOne(map: { [Epoch]: number }, epoch: Epoch): boolean + local revision = epoch.Revision + if map[epoch] ~= revision then + map[epoch] = revision + return true + end + return false +end + +function EpochMapImpl.Update(self: EpochMap, from: Epoch | EpochSet): boolean + local map = (self :: any)._map + if isEpoch(from) then + return updateOne(map, from :: Epoch) + end + local changed = false + for epoch in pairs(from :: EpochSet) do + if changed then + map[epoch] = epoch.Revision -- answer already fixed: write only + else + changed = updateOne(map, epoch) + end + end + return changed +end + +function EpochMapImpl.Peek(self: EpochMap, from: Epoch | EpochSet): boolean + local map = (self :: any)._map + if isEpoch(from) then + local epoch = from :: Epoch + return map[epoch] ~= epoch.Revision + end + for epoch in pairs(from :: EpochSet) do + if map[epoch] ~= epoch.Revision then + return true + end + end + return false +end + +function EpochMapImpl.Refresh(self: EpochMap): boolean + local map = (self :: any)._map + local changed = false + for epoch, revision in pairs(map) do + local live = epoch.Revision + if live ~= revision then + changed = true + map[epoch] = live + end + end + return changed +end + +function EpochMapImpl.Sync(self: EpochMap, from: Epoch | EpochSet) + local map = (self :: any)._map + if isEpoch(from) then + local epoch = from :: Epoch + map[epoch] = epoch.Revision + return + end + for epoch in pairs(from :: EpochSet) do + map[epoch] = epoch.Revision + end +end + +function EpochMapImpl.TrackFrom(self: EpochMap, other: EpochMap) + local map = (self :: any)._map + for epoch in pairs((other :: any)._map) do + map[epoch] = epoch.Revision -- live revision, not other's stored one + end +end + +local function EpochMap(): EpochMap + local self = setmetatable({ + _map = setmetatable({}, WEAK_KEY_MT) :: { [Epoch]: number }, + }, EpochMapImpl) + return (self :: any) :: EpochMap +end + +return EpochMap diff --git a/quad-base/src/Source.luau b/quad-base/src/Source.luau new file mode 100644 index 0000000..b683d99 --- /dev/null +++ b/quad-base/src/Source.luau @@ -0,0 +1,90 @@ +--[[ + Source — the writable root value. Structurally satisfies `State` (all + derived methods come from `State.luau`'s impl through `__index`) and is + itself an `Epoch` (`Revision`), so it is registered in BOTH + `SourceBrand` and `EpochBrand` (multi-tagging, `brand-plan.md`) — never in + `StateBrand`: `isState = isSource or StateBrand` is composed in the + predicate, not by double registration. + + Sources, as-is: + - `.claude/base/source-state-plan.md` "State는 쓰기 대상이 아님 — + 확정, Source는 독립 공개 프리미티브로 격상" (`Source(default)`), + "`Source:Set(v)`는 동일값이어도 항상 갱신하고 emit한다" (`H-68`), + "Source 값을 직접 mutate한 뒤 전파 — `:Emit()`" (root only), + "따름정리 — `Store`/`Source`의 `T`는 Modifier가 될 수 없음". + - `.claude/base/modifier-plan.md` 7번: `isModifier` guard at the + constructor and at `:Set` (the constructor also covers `store:Of`). + - `.claude/base/state-epoch-plan.md` §2: `Revision` bump is + `bit32.bnot(-rev)`; `Set`/`Emit` push the Source itself as the emit + payload ("출처" only — no value, no revision). + + Assembly (`H-174`): `InitSource(module)` — needs the per-instance State + impl for its `__index` chain, so `InitState` must have run first. +]] + +local Brand = require("./Brand") +local State = require("./State") +local QuadTypes = require("../roblox_packages/quad_types") + +export type Source = QuadTypes.Source + +local SourceBrand = Brand.SourceBrand +local EpochBrand = Brand.EpochBrand +local isModifier = Brand.isModifier + +local WEAK_KEY_MT = { __mode = "k" } + +local function Init(module: any) + local StateImpl = State.implFor(module) + local emitDown = StateImpl._emitDown + + local Impl = setmetatable({}, { __index = StateImpl }) + Impl.__index = Impl + + function Impl.Get(self: any): any + return self._value + end + + -- `H-68`: same value still bumps the revision and emits — dedup is the + -- downstream's job (EpochMap judgement, gates), and `==` would silently + -- drop in-place table mutations. + function Impl.Set(self: any, value: any): any + if isModifier(value) then + error("Source: cannot Set a Modifier as a Source value", 2) + end + self._value = value + self.Revision = bit32.bnot(-self.Revision) + emitDown(self, self) -- payload = the source epoch itself + return self + end + + -- In-place mutation was done by the caller; only the signal is sent. + -- Root Sources only — derived States have no such concept ("하드 경계"). + function Impl.Emit(self: any): any + self.Revision = bit32.bnot(-self.Revision) + emitDown(self, self) + return self + end + + function Impl._track(self: any, map: any) + map:Sync(self) -- a Source IS an Epoch: it goes into the map directly + end + + local function Source(default: any): any + if isModifier(default) then + error("Source: cannot hold a Modifier as a Source value", 2) + end + local self = setmetatable({ + _value = default, + Revision = 0, + _subs = setmetatable({}, WEAK_KEY_MT), -- weak-key subscribers (downstream) + }, Impl) + SourceBrand:register(self) + EpochBrand:register(self) + return self + end + + module.Source = Source +end + +return Init diff --git a/quad-base/src/State.luau b/quad-base/src/State.luau new file mode 100644 index 0000000..dad9be6 --- /dev/null +++ b/quad-base/src/State.luau @@ -0,0 +1,202 @@ +--[[ + State — derived reactive node (`:Compute` / `:With` / `:Apply`), the + propagation loop, and the epoch-based recompute judgement. + + Assembly (`H-174`, user-confirmed 2026-08-28): `InitState(module)` builds + one implementation table per `quad` instance and keeps `module` in its + closure, so later units (`Observer`, `Gate`) can read lifetime gates as + `module.canExecute(...)` AT FIRE TIME (never captured at init — the + backend overwrites those fields after `New()`). `implFor(module)` hands + the same table to `Source.luau`/`Store.luau` inits. + + There is no runtime `State` constructor — nodes exist only as results of + `:Compute`/`:With` (and `:Gate`, unit 4). `State` is NOT an `Epoch` + (`state-epoch-plan.md` §4): its counters are private recompute + bookkeeping, and upstream tracking goes through `valueEpochMap`. + + Sources, transcribed as-is: + - `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드" + (`_emitDown`: snapshot, then `sub:_receive(from)` — every subscriber + is an `EmitReceive`, `H-163`), "`:With`도 새 State 노드로 확정" + (pass-through node, wider subscription list), "self 인자도 lazy + 핸들로 통일", "`:Compute(fn, ...)`" (trailing deps: one node), + "trailing deps를 `fn`에 lazy positional 인자로도 노출" + (`fn(self, previous?, ...deps)`), "`state:Apply(factory)`" + (`__apply` method form, `H-158`), "`_hold`로 살아남는다" + (downstream → upstream strong, upstream → downstream weak). + - `.claude/base/state-epoch-plan.md` §4: seeding (`emitEpochMap` + empty, `valueEpochMap` filled from every dep — `:Sync(dep)` if the + dep is an `Epoch`, `:TrackFrom(dep.valueEpochMap)` otherwise), + receive rules 1~3, the `cacheTargetCount`/`cacheCurrCount` pair + (`H-85`, `curr = nil` initially), recompute (`d:_track`, then + `cacheCurrCount = gen` only on success), `Get` (`Refresh` walk + advances the value only — never notifies). + - `.claude/base/modifier-plan.md` 7번: a `:Compute` result that is a + Modifier errors before it is cached. +]] + +local Brand = require("./Brand") +local EpochMap = require("./EpochMap") +local QuadTypes = require("../roblox_packages/quad_types") + +export type StateData = QuadTypes.StateData +export type State = QuadTypes.State +type EpochMap = EpochMap.EpochMap + +local StateBrand = Brand.StateBrand +local isEpoch = Brand.isEpoch +local isState = Brand.isState +local isModifier = Brand.isModifier + +local WEAK_KEY_MT = { __mode = "k" } + +-- One implementation per quad instance (`H-174`). +local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any } + +local function createImpl(module: any) + local Impl = {} + Impl.__index = Impl + Impl._module = module -- read lazily by later units, never captured + + -- ── propagation loop ────────────────────────────────────────────── + -- Shared by every node type that has `_subs` (Source nodes too). + local function emitDown(self: any, from: any) + -- H-23: adding a key during `pairs` is undefined, so snapshot first. + -- "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약. + local snap = {} + for sub in pairs(self._subs) do + snap[#snap + 1] = sub + end + for _, sub in ipairs(snap) do + (sub :: any):_receive(from) -- every subscriber is an `EmitReceive` (`H-163`) + end + end + Impl._emitDown = emitDown + + -- ── node creation ───────────────────────────────────────────────── + local function newNode(deps: { any }, fn: 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 + end + end + local self = setmetatable({ + _fn = fn, + _hold = deps, -- strong: downstream → upstream (`_hold` invariant) + _subs = setmetatable({}, WEAK_KEY_MT), -- weak-key: upstream → downstream + _valueEpochMap = EpochMap(), + _emitEpochMap = EpochMap(), -- starts EMPTY on purpose (§4 seeding) + _cacheTargetCount = 0, + _cacheCurrCount = nil, -- never computed yet → always differs + _cache = nil, + }, Impl) + StateBrand:register(self) + -- §4 seeding: valueEpochMap takes every upstream epoch, live. + for _, dep in ipairs(deps) do + dep._subs[self] = true + if isEpoch(dep) then + self._valueEpochMap:Sync(dep) + else + self._valueEpochMap:TrackFrom(dep._valueEpochMap) + end + end + return self + end + Impl._newNode = newNode + + -- ── EmitReceive + epoch bookkeeping ────────────────────────────── + function Impl._track(self: any, map: EpochMap) + map:TrackFrom(self._valueEpochMap) -- a State is not an Epoch: hand over what it tracks + end + + function Impl._invalidate(self: any) + self._cacheTargetCount = bit32.bnot(-self._cacheTargetCount) + end + + -- §4 "emit을 받았을 때": two maps, two booleans, three cases. + function Impl._receive(self: any, from: any) + local valueChanged = self._valueEpochMap:Update(from) + local emitChanged = self._emitEpochMap:Update(from) + if valueChanged then + self:_invalidate() -- rule 1: value is stale + end + if valueChanged or emitChanged then + emitDown(self, from) -- rules 1/2: forward the SAME source; rule 3: swallow + end + end + + function Impl._recompute(self: any) + local gen = self._cacheTargetCount -- snapshot right before fn (`H-85`) + local hold = self._hold + -- `fn(self, previous?, ...deps)` — `self` is the RECEIVER's lazy handle + -- (`hold[1]`), never this result node. + local result = self._fn(hold[1], self._cache, table.unpack(hold, 2)) + if isModifier(result) then + error("State: a Compute function returned a Modifier — State/Source cannot hold Modifiers", 2) + end + self._cache = result + for _, dep in ipairs(hold) do + dep:_track(self._valueEpochMap) -- fresh value is current w.r.t. EVERY upstream + end + self._cacheCurrCount = gen -- only on success: a thrown fn never marks the cache valid + end + + function Impl.Get(self: any): any + if self._cacheCurrCount ~= self._cacheTargetCount then + self:_recompute() + elseif self._valueEpochMap:Refresh() then + -- 순회: catch an emit that never reached us (gate, wave not arrived) — + -- advance the VALUE only; notification waits for the real emit. + self:_invalidate() + self:_recompute() + end + return self._cache + end + + -- ── derived-node methods (shared by Source through `__index`) ───── + local function passThrough(s: any) + return s:Get() + end + + function Impl.With(self: any, ...: any): any + local deps = { self } + for i = 1, select("#", ...) do + deps[#deps + 1] = (select(i, ...)) + end + return newNode(deps, passThrough) -- value is `self`'s, subscriptions are wider + end + + function Impl.Compute(self: any, fn: any, ...: any): any + local deps = { self } + for i = 1, select("#", ...) do + deps[#deps + 1] = (select(i, ...)) + end + return newNode(deps, fn) -- one node: edges only, no combining node + end + + function Impl.Apply(self: any, factory: any): any + if type(factory) == "function" then + return (factory :: any)(self) + end + return (factory :: any):__apply(self) -- method form: `self` there is the factory object (`H-158`) + end + + return Impl +end + +local function Init(module: any) + implByModule[module] = createImpl(module) +end + +local function implFor(module: any) + local impl = implByModule[module] + if impl == nil then + error("State: InitState(module) has not run for this quad instance — RunInit order is broken", 1) + end + return impl +end + +return { + Init = Init, + implFor = implFor, +} diff --git a/quad-base/src/Store.luau b/quad-base/src/Store.luau new file mode 100644 index 0000000..657783b --- /dev/null +++ b/quad-base/src/Store.luau @@ -0,0 +1,87 @@ +--[[ + Store — a named bag of Sources, explicitly initialised. + `.claude/base/store-plan.md` "Store = Source들의 이름 붙은 모음 (명시적 + 초기화)" and "타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 + 확정" (`store:Of<>(name)`), as-is. + + local store = quad.Store<<{ hp: Source }>>({ hp = Source(100) }) + store.hp:Get() -- plain record field → the Source itself + store:Of<>(name) -- the ONLY dynamic-key door; creates on first use + store:Names() -- keys at this moment (declared + created by Of) + + - The shadow table IS the store (`H-153`): `table.clone(defaults or {})` + becomes the object; methods live in `__index`, so `Names()` does not + count them and `store.key` stays a plain field. + - Constructor validation, one pass over `defaults` (`H-122` + `H-153`): + every value must pass `isSource` (whitelist — raw values would be + accepted silently and die at the first `:Get()`), and no key may be a + reserved name. Both are user input → `error(..., 2)`, English. + - `RESERVED` here is the runtime single source of the reserved names; + the type function `CheckReservedKeys` in quad-types carries a copy — + rename both together. `__reservedCheck` is a phantom (type-only) field. + - `isModifier` is NOT checked here — the `Source` constructor does it, + which covers the `Of` path too (`modifier-plan.md` 7번). + - Nothing lets an externally made Source be inserted later; `Of` is the + one lazy spot left, and it is explicit about giving up type safety. + + Assembly (`H-174`): `InitStore(module)` — `Of` creates values through + `module.Source`, read at call time. +]] + +local Brand = require("./Brand") +local QuadTypes = require("../roblox_packages/quad_types") + +export type Store = QuadTypes.Store + +local StoreBrand = Brand.StoreBrand +local isSource = Brand.isSource + +-- Runtime single source of the reserved names (`H-153`). Mirror: quad-types +-- `CheckReservedKeys`. `__reservedCheck` cannot be derived from `__index` +-- because it is a phantom field. +local RESERVED = { Of = true, Names = true, __reservedCheck = true } + +local function Init(module: any) + local Impl = {} + Impl.__index = Impl + + -- Dynamic keys only: the type cannot stop a computed name, so the runtime does. + function Impl.Of(self: any, name: string): any + if RESERVED[name] then + error(`Store: "{name}" is a reserved store key`, 2) + end + local src = rawget(self, name) -- shadow = the store itself; never go through `__index` + if src == nil then + src = module.Source(nil) -- == Source() — read lazily, not captured + self[name] = src + end + return src + end + + function Impl.Names(self: any): { string } + local names = {} + for name in pairs(self) do + names[#names + 1] = name + end + return names + end + + local function Store(defaults: any?): any + local self = table.clone(defaults or {}) -- `or {}`: `Store<<{}>>()` is valid (`H-83`) + for name, value in pairs(self) do + if RESERVED[name :: any] then + error(`Store: "{tostring(name)}" is a reserved store key`, 2) + end + if not isSource(value) then + error(`Store: default for "{tostring(name)}" is not a Source (got {typeof(value)})`, 2) + end + end + setmetatable(self, Impl) + StoreBrand:register(self) + return self + end + + module.Store = Store +end + +return Init diff --git a/quad-base/src/init.luau b/quad-base/src/init.luau index ef0bbed..eab3db7 100644 --- a/quad-base/src/init.luau +++ b/quad-base/src/init.luau @@ -20,6 +20,9 @@ local Void = require("@self/Void") local QuadTypes = require("./roblox_packages/quad_types") local InitDebug = require("@self/Debug") local InitLifetimeHandle = require("@self/LifetimeHandle") +local State = require("@self/State") +local InitSource = require("@self/Source") +local InitStore = require("@self/Store") type Quad = QuadTypes.Quad @@ -67,6 +70,11 @@ local function New(): Quad module:RunInit(InitDebug) module:RunInit(InitLifetimeHandle) -- 생명주기 4종 에러 스텁 — 백엔드가 덮어씀 + -- 반응형 코어(M2 단위 2) — `H-174`: 인스턴스별 팩토리. State 임플이 먼저 있어야 + -- Source가 `__index` 체인을 걸 수 있다(순서 의존, `State.implFor`가 검사). + module:RunInit(State.Init) + module:RunInit(InitSource) + module:RunInit(InitStore) -- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가 return module diff --git a/quad-base/test/spec.epochmap.luau b/quad-base/test/spec.epochmap.luau new file mode 100644 index 0000000..c925d85 --- /dev/null +++ b/quad-base/test/spec.epochmap.luau @@ -0,0 +1,125 @@ +--[[ EpochMap 계약 — `.claude/base/state-epoch-plan.md` §3 "`EpochMap` — 컴포지션 가능한 부기 객체" ]] + +local Quad = require("../src") +local EpochMap = require("../src/EpochMap") +local Brand = require("../src/Brand") + +-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음) +local collectgarbage = (_G :: any).collectgarbage :: () -> () + +local function epoch(rev: number): any + local e = { Revision = rev } + Brand.EpochBrand:register(e) + return e +end + +print("=== 1. Update — 읽고 비교하고 덮는다, 하나라도 달랐으면 true ===") +do + local m = EpochMap() + local a = epoch(1) + assert(m:Update(a) == true, "first sight of an epoch is a change") + assert(m:Update(a) == false, "same revision again is not") + a.Revision = 2 + assert(m:Update(a) == true and m:Update(a) == false, "revision moved → true once, then absorbed") + print("PASS") +end + +print() +print("=== 2. EpochSet — 집합({[Epoch]: true})을 받는다, 배열이 아니다 ===") +do + local m = EpochMap() + local a, b = epoch(1), epoch(1) + assert(m:Update({ [a] = true, [b] = true }) == true, "set: any unseen key → true") + assert(m:Update({ [a] = true, [b] = true }) == false, "set: all absorbed → false") + b.Revision = 5 + assert(m:Update({ [a] = true, [b] = true }) == true, "set: one moved → true") + assert(m:Update(b) == false, "and it was written even though the answer was already fixed") + -- (배열 `{a, b}`는 계약 밖 — 집합이어야 한다. 문서가 경고한 그 함정은 여기서 고정하지 않는다.) + print("PASS") +end + +print() +print("=== 3. Peek — 비교만, 덮지 않음 ===") +do + local m = EpochMap() + local a = epoch(1) + assert(m:Peek(a) == true, "unseen → differs") + assert(m:Peek(a) == true, "still differs: Peek wrote nothing") + m:Update(a) + assert(m:Peek(a) == false, "absorbed by Update") + a.Revision = 2 + assert(m:Peek({ [a] = true }) == true and m:Update(a) == true, "set form too; Update still sees the change") + print("PASS") +end + +print() +print("=== 4. Sync — 읽지 않고 쓰기만, 반환값 없음 ===") +do + local m = EpochMap() + local a = epoch(3) + assert(select("#", m:Sync(a)) == 0, "Sync returns nothing") + assert(m:Update(a) == false, "after Sync the epoch is current") + local b = epoch(1) + m:Sync({ [b] = true }) + assert(m:Update(b) == false, "set form") + print("PASS") +end + +print() +print("=== 5. Refresh — 인자 없는 Update: 추적 중인 키 전부를 라이브로 ===") +do + local m = EpochMap() + local a, b = epoch(1), epoch(1) + m:Sync({ [a] = true, [b] = true }) + assert(m:Refresh() == false, "nothing moved") + b.Revision = 2 + assert(m:Refresh() == true, "b moved") + assert(m:Refresh() == false and m:Update(b) == false, "Refresh wrote the live revision") + assert(EpochMap():Refresh() == false, "empty map: nothing to walk → false") + print("PASS") +end + +print() +print("=== 6. TrackFrom — other의 키를 넘겨받아 라이브 리비전으로, other는 안 건드림 ===") +do + local up = EpochMap() + local a = epoch(1) + up:Sync(a) + a.Revision = 7 -- up의 저장값(1)은 이제 낡았다 + local down = EpochMap() + down:TrackFrom(up) + assert(down:Update(a) == false, "down was filled with the LIVE revision (7), not up's stored 1") + assert(up:Refresh() == true, "up itself was untouched — it still sees the move") + print("PASS") +end + +print() +print("=== 7. 키는 weak — Epoch가 죽으면 항목이 사라짐 ===") +do + local m = EpochMap() + local function seed() + m:Sync(epoch(1)) + end + seed() + collectgarbage() + collectgarbage() + assert(next((m :: any)._map) == nil, "dead epoch's entry is gone") + print("PASS") +end + +print() +print("=== 8. Source/Ref는 Epoch로서 그대로 들어간다 ===") +do + local m = EpochMap() + local src = Quad.Source(0) + local ref = Quad.Ref(0) + assert(m:Update({ [src] = true, [ref] = true } :: any) == true, "both are Epochs") + src:Set(1) + assert(m:Update(src) == true, "Source:Set bumps its revision") + ref:Set(1) + assert(m:Refresh() == true, "Ref:Set too") + print("PASS") +end + +print() +print("=== ALL PASS ===") diff --git a/quad-base/test/spec.init.luau b/quad-base/test/spec.init.luau index a0f655b..500718f 100644 --- a/quad-base/test/spec.init.luau +++ b/quad-base/test/spec.init.luau @@ -14,6 +14,8 @@ print("=== 1. New()마다 M2 첫 단위 탑레벨 값이 실려 있음 ===") do local m: Quad = Quad.New() assert(type(m.Relate) == "function" and type(m.Void) == "function" and type(m.Ref) == "function", "Relate/Void/Ref constructors") + assert(type(m.Source) == "function" and type(m.Store) == "function", "Source/Store constructors (unit 2, per instance)") + assert(m.Source ~= Quad.Source, "Source is an instance-level factory (H-174), not a shared leaf") local predicates: { (any) -> boolean } = { m.isEpoch, m.isSource, m.isState, m.isStore, m.isObserver, m.isEffect, m.isBlocker, m.isModifier, m.isRef, m.isPreRef, m.isPostRef, diff --git a/quad-base/test/spec.source.luau b/quad-base/test/spec.source.luau new file mode 100644 index 0000000..76ebf7f --- /dev/null +++ b/quad-base/test/spec.source.luau @@ -0,0 +1,101 @@ +--[[ + Source 계약 — `.claude/base/source-state-plan.md` "Source는 독립 공개 프리미티브로 격상" / + "`Source:Set(v)`는 동일값이어도 항상 갱신하고 emit한다" / "`:Emit()`" / "따름정리 — Modifier", + `.claude/base/modifier-plan.md` 7번, `.claude/base/brand-plan.md`(다중 태깅). +]] + +local Quad = require("../src") +local Brand = require("../src/Brand") +local QuadTypes = require("../roblox_packages/quad_types") + +type Source = QuadTypes.Source + +local Source = Quad.Source + +print("=== 1. 생성 — 값, Revision 0, SourceBrand+EpochBrand 다중 태깅, StateBrand엔 미등록 ===") +do + local s = Source(5) + assert(s:Get() == 5, "default is the value") + assert(s.Revision == 0, "initial revision") + assert(Quad.isSource(s) and Quad.isEpoch(s), "Source is both Source and Epoch") + assert(Quad.isState(s), "isState passes a Source by composition") + assert(Brand.StateBrand:is(s) == false, "…but it is NOT registered in StateBrand (predicate composition, not double registration)") + assert(not Quad.isRef(s) and not Quad.isStore(s), "nothing else") + local n: Source = Source(nil :: number?) + assert(n:Get() == nil, "Source(nil) holds nil") + print("PASS") +end + +print() +print("=== 2. :Set — 값 갱신 + 리비전 감소 랩 + self 반환; 동일값도 항상 (H-68) ===") +do + local s = Source(1 :: any) + assert(s:Set(2) == s, "Set returns self") + assert(s:Get() == 2 and s.Revision == 4294967295, "value moved, revision wrapped from 0") + local r1 = s.Revision + s:Set(2) -- same value + assert(s.Revision ~= r1, "same value still bumps the revision") + local t = {} + s:Set(t :: any) + assert(s:Get() == t, "Get returns the live reference") + print("PASS") +end + +print() +print("=== 3. :Emit — 값은 그대로, 리비전만 갱신, self 반환 ===") +do + local s = Source({ n = 1 }) + local rev = s.Revision + s:Get().n = 2 + assert(s:Emit() == s and s.Revision ~= rev and s:Get().n == 2, "Emit bumps revision without touching the value") + print("PASS") +end + +print() +print("=== 4. Modifier 가드 — 생성자와 Set 둘 다, level 2 ===") +do + local mod = {} + Brand.ModifierBrand:register(mod) + local ok, err = pcall(function() + Source(mod :: any) + end) + assert(not ok and string.find(tostring(err), "Modifier", 1, true) ~= nil, "constructor rejects: " .. tostring(err)) + assert(string.find(tostring(err), "spec.source.luau", 1, true) ~= nil, "level 2 points at the caller") + local s = Source(1) + local ok2, err2 = pcall(function() + s:Set(mod :: any) + end) + assert(not ok2 and string.find(tostring(err2), "Modifier", 1, true) ~= nil, "Set rejects: " .. tostring(err2)) + assert(s:Get() == 1, "value untouched after the rejected Set") + print("PASS") +end + +print() +print("=== 5. State 메소드 위임 — Source에서 With/Compute/Apply가 바로 됨 ===") +do + local s = Source(3) + local doubled: QuadTypes.State = s:Compute(function(x): number + return x:Get() * 2 + end) + assert(doubled:Get() == 6, "Compute through a Source") + local w: QuadTypes.State = s:With(Source(0)) + assert(w:Get() == 3, "With passes self's value through") + local applied = s:Apply(function(st: QuadTypes.StateData): number + return st:Get() + 1 + end) + assert(applied == 4, "Apply(fn) is just fn(self)") + print("PASS") +end + +print() +print("=== 6. 인스턴스별 팩토리 (H-174) — 다른 New()의 Source는 다른 임플, 브랜드는 공유 ===") +do + local other = Quad.New() + local a, b = Quad.Source(1), other.Source(1) + assert(getmetatable(a :: any) ~= getmetatable(b :: any), "each quad instance has its own Source impl") + assert(Quad.isSource(b) and other.isSource(a), "brands are shared leaf modules") + print("PASS") +end + +print() +print("=== ALL PASS ===") diff --git a/quad-base/test/spec.state.luau b/quad-base/test/spec.state.luau new file mode 100644 index 0000000..635aad1 --- /dev/null +++ b/quad-base/test/spec.state.luau @@ -0,0 +1,326 @@ +--[[ + State 계약 — `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드" / "`:With`도 새 State + 노드로 확정" / "self 인자도 lazy 핸들로 통일" / "`:Compute(fn, ...)`" / "trailing deps를 `fn`에 lazy + positional 인자로도 노출" / "`:Compute(fn)`의 선택적 두 번째 인자 — `previous`" / + "`state:Apply(factory)`" / "`_hold`로 살아남는다", `.claude/base/state-epoch-plan.md` §4. + Observer/Effect(단위 3)는 여기 없고, 구독자는 `_receive`를 가진 가짜 EmitReceive로 관측한다. +]] + +local Quad = require("../src") +local Brand = require("../src/Brand") +local QuadTypes = require("../roblox_packages/quad_types") + +type State = QuadTypes.State +type StateData = QuadTypes.StateData + +-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음) +local collectgarbage = (_G :: any).collectgarbage :: () -> () + +local Source = Quad.Source + +-- 전파 루프가 부르는 유일한 인터페이스 `sub:_receive(from)`을 흉내내는 관측자. +-- 구독자 집합은 weak-key라 호출부가 강하게 들고 있어야 한다. +type Probe = { count: number, last: any, _receive: (self: Probe, from: any) -> () } +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. Compute — lazy: 아무도 Get 안 하면 fn이 안 돈다, Get마다 재계산 안 함 ===") +do + local s = Source(2) + local runs = 0 + local d: State = s:Compute(function(x) + runs += 1 + return x:Get() * 10 + end) + assert(runs == 0, "creation does not compute") + assert(d:Get() == 20 and runs == 1, "first Get computes") + assert(d:Get() == 20 and runs == 1, "second Get is cached") + s:Set(3) + assert(runs == 1, "Set alone does not recompute (push-invalidate, pull-recompute)") + assert(d:Get() == 30 and runs == 2, "next Get recomputes once") + print("PASS") +end + +print() +print("=== 2. 전파 루프 — 출처(Source 자신)를 그대로 넘긴다, 스냅샷 순회 (H-23), 브랜드 ===") +do + local s = Source(1) + local d: State = s:Compute(function(x) + return x:Get() + end) + assert(Brand.StateBrand:is(d) and Quad.isState(d) and not Quad.isSource(d) and not Quad.isEpoch(d), "a derived node is a State, not a Source/Epoch") + local p = probe(d) + s:Set(2) + assert(p.count == 1 and p.last == s, "downstream receives the SAME source epoch, not the node") + -- 파동 중 붙은 구독자는 다음 파동부터 + local late: any = nil + local joiner = { count = 0 } :: { count: number, _receive: (self: any, from: any) -> () } + function joiner._receive(self: any, _from: any) + self.count += 1 + if late == nil then + late = probe(d) + end + end + (d :: any)._subs[joiner] = true + s:Set(3) + assert(joiner.count == 1 and late ~= nil and (late :: any).count == 0, "a subscriber added mid-wave is not visited in that wave") + s:Set(4) + assert((late :: any).count == 1, "…but from the next wave on") + print("PASS") +end + +print() +print("=== 3. 다이아몬드 — 같은 리비전이 두 경로로 와도 두 번째는 삼킨다 (규칙 3) ===") +do + local s = Source(1) + local a: State = s:Compute(function(x) + return x:Get() + 1 + end) + local b: State = s:Compute(function(x) + return x:Get() + 2 + end) + local joinRuns = 0 + local j: State = a:Compute(function(x, _, bb: StateData): number + joinRuns += 1 + return x:Get() + bb:Get() + end, b) + local p = probe(j) + s:Set(5) + assert(p.count == 1, "the join node forwarded exactly once for one Set (second arrival swallowed)") + assert(j:Get() == (5 + 1) + (5 + 2) and joinRuns == 1, "value correct, computed once") + print("PASS") +end + +print() +print("=== 4. 순회(Refresh) — 놓친 emit을 Get에서 값만 앞당기고 통지는 안 한다 ===") +do + local s = Source(1) + local d: State = s:Compute(function(x): number + return x:Get() * 2 + end) + assert(d:Get() == 2, "seed") + -- 전파 없이 리비전만 움직인 상황을 흉내: 구독 끊고 Set + local subs = (s :: any)._subs + subs[d] = nil -- detach the edge so no emit reaches d + s:Set(10) + local p = probe(d) + assert(d:Get() == 20, "Get walks valueEpochMap, sees the moved revision, recomputes") + assert(p.count == 0, "…and does NOT notify downstream (notification waits for the real emit)") + subs[d] = true + s:Set(11) + assert(p.count == 1 and d:Get() == 22, "the real emit propagates normally afterwards") + print("PASS") +end + +print() +print("=== 5. 규칙 2 — 값은 이미 최신(앞당겨 읽음)인데 emit이 오면 통지만 하고 재계산 없음 ===") +do + local s = Source(1) + local runs = 0 + local d: State = s:Compute(function(x) + runs += 1 + return x:Get() + end) + d:Get() + local subs = (s :: any)._subs + subs[d] = nil + s:Set(2) + assert(d:Get() == 2 and runs == 2, "advanced the value via Refresh") + local p = probe(d) + subs[d] = true; + (d :: any):_receive(s) -- the late emit for the same revision arrives + assert(p.count == 1, "rule 2: notify downstream") + assert(d:Get() == 2 and runs == 2, "…without recomputing (value was already current)") + print("PASS") +end + +print() +print("=== 6. 캐시 카운터 쌍 (H-85) — 재계산 도중 온 무효화는 다음 Get이 반드시 다시 계산 ===") +do + local s = Source(1) + local runs = 0 + local d: any + d = s:Compute(function(x) + runs += 1 + local v = x:Get() + if v == 1 then + s:Set(2) -- upstream write DURING recompute + end + return v + end) + assert(d:Get() == 1 and runs == 1, "first compute saw 1 (and set 2 mid-way)") + assert(d:Get() == 2 and runs == 2, "the mid-recompute invalidation was not lost") + assert(d:Get() == 2 and runs == 2, "then stable") + -- fn이 던지면 cacheCurrCount가 안 갱신돼 다음 Get이 다시 시도한다 + local boom = true + local e: State = s:Compute(function(x) + if boom then + error("boom") + end + return x:Get() + end) + assert(not pcall(function() + e:Get() + end), "fn threw") + boom = false + assert(e:Get() == 2, "a thrown fn never marked the cache valid — recomputed on the next Get") + print("PASS") +end + +print() +print("=== 7. :With — pass-through 새 노드, 구독만 넓힘; :Compute(fn, ...deps) — 노드 1개, positional lazy deps ===") +do + local a, b = Source(1), Source(10) + local w: State = a:With(b) + assert(w:Get() == 1, "With's value is self's") + assert(Brand.StateBrand:is(w) and w ~= a, "a new node") + local pw = probe(w) + b:Set(20) + assert(pw.count == 1 and pw.last == b, "With subscribes to the extra dep") + assert(w:Get() == 1, "…but still passes self through") + + local runs = 0 + local c: State = a:Compute(function(x, _prev, dep: StateData): number + runs += 1 + return x:Get() + dep:Get() + end, b) + assert(c:Get() == 21 and runs == 1, "trailing dep arrives as a lazy positional after previous") + assert(#(c :: any)._hold == 2, "one node with two edges — no combining With node") + b:Set(30) + assert(c:Get() == 31 and runs == 2, "dep change invalidates") + print("PASS") +end + +print() +print("=== 8. previous — 직전 결과가 두 번째 인자로, 노드마다 독립 ===") +do + local s = Source(1) + local seen: { any } = {} + local d: State<{ n: number }> = s:Compute(function(x, previous: { n: number }?): { n: number } + table.insert(seen, previous :: any) + if previous then + previous.n = x:Get() + return previous + end + return { n = x:Get() } + end) + local first = d:Get() + assert(seen[1] == nil, "first run has no previous") + s:Set(2) + assert(d:Get() == first and first.n == 2, "second run received the first result and reused it") + -- 팬아웃: 형제 노드의 previous는 섞이지 않는다 + local other: State = s:Compute(function(_x, previous) + return previous + end) + assert(other:Get() == nil, "a sibling node starts with its own nil previous") + print("PASS") +end + +print() +print("=== 9. self는 리시버의 lazy 핸들 — 결과 노드가 아니다; 조건부로 self:Get을 건너뛰면 계산 안 됨 ===") +do + local runs = 0 + local s = Source(1) + local mid: State = s:Compute(function(x) + runs += 1 + return x:Get() + end) + local skip = Source(true) + local top: State = mid:Compute(function(x, _, sk: StateData): number + if sk:Get() then + return -1 + end + return x:Get() + end, skip) + assert(top:Get() == -1 and runs == 0, "self was never read → mid never computed") + skip:Set(false) + assert(top:Get() == 1 and runs == 1, "now it is") + print("PASS") +end + +print() +print("=== 10. Apply — 함수 / 메소드형 __apply, 반환은 열려 있음 ===") +do + local s = Source(2) + local viaFn = s:Apply(function(st: StateData): number + return st:Get() * 3 + end) + assert(viaFn == 6, "function factory") + local factory = { calls = 0 } :: { calls: number, __apply: (self: any, st: any) -> any } + function factory.__apply(self: any, st: any) + self.calls += 1 + return st + end + local viaObj = s:Apply(factory :: any) + assert(viaObj == s and factory.calls == 1, "__apply is called as a METHOD on the factory object (H-158)") + print("PASS") +end + +print() +print("=== 11. _hold — 하류가 상류를 강하게, 상류는 하류를 weak로 ===") +do + local weakNodes = setmetatable({}, { __mode = "v" }) :: { any } + local leaf: any + local function build() + local root = Source(1) + local mid: State = root:With(Source(0)) + weakNodes[1] = root + weakNodes[2] = mid + leaf = mid:Compute(function(x) + return x:Get() + end) + end + build() + collectgarbage() + collectgarbage() + assert(weakNodes[1] ~= nil and weakNodes[2] ~= nil, "root and mid survive while the leaf is held (_hold chain)") + assert(leaf:Get() == 1, "and the chain still works") + -- 반대 방향: 하류를 놓으면 상류 혼자 살아도 하류는 수거된다 + local root2 = Source(1) + local weakLeaf = setmetatable({}, { __mode = "v" }) :: { any } + local function attach() + weakLeaf[1] = root2:Compute(function(x) + return x:Get() + end) + end + attach() + collectgarbage() + collectgarbage() + assert(weakLeaf[1] == nil, "upstream holds downstream only weakly") + assert(next((root2 :: any)._subs) == nil, "…and the dead subscriber left the set") + print("PASS") +end + +print() +print("=== 12. 가드 — Compute 결과가 Modifier면 캐싱 전에 error(level 2); dep이 State가 아니면 error ===") +do + local mod = {} + Brand.ModifierBrand:register(mod) + local s = Source(1) + local d: State = s:Compute(function() + return mod + end) + local ok, err = pcall(function() + d:Get() + end) + assert(not ok and string.find(tostring(err), "Modifier", 1, true) ~= nil, "rejected: " .. tostring(err)) + assert((d :: any)._cache == nil, "nothing was cached") + local ok2, err2 = pcall(function() + s:Compute(function(x) + return x:Get() + end, {} :: any) + end) + assert(not ok2 and string.find(tostring(err2), "dep #2", 1, true) ~= nil, "non-State dep: " .. tostring(err2)) + assert(string.find(tostring(err2), "spec.state.luau", 1, true) ~= nil, "points at the caller") + print("PASS") +end + +print() +print("=== ALL PASS ===") diff --git a/quad-base/test/spec.store.luau b/quad-base/test/spec.store.luau new file mode 100644 index 0000000..d3c8ec7 --- /dev/null +++ b/quad-base/test/spec.store.luau @@ -0,0 +1,123 @@ +--[[ + Store 계약 — `.claude/base/store-plan.md` "Store = Source들의 이름 붙은 모음 (명시적 초기화)" / + "타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정" / "`store.key` 레코드 필드 타이핑". + `H-122`(isSource 화이트리스트) / `H-153`(예약 이름 런타임 가드, 그림자 = store 자신) / `H-83`(무인자). +]] + +local Quad = require("../src") +local QuadTypes = require("../roblox_packages/quad_types") + +type Source = QuadTypes.Source +type State = QuadTypes.State + +local Source, Store = Quad.Source, Quad.Store + +print("=== 1. 명시적 초기화 — store.key는 넣은 Source 그 자체, 평범한 레코드 필드 ===") +do + local hp, name = Source(100), Source("x") + local s = Store({ hp = hp, name = name }) + assert(s.hp == hp and s.name == name, "fields are the very Sources passed in") + assert(rawget(s, "hp") == hp, "shadow = the store itself (H-153)") + assert(Quad.isStore(s) and not Quad.isSource(s), "brand") + local v: number = s.hp:Get() + assert(v == 100, "typed dot-access") + s.hp:Set(5) + assert(hp:Get() == 5, "Set goes to the same Source") + -- 무주석 콜백 파라미터 추론이 사는 선언 스타일(§1②) — analyze가 지킨다 + local doubled: State = s.hp:Compute(function(x) + return x:Get() * 2 + end) + assert(doubled:Get() == 10, "Compute through a store field") + print("PASS") +end + +print() +print("=== 2. defaults 검증 (H-122) — isSource 화이트리스트, level 2, 영어 ===") +do + local ok, err = pcall(function() + Store({ hp = 100 } :: any) + end) + assert(not ok and string.find(tostring(err), "not a Source", 1, true) ~= nil, "raw value rejected: " .. tostring(err)) + assert(string.find(tostring(err), "spec.store.luau", 1, true) ~= nil, "level 2 points at the caller") + local ok2 = pcall(function() + Store({ ok = Source(1), bad = Quad.Ref(1) } :: any) + end) + assert(not ok2, "a Ref is not a Source") + print("PASS") +end + +print() +print("=== 3. 예약 이름 (H-153) — 생성자 순회와 Of 둘 다 error(level 2) ===") +do + for _, name in { "Of", "Names", "__reservedCheck" } do + local ok, err = pcall(function() + Store({ [name] = Source(1) } :: any) + end) + assert(not ok and string.find(tostring(err), "reserved", 1, true) ~= nil, name .. " in defaults: " .. tostring(err)) + local s = Store({} :: {}) + local ok2, err2 = pcall(function() + s:Of(name) + end) + assert(not ok2 and string.find(tostring(err2), "reserved", 1, true) ~= nil, name .. " via Of: " .. tostring(err2)) + assert(string.find(tostring(err2), "spec.store.luau", 1, true) ~= nil, "level 2") + end + print("PASS") +end + +print() +print("=== 4. :Of — 없는 이름은 그 자리에서 만들어 저장(lazy가 남는 유일한 자리), 있으면 그것 ===") +do + local s = Store({ hp = Source(1) }) + local dyn: Source = s:Of<>("dyn") + assert(Quad.isSource(dyn) and dyn:Get() == nil, "created as Source(nil)") + assert(s:Of("dyn") == dyn, "same name → same Source") + assert(rawget(s, "dyn") == dyn, "stored on the store itself") + assert(s:Of("hp") == s.hp, "declared keys are reachable through Of too") + print("PASS") +end + +print() +print("=== 5. :Names — defaults 키 + Of가 만든 키, 메소드는 안 셈, 팬텀 필드도 없음 ===") +do + local s = Store({ hp = Source(1), name = Source("n") }) + local function set(list: { string }): { [string]: true } + local t = {} + for _, k in list do + t[k] = true + end + return t + end + local names = set(s:Names()) + assert(names.hp and names.name and not names.Of and not names.Names and not names.__reservedCheck, "declared keys only") + assert(#s:Names() == 2, "count") + s:Of("dyn") + assert(set(s:Names()).dyn == true and #s:Names() == 3, "Of adds to Names") + assert(rawget(s, "__reservedCheck") == nil, "phantom field has no runtime counterpart") + print("PASS") +end + +print() +print("=== 6. 빈 Store (H-83/H-157) — 무인자도 유효 ===") +do + local e = Store({} :: {}) + assert(#e:Names() == 0, "no names") + local d: Source = e:Of("x") + assert(Quad.isSource(d), "Of works on an empty store") + local e2 = (Quad.Store :: any)() + assert(Quad.isStore(e2) and #e2:Names() == 0, "no-arg form (or {} guard)") + print("PASS") +end + +print() +print("=== 7. 인스턴스별 팩토리 (H-174) — Of가 자기 quad의 Source를 만든다 ===") +do + local other = Quad.New() + local s = other.Store({} :: {}) + local d = s:Of("x") + assert(getmetatable(d :: any) == getmetatable(other.Source(0) :: any), "Of uses the module's own Source, read at call time") + assert(getmetatable(d :: any) ~= getmetatable(Quad.Source(0) :: any), "not the top-level instance's") + print("PASS") +end + +print() +print("=== ALL PASS ===") diff --git a/quad-types/src/init.luau b/quad-types/src/init.luau index 926aa23..3267b93 100644 --- a/quad-types/src/init.luau +++ b/quad-types/src/init.luau @@ -46,6 +46,55 @@ export type Ref = { Uncallback: (self: Ref, fn: RefCallback) -> Ref, } +-- ── 반응형 코어(M2 단위 2) ──────────────────────────────────────────── +-- 선언 스타일은 `.claude/base/typing-limits.md` §1②의 "데이터부/메소드부" 쪼개기 — +-- 콜백 파라미터 무주석 추론이 사는 유일한 스타일(`audit/handtrace-round10-reference-impl/ +-- spikes/ty11_store_final.luau`가 최종형 실측). `Compute`의 `-> State` 반환은 §1①의 +-- 알려진 한계라 **파생 State를 만드는 자리는 결과 타입을 명시 주석**할 것. +-- `fn(self, previous?, ...deps)` — 전부 lazy 핸들, 값은 `:Get()`으로만 +-- (`.claude/base/source-state-plan.md` "self 인자도 lazy 핸들로 통일"). +export type StateData = { Get: (self: StateData) -> T } +export type State = StateData & { + -- deps는 `...any`다 — 타입팩 `D...`로 위치 인자를 좁히는 형태는 strict에서 콜백 dep + -- 추론이 `{read Get: ...}`로 뒤틀려 정상 호출까지 막힌다(M2 단위 2 실측, 스파이크 15가 + -- "미검증"으로 남겨둔 자리). 콜백 안에서 dep 파라미터에 주석을 달 것. + 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, +} +-- `Source`는 `State`를 구조적으로 만족하고 동시에 `Epoch`다(다중 태깅). +export type Source = State & { + Revision: number, + Set: (self: Source, v: T) -> Source, + Emit: (self: Source) -> Source, +} + +-- 예약 키 진단 — `keyof`(키 싱글톤 유니온)만 받는다(`H-112`). `error()`는 못 쓴다 +-- (타입 함수 자체가 실패로 판정) — `print` + `types.never`. 목록은 quad-base +-- `Store.luau`의 `RESERVED` 테이블의 사본이라 이름을 바꿀 땐 둘을 같이. +-- `.claude/base/typing-limits.md` §0: 타입 함수는 **진단까지만**. +export type function CheckReservedKeys(keys: type) + local list = if keys:is("union") then keys:components() else { keys } + for _, k in list do + if k:is("singleton") then + local v = k:value() + if v == "Of" or v == "Names" or v == "__reservedCheck" then + print(`quad.Store: "{v}" is a reserved key`) + return types.never + end + end + end + return types.singleton(true) +end +-- `Store` — `T`는 `{hp: Source, …}` 그대로(평범한 레코드, 타입 함수 없음). +-- `__reservedCheck`는 팬텀(런타임 nil, 읽지 말 것, `Names()`에 안 들어감). +export type Store = T & { + Of: (self: any, name: string) -> Source, + Names: (self: any) -> { string }, + __reservedCheck: CheckReservedKeys>, +} + export type Quad = { Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 debug: boolean, @@ -58,6 +107,9 @@ export type Quad = { Relate: () -> Relate, Void: (...any) -> (), Ref: (default: T) -> Ref, + -- 단위 2. `State`는 런타임 생성자가 없다(파생 전용) — 타입만 위에서 export. + Source: (v: T) -> Source, + Store: (defaults: T?) -> Store, -- 생명주기 4종 — quad-base는 인터페이스(에러 스텁)만, 백엔드가 주입 -- (`.claude/base/lifecycle-pattern.md`). `canBound(v) == not canExecute(v)`.