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:
parent
4169d7ba50
commit
1e070e3f1f
16 changed files with 1318 additions and 19 deletions
|
|
@ -836,6 +836,13 @@ deps만 받고 싶어도 `previous`가 2번째 자리를 차지하므로, 그
|
|||
비워둬야 함 — 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`
|
||||
신규(ROADMAP.md M2 반영).** 순서 문제 자체는 위 정정으로 구조적으로
|
||||
풀렸으므로, 스파이크가 실제로 확인할 진짜 불확실성은 (B) 하나로 좁혀짐 —
|
||||
|
|
|
|||
|
|
@ -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<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` 🟢 — 클로저 캐시 규칙의 범위 (①)
|
||||
|
||||
- **어디서**: 이 파일 §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/...`.
|
||||
|
|
|
|||
|
|
@ -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<<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`).
|
||||
|
|
|
|||
|
|
@ -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를 보라").
|
||||
|
|
|
|||
34
ROADMAP.md
34
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<<T>>(name): Source<T>` —
|
||||
- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-18 신설, 2026-08-25 확정]** `store:Of<<T>>(name): Source<T>` —
|
||||
런타임에 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열
|
||||
커링은 기각). **콜론 메소드로 확정**했고, 예약 키
|
||||
(`Of`/`Names`/**`__reservedCheck`** — **[2026-08-26 `/code-review high`]**
|
||||
|
|
@ -412,10 +412,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
돈다 — `base/store-plan.md`의 "타입 추론 문제" 절).
|
||||
`<<T>>`가 값 호출부에서 실제로 `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<T>`**(키 싱글톤 유니온)를 받아
|
||||
예약 키를 검증만 하고, 팬텀 필드 `__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<T>`를
|
||||
- [x] **[2026-08-28 완료 — 단위 2]** **[2026-08-25 신설]** **명시적 초기화** — 타입 인자에 `Source<T>`를
|
||||
직접 쓰고 `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<T>) -> 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<T>`를 요구하지만 `--!nocheck`/동적
|
||||
코드가 raw 값을 넘기면 지금 스케치(`table.clone`)는 조용히 받고 첫
|
||||
|
|
|
|||
127
quad-base/src/EpochMap.luau
Normal file
127
quad-base/src/EpochMap.luau
Normal 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
90
quad-base/src/Source.luau
Normal 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
202
quad-base/src/State.luau
Normal 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
87
quad-base/src/Store.luau
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
125
quad-base/test/spec.epochmap.luau
Normal file
125
quad-base/test/spec.epochmap.luau
Normal 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 ===")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
101
quad-base/test/spec.source.luau
Normal file
101
quad-base/test/spec.source.luau
Normal 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 ===")
|
||||
326
quad-base/test/spec.state.luau
Normal file
326
quad-base/test/spec.state.luau
Normal 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 ===")
|
||||
123
quad-base/test/spec.store.luau
Normal file
123
quad-base/test/spec.store.luau
Normal 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 ===")
|
||||
|
|
@ -46,6 +46,55 @@ export type 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 = {
|
||||
Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것
|
||||
debug: boolean,
|
||||
|
|
@ -58,6 +107,9 @@ export type Quad = {
|
|||
Relate: () -> Relate,
|
||||
Void: (...any) -> (),
|
||||
Ref: <T>(default: T) -> Ref<T>,
|
||||
-- 단위 2. `State`는 런타임 생성자가 없다(파생 전용) — 타입만 위에서 export.
|
||||
Source: <T>(v: T) -> Source<T>,
|
||||
Store: <T>(defaults: T?) -> Store<T>,
|
||||
|
||||
-- 생명주기 4종 — quad-base는 인터페이스(에러 스텁)만, 백엔드가 주입
|
||||
-- (`.claude/base/lifecycle-pattern.md`). `canBound(v) == not canExecute(v)`.
|
||||
|
|
|
|||
Loading…
Reference in a new issue