feat(m2): 단위 2 — EpochMap / State(Init 팩토리, H-174) / Source / Store + quad-types 최종형 타입 + spec 4개

- 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 <me@qwreey.moe>
This commit is contained in:
qwreey 2026-08-28 20:33:29 +09:00
parent 4169d7ba50
commit 1e070e3f1f
Signed by: qwreey
GPG key ID: D28DB79297A214BD
16 changed files with 1318 additions and 19 deletions

View file

@ -836,6 +836,13 @@ deps만 받고 싶어도 `previous`가 2번째 자리를 차지하므로, 그
비워둬야 함 — deps만 쓰는 흔한 케이스가 약간 불편해지지만, Luau 문법 비워둬야 함 — deps만 쓰는 흔한 케이스가 약간 불편해지지만, Luau 문법
제약상 다른 선택지가 없음(대안은 애초에 이 확장 자체를 안 하는 것뿐). 제약상 다른 선택지가 없음(대안은 애초에 이 확장 자체를 안 하는 것뿐).
**⭐ [2026-08-28 실측, M2 단위 2 `H-176`] (B)는 안 된다** — `Compute: <U, D...>(self, fn:
(self, U?, D...) -> U, D...) -> State<U>`로 선언하면 `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<U>` 주석 —
런타임 계약(위치·순서·lazy)은 그대로다. 스파이크 `15`는 이 결과로 닫힌다
(`luau-test/STATUS.md`). 아래는 실측 전 서술:
**실측 필요 — `luau-test``15-type-compute-trailing-deps-typepack.luau` **실측 필요 — `luau-test``15-type-compute-trailing-deps-typepack.luau`
신규(ROADMAP.md M2 반영).** 순서 문제 자체는 위 정정으로 구조적으로 신규(ROADMAP.md M2 반영).** 순서 문제 자체는 위 정정으로 구조적으로
풀렸으므로, 스파이크가 실제로 확인할 진짜 불확실성은 (B) 하나로 좁혀짐 — 풀렸으므로, 스파이크가 실제로 확인할 진짜 불확실성은 (B) 하나로 좁혀짐 —

View file

@ -24,6 +24,7 @@
| `H-172` | ① | 1 | 🟡 | mock `Destroy`가 자손을 안 죽이고(조상 파괴 계약 검증 불가), `Parent` 변경 시그널이 연결 해제 뒤라 관측 불가, 재귀 Destroy 무한 루프 | ✅ mock: Destroying → Parent nil → 자손 Destroy → 연결 해제, 이중 Destroy no-op(`spec.lifetime` 6c) | | `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-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-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` 주석 좁힘 | | `H-175` | ① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 **업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬**일 때만(컴파일러 `shouldShareClosure`). 함수 인자·지역을 잡는 클로저(단위 3 `Effect`의 콜백 모양)는 정상 GC됨을 실측 | ✅ §5·`spec.ref` 주석 좁힘 |
## 상세 ## 상세
@ -140,6 +141,19 @@
등"*이 방향만 준다. 어느 모양이든 **새 조립 메커니즘**이라 §4. 등"*이 방향만 준다. 어느 모양이든 **새 조립 메커니즘**이라 §4.
- **갈래**: §4. 단위 1 코드는 어느 선택지에서도 그대로다(③ 아님). - **갈래**: §4. 단위 1 코드는 어느 선택지에서도 그대로다(③ 아님).
### 단위 2 — `EpochMap``Source`/`State`/`Store` (2026-08-28)
### `H-176` 🟡 — 타입팩 deps 선언은 strict에서 안 산다 (①)
- **어디서**: `quad-types/src/init.luau` `State<T>.Compute` / `base/source-state-plan.md` "trailing
deps를 `fn`에 lazy positional 인자로도 노출"의 "(B) 이형 다중 deps를 제네릭 팩으로".
- **무엇이**: `<U, D...>(self, fn: (self, U?, D...) -> U, D...)`로 선언하자 `spec.state.luau`
정상 호출 셋(3·7·9)이 *"Expected `{ read Get: (t1) -> (number, ...unknown) }` but got
`Source<number>`"*로 막혔다 — 팩이 콜백 파라미터 쪽으로 역추론되며 `Get`을 read-only
`...unknown` 반환으로 뒤튼다. 문서가 "실측 필요"로 남겨둔 바로 그 (B)다.
- **처리**: deps 자리 `...any`, 콜백 안에서 `dep: StateData<U>` 주석(콜백 파라미터 주석 관례
그대로). 런타임 계약 무변경. 문서에 실측 기록, 스파이크 `15` 닫힘.
### `H-175` 🟢 — 클로저 캐시 규칙의 범위 (①) ### `H-175` 🟢 — 클로저 캐시 규칙의 범위 (①)
- **어디서**: 이 파일 §5 "툴링 사실 둘"의 둘째 항, `spec.ref.luau` 7번 주석. - **어디서**: 이 파일 §5 "툴링 사실 둘"의 둘째 항, `spec.ref.luau` 7번 주석.
@ -193,6 +207,27 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
- `brand-plan.md` 합성 술어(`isState = isSource or StateBrand`, `isRef = isPreRef or - `brand-plan.md` 합성 술어(`isState = isSource or StateBrand`, `isRef = isPreRef or
isPostRef or RefBrand`)와 weak-key 멤버십 — 성립. 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`는 그 파일 - `require("@self/X")`**`init.luau`에서만** 통한다 — 일반 파일에서 `@self`는 그 파일
자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`. 자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`.

View file

@ -2029,4 +2029,6 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
§4로, ① 셋(mock lazy claim·`Destroy` 의미론·M7 `TweenBrand` 잔재) 반영. 탐사자 §4로, ① 셋(mock lazy claim·`Destroy` 의미론·M7 `TweenBrand` 잔재) 반영. 탐사자
🔴 `H-174`(생명주기 필드 조립 형태). **같은 날 배치 회신으로 넷 다 확정**: `H-174` 🔴 `H-174`(생명주기 필드 조립 형태). **같은 날 배치 회신으로 넷 다 확정**: `H-174`
팩토리형 + `module.canExecute` 늦게 읽기 / `H-169` 사용자 안(리비전 바뀌면 파동을 팩토리형 + `module.canExecute` 늦게 읽기 / `H-169` 사용자 안(리비전 바뀌면 파동을
놓음, 권고안 기각) / `H-168` `Ref<<T?>>()` / `H-170` 즉시 실패만 re-raise. 단위 2 게이트 0. 놓음, 권고안 기각) / `H-168` `Ref<<T?>>()` / `H-170` 즉시 실패만 re-raise. **이어서 단위 2
구현** — `EpochMap`/`State`(`Init(module)`+`implFor`)/`Source`/`Store` + `quad-types`
최종형 타입(`ty11`) + spec 4개, ALL PASS. `H-176` 타입팩 deps 선언 기각(`...any`).

View file

@ -62,6 +62,18 @@
재진입 절·`:Wait` 정정·"제네릭 시그니처" 규칙, `lifecycle-hooks`/`debounce-throttle` 재진입 절·`:Wait` 정정·"제네릭 시그니처" 규칙, `lifecycle-hooks`/`debounce-throttle`
배너, `lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체에 `H-174`. 배너, `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를 보라").

View file

@ -375,14 +375,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> 금지 — 백엔드가 `New()` 뒤에 덮어쓴다). 의사코드의 `canExecute(self)`는 전부 그 뜻. > 금지 — 백엔드가 `New()` 뒤에 덮어쓴다). 의사코드의 `canExecute(self)`는 전부 그 뜻.
> 소스는 `base/lifecycle-pattern.md``H-174` 문단. > 소스는 `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`이 "뒤로 전파가 부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가
필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet =
{[Epoch]: true}`로 **배열이 아니라 집합**). `State.luau`에 묻지 말고 {[Epoch]: true}`로 **배열이 아니라 집합**). `State.luau`에 묻지 말고
별도 모듈로 낼 것 — `GateNode`(아래)와 `State`/`Effect`가 별도 모듈로 낼 것 — `GateNode`(아래)와 `State`/`Effect`가
전부 같은 것을 쓴다. `Epoch` 인터페이스 자체(`{ Revision: number }`)와 전부 같은 것을 쓴다. `Epoch` 인터페이스 자체(`{ Revision: number }`)와
리비전 갱신(`bit32.bnot(-rev)`)도 여기서 확정 — `base/state-epoch-plan.md` 리비전 갱신(`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`) 재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`)
`invalid` 플래그가 아니다. **아래 `Source.luau`/`State.luau`가 이걸 `invalid` 플래그가 아니다. **아래 `Source.luau`/`State.luau`가 이걸
전제로 짜여야 하므로 `EpochMap.luau`(위 항목)가 State 본체보다 먼저 전제로 짜여야 하므로 `EpochMap.luau`(위 항목)가 State 본체보다 먼저
@ -397,11 +397,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복 돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복
통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다
(`luau-test/STATUS.md`). (`luau-test/STATUS.md`).
- [ ] `Source.luau`/`State.luau`/`Store.luau` - [x] **[2026-08-28 완료 — 단위 2]** `Source.luau`/`State.luau`/`Store.luau` — `InitState`/`InitSource`/`InitStore` 팩토리(`H-174`), `test/spec.{epochmap,source,state,store}.luau`
- [ ] **[2026-08-28 10라운드 `H-153`]** Store 생성자의 `isSource` 순회와 `store:Of(name)` - [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-28 10라운드 `H-153`]** Store 생성자의 `isSource` 순회와 `store:Of(name)`
**예약 이름 런타임 가드**(`error(…, 2)`) — 동적 키는 타입이 못 막는다; **예약 이름 런타임 가드**(`error(…, 2)`) — 동적 키는 타입이 못 막는다;
그림자 = store 자신(`base/store-plan.md`). 그림자 = store 자신(`base/store-plan.md`).
- [ ] **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<<T>>(name): Source<T>` - [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<<T>>(name): Source<T>`
런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열
커링은 기각). **콜론 메소드로 확정**했고, 예약 키 커링은 기각). **콜론 메소드로 확정**했고, 예약 키
(`Of`/`Names`/**`__reservedCheck`** — **[2026-08-26 `/code-review high`]** (`Of`/`Names`/**`__reservedCheck`** — **[2026-08-26 `/code-review high`]**
@ -412,10 +412,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
돈다 — `base/store-plan.md`의 "타입 추론 문제" 절). 돈다 — `base/store-plan.md`의 "타입 추론 문제" 절).
`<<T>>`가 값 호출부에서 실제로 `T`를 묶는 것도 실측 확인됨. `<<T>>`가 값 호출부에서 실제로 `T`를 묶는 것도 실측 확인됨.
**[2026-08-25] 옛 이름은 `GetDynamic`이었다 — `Of`가 흡수했다** **[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()`이 열거(그림자 테이블의 키). 그룹 `Attribute(...)`/`attr:NameMap()`이
요구한다(`base/attribute-plan.md`) 요구한다(`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<T>`**(키 싱글톤 유니온)를 받아 타입 함수 — **`T`가 아니라 `keyof<T>`**(키 싱글톤 유니온)를 받아
예약 키를 검증만 하고, 팬텀 필드 `__reservedCheck`로 격리한다. 예약 키를 검증만 하고, 팬텀 필드 `__reservedCheck`로 격리한다.
`error()`가 아니라 `print(...)` + `return types.never`를 써야 한다. `error()`가 아니라 `print(...)` + `return types.never`를 써야 한다.
@ -424,11 +424,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`*error-type*`을 품어 **유효한 Store 전부**에 스퓨리어스 타입 함수 `*error-type*`을 품어 **유효한 Store 전부**에 스퓨리어스 타입 함수
에러가 뜬다(실측). 근거·통과 배선은 `base/store-plan.md`. 에러가 뜬다(실측). 근거·통과 배선은 `base/store-plan.md`.
**타입 함수는 이 용도(진단)까지만 쓴다**`base/typing-limits.md` §0 **타입 함수는 이 용도(진단)까지만 쓴다**`base/typing-limits.md` §0
- [ ] **[2026-08-25 신설]** **명시적 초기화** — 타입 인자에 `Source<T>` - [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-25 신설]** **명시적 초기화** — 타입 인자에 `Source<T>`
직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index` 직접 쓰고 `defaults`에도 `Source(v)`를 직접 넣는다. 옛 lazy `__index`
(없는 키를 그 자리에서 만들어 저장)는 **폐기**. 그래서 `defaults` (없는 키를 그 자리에서 만들어 저장)는 **폐기**. 그래서 `defaults`
곧 선언 키 집합이고 `Names()`가 성립한다 곧 선언 키 집합이고 `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`의 "실제 호출부" 절) — (2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제 호출부" 절) —
State는 구독자를 **weak-키로만** 담고, 살려두는 State는 구독자를 **weak-키로만** 담고, 살려두는
책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음 책임은 `gchold`(leaf) 또는 전역 `Subscribed` 테이블(전역)에 있음
@ -467,7 +467,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
"`store.key` 레코드 필드 타이핑" 절이 소스. 스파이크 `16`/`21`은 "`store.key` 레코드 필드 타이핑" 절이 소스. 스파이크 `16`/`21`은
폐기된 접근을 검증한 것이라 재작성 대기 폐기된 접근을 검증한 것이라 재작성 대기
(`luau-test/STATUS.md`) (`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, ...)`" (2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`"
절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드 절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드
자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M2 자신에 구독만 추가)로 끝나야 함, 새 노드 생성 없이 구현되는지 M0/M2
@ -477,7 +477,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
걸면 합치는 노드 자체가 안 생겨 감출 비용이 없다. **기각으로 남은 건 걸면 합치는 노드 자체가 안 생겨 감출 비용이 없다. **기각으로 남은 건
`Observer` 하나**이고 근거도 새로 쓰였다("Observer는 리시버 State `Observer` 하나**이고 근거도 새로 쓰였다("Observer는 리시버 State
하나에 붙는 구독, 여럿을 엮는 건 Effect가 대신한다") 하나에 붙는 구독, 여럿을 엮는 건 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)`를 체이닝 문법으로 절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도 부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함
@ -572,7 +572,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(`H-7`). 캐치업은 바인드 직후 **`_rerunRequired`면 1회**(`if self._rerunRequired then (`H-7`). 캐치업은 바인드 직후 **`_rerunRequired`면 1회**(`if self._rerunRequired then
self:Rerun() end` — **[2026-08-28 `H-151`/`H-159`]** 옛 `_epochs:Refresh()`는 폐기, self:Rerun() end` — **[2026-08-28 `H-151`/`H-159`]** 옛 `_epochs:Refresh()`는 폐기,
`_epochs`는 emit 받을 때만 갱신하되 실행 불가 상태의 변경은 홀드 — `H-64`/`H-65`). 의사코드는 `base/effect-plan.md`가 소스 `_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에서 스냅샷한 뒤** 돈다 — 순회 중 새 구독자 추가가 정상 경로인데 Lua에서
미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로 미정의라, 실측에서 실행마다 결과가 달라지고 한 Observer가 통째로
누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약 누락됐다. "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약
@ -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 부분 — 단위분(`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`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 — `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`) /
@ -652,7 +652,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
이 마일스톤이 그 규칙의 첫 적용 지점**이고, 규칙의 정본은 이 마일스톤이 그 규칙의 첫 적용 지점**이고, 규칙의 정본은
`base/quad-types-plan.md`의 "`Quad` 타입 — 확정된 표면" 절 `base/quad-types-plan.md`의 "`Quad` 타입 — 확정된 표면" 절
(M3의 `H-25` 항목이 같은 규칙을 `Dispatch` 기준으로 서술한다) (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가 안 `H-14`] 이 항목은 `:Compute` 한정이다** — `Effect``fn`엔 deps가 안
넘어간다) — (`fn(self, 넘어간다) — (`fn(self,
previous?, dep1, ..., depN)` — 순서는 Luau 값 레벨 `...`가 파라미터 previous?, dep1, ..., depN)` — 순서는 Luau 값 레벨 `...`가 파라미터
@ -663,10 +663,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`luau-test``15-type-compute-trailing-deps-typepack.luau` `luau-test``15-type-compute-trailing-deps-typepack.luau`
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안 이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
되면 동종 타입 dep 1개로 한정) 되면 동종 타입 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`는 각각 체크박스가 `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` 런타임 가드 — 적용 지점이 `isModifier` 런타임 가드 — 적용 지점이
**전부 이 마일스톤의 코드**다: **`Source(...)` 생성자** / `Source:Set` / **전부 이 마일스톤의 코드**다: **`Source(...)` 생성자** / `Source:Set` /
State의 `:Compute` 결과 캐싱. 체크박스가 M7에만 있었다. State의 `:Compute` 결과 캐싱. 체크박스가 M7에만 있었다.
@ -675,7 +675,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(**[2026-08-26 정밀화]** 동적 키 창구 `store:Of(name)`은 여전히 만든다) — (**[2026-08-26 정밀화]** 동적 키 창구 `store:Of(name)`은 여전히 만든다) —
가드를 `Source` 생성자에 두면 그 둘이 **한 번에 커버**된다 가드를 `Source` 생성자에 두면 그 둘이 **한 번에 커버**된다
(`base/modifier-plan.md` 7번이 소스) (`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)` 검증 — 값 전량에 `isSource` 화이트리스트, 거짓이면 `error(..., 2)`
(영어 메시지). 타입은 `Source<T>`를 요구하지만 `--!nocheck`/동적 (영어 메시지). 타입은 `Source<T>`를 요구하지만 `--!nocheck`/동적
코드가 raw 값을 넘기면 지금 스케치(`table.clone`)는 조용히 받고 첫 코드가 raw 값을 넘기면 지금 스케치(`table.clone`)는 조용히 받고 첫

127
quad-base/src/EpochMap.luau Normal file
View file

@ -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

90
quad-base/src/Source.luau Normal file
View file

@ -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<T>`/`Source<T>`의 `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<T> = QuadTypes.Source<T>
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

202
quad-base/src/State.luau Normal file
View file

@ -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<T> = QuadTypes.StateData<T>
export type State<T> = QuadTypes.State<T>
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,
}

87
quad-base/src/Store.luau Normal file
View file

@ -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<<T>>(name)`), as-is.
local store = quad.Store<<{ hp: Source<number> }>>({ hp = Source(100) })
store.hp:Get() -- plain record field → the Source itself
store:Of<<T>>(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<T> = QuadTypes.Store<T>
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

View file

@ -20,6 +20,9 @@ local Void = require("@self/Void")
local QuadTypes = require("./roblox_packages/quad_types") local QuadTypes = require("./roblox_packages/quad_types")
local InitDebug = require("@self/Debug") local InitDebug = require("@self/Debug")
local InitLifetimeHandle = require("@self/LifetimeHandle") local InitLifetimeHandle = require("@self/LifetimeHandle")
local State = require("@self/State")
local InitSource = require("@self/Source")
local InitStore = require("@self/Store")
type Quad = QuadTypes.Quad type Quad = QuadTypes.Quad
@ -67,6 +70,11 @@ local function New(): Quad
module:RunInit(InitDebug) module:RunInit(InitDebug)
module:RunInit(InitLifetimeHandle) -- 생명주기 4종 에러 스텁 — 백엔드가 덮어씀 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)를 순서 무관하게 추가 -- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
return module return module

View file

@ -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 ===")

View file

@ -14,6 +14,8 @@ print("=== 1. New()마다 M2 첫 단위 탑레벨 값이 실려 있음 ===")
do do
local m: Quad = Quad.New() 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.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 } = { local predicates: { (any) -> boolean } = {
m.isEpoch, m.isSource, m.isState, m.isStore, m.isObserver, m.isEffect, m.isEpoch, m.isSource, m.isState, m.isStore, m.isObserver, m.isEffect,
m.isBlocker, m.isModifier, m.isRef, m.isPreRef, m.isPostRef, m.isBlocker, m.isModifier, m.isRef, m.isPreRef, m.isPostRef,

View file

@ -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<T> = QuadTypes.Source<T>
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<number?> = 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<number> = s:Compute(function(x): number
return x:Get() * 2
end)
assert(doubled:Get() == 6, "Compute through a Source")
local w: QuadTypes.State<number> = s:With(Source(0))
assert(w:Get() == 3, "With passes self's value through")
local applied = s:Apply(function(st: QuadTypes.StateData<number>): 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 ===")

View file

@ -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<T> = QuadTypes.State<T>
type StateData<T> = QuadTypes.StateData<T>
-- 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<number> = 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<number> = 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<number> = s:Compute(function(x)
return x:Get() + 1
end)
local b: State<number> = s:Compute(function(x)
return x:Get() + 2
end)
local joinRuns = 0
local j: State<number> = a:Compute(function(x, _, bb: StateData<number>): 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<number> = 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<number> = 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<number> = 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<number> = 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<number> = a:Compute(function(x, _prev, dep: StateData<number>): 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<any> = 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<number> = s:Compute(function(x)
runs += 1
return x:Get()
end)
local skip = Source(true)
local top: State<number> = mid:Compute(function(x, _, sk: StateData<boolean>): 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>): 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<number> = 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<any> = 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 ===")

View file

@ -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<T> = QuadTypes.Source<T>
type State<T> = QuadTypes.State<T>
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<number> = 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<boolean> = s:Of<<boolean>>("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<number> = 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 ===")

View file

@ -46,6 +46,55 @@ export type Ref<T> = {
Uncallback: (self: Ref<T>, fn: RefCallback<T>) -> Ref<T>, Uncallback: (self: Ref<T>, fn: RefCallback<T>) -> Ref<T>,
} }
-- ── 반응형 코어(M2 단위 2) ────────────────────────────────────────────
-- 선언 스타일은 `.claude/base/typing-limits.md` §1②의 "데이터부/메소드부" 쪼개기 —
-- 콜백 파라미터 무주석 추론이 사는 유일한 스타일(`audit/handtrace-round10-reference-impl/
-- spikes/ty11_store_final.luau`가 최종형 실측). `Compute`의 `-> State<U>` 반환은 §1①의
-- 알려진 한계라 **파생 State를 만드는 자리는 결과 타입을 명시 주석**할 것.
-- `fn(self, previous?, ...deps)` — 전부 lazy 핸들, 값은 `:Get()`으로만
-- (`.claude/base/source-state-plan.md` "self 인자도 lazy 핸들로 통일").
export type StateData<T> = { Get: (self: StateData<T>) -> T }
export type State<T> = StateData<T> & {
-- deps는 `...any`다 — 타입팩 `D...`로 위치 인자를 좁히는 형태는 strict에서 콜백 dep
-- 추론이 `{read Get: ...}`로 뒤틀려 정상 호출까지 막힌다(M2 단위 2 실측, 스파이크 15가
-- "미검증"으로 남겨둔 자리). 콜백 안에서 dep 파라미터에 주석을 달 것.
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>, previous: U?, ...any) -> U, ...any) -> State<U>,
With: (self: StateData<T>, ...any) -> State<T>,
-- 애플리커티브 팩토리는 함수이거나 메소드형 `__apply`를 가진 객체(`H-94`/`H-158`).
Apply: <U>(self: StateData<T>, factory: ((State<T>) -> U) | { __apply: (self: any, state: State<T>) -> U }) -> U,
}
-- `Source`는 `State`를 구조적으로 만족하고 동시에 `Epoch`다(다중 태깅).
export type Source<T> = State<T> & {
Revision: number,
Set: (self: Source<T>, v: T) -> Source<T>,
Emit: (self: Source<T>) -> Source<T>,
}
-- 예약 키 진단 — `keyof<T>`(키 싱글톤 유니온)만 받는다(`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>` — `T`는 `{hp: Source<number>, …}` 그대로(평범한 레코드, 타입 함수 없음).
-- `__reservedCheck`는 팬텀(런타임 nil, 읽지 말 것, `Names()`에 안 들어감).
export type Store<T> = T & {
Of: <U>(self: any, name: string) -> Source<U>,
Names: (self: any) -> { string },
__reservedCheck: CheckReservedKeys<keyof<T>>,
}
export type Quad = { export type Quad = {
Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것
debug: boolean, debug: boolean,
@ -58,6 +107,9 @@ export type Quad = {
Relate: () -> Relate, Relate: () -> Relate,
Void: (...any) -> (), Void: (...any) -> (),
Ref: <T>(default: T) -> Ref<T>, Ref: <T>(default: T) -> Ref<T>,
-- 단위 2. `State`는 런타임 생성자가 없다(파생 전용) — 타입만 위에서 export.
Source: <T>(v: T) -> Source<T>,
Store: <T>(defaults: T?) -> Store<T>,
-- 생명주기 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)`.