feat(m2): 첫 단위 공통 기반 — Void/Brand/LifetimeHandle/Ref 최소형 + mock 생명주기 + spec 5개

- quad-base/src: Void.luau(단일 no-op), Brand.luau(Brand() + 브랜드 인스턴스 15 +
  M2 is* 11), LifetimeHandle.luau(InitLifetimeHandle — 모듈 인스턴스에 영어 level 2
  에러 스텁 4종), Ref.luau(.Value/.Revision/:Set/:Callback/:WeakCallback/:Uncallback,
  EpochBrand+RefBrand), init.luau 재export. Relate.luau는 타입만 quad-types에서 재export.
- quad-types: Quad에 M2 첫 단위 탑레벨 값 + Ref<T>/RefCallback<T>/Relate/Epoch 타입.
- test/mock.luau: installLifetime(quad) — lifecycle-pattern.md (0)/(1) 스케치 그대로,
  Destroy가 모든 Connection을 끊도록 보강(gcconn 판정 근거).
- scripts/test.sh: spec.* 수집 + luau-analyze(src/spec/mock). 전부 ALL PASS, analyze 0건.
- 발견 ①: H-165 pesde shim은 생성 시점 export 타입만 안다(project-setup-plan.md),
  H-166 Ref.Revision 초기값 0(ref-plan.md). ROADMAP 공통 기반 체크박스 완료 표기.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey 2026-08-28 18:10:50 +09:00
parent 92721d768f
commit d9898d6629
Signed by: qwreey
GPG key ID: D28DB79297A214BD
20 changed files with 1214 additions and 19 deletions

View file

@ -248,7 +248,14 @@ Rojo/Studio가 실제로 소비하는 게 그 경로이고 위에서 확인했
(8라운드에서 실제로 실행해 확인). 지금 규칙은 하나다 — **테스트는
`./scripts/test.sh`로 돌린다**(그 스크립트가 `relink.sh`를 먼저 부른다).
그냥 `luau`로 돌리면 스모크가 죽고 `luau-analyze`는 모듈을 `any`로 떨어뜨려
**조용히 통과**한다("거짓 클린"). Luau의
**조용히 통과**한다("거짓 클린"). **[2026-08-28 M2 첫 단위, `H-165`] 둘째
함정 — `quad-types``export type`을 추가하면 `pesde install`을 다시 돌려야
한다.** pesde가 만드는 링크 파일(`quad-base/roblox_packages/quad_types.luau`)은
`return module` 위에 **그 시점에 존재하던 export 타입만** `export type X =
module.X`로 손으로 나열한 shim이라, `quad-types/src/init.luau`에 타입을 새로
export해도 shim을 재생성하기 전엔 `QuadTypes.Ref` 같은 참조가 "Unknown type"으로
죽는다(`relink.sh`는 복사만 갱신하지 shim은 못 고친다 — 실측). `test.sh`
**[2026-08-28]** `luau-analyze`도 같이 돌리므로 이 실패는 조용하지 않다. Luau의
`.luaurc` symlink opt-in 토글이 미래에 생기면 이 절 전체가 불필요해짐 —
그때 다시 볼 것.

View file

@ -430,7 +430,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
- **`Ref<T>`에 공개 필드 `Revision: number`가 생긴다.** `:Set()`
`Source`와 **같은 한 줄**로 갱신한다 — `self.Revision = bit32.bnot(-self.Revision)`
(그 문서 §2의 랩어라운드 감소). 공개여야 구조적 만족이 타입 레벨에서
성립한다.
성립한다. **[2026-08-28 M2 첫 단위, `H-166`] 초기값은 `0`** — 어느 문서도
적지 않았던 것을 구현이 정했다. 계약은 `==`/`~=`뿐이라 값 자체는 무관하고,
`:Set``4294967295`로 감는 것까지 테스트(`spec.ref.luau`)가 고정한다.
- **`EpochBrand:register(self)`** — `Source``SourceBrand`이면서 동시에
`EpochBrand`인 것과 같은 다중 태깅(`base/brand-plan.md`).
- **`.Callbacks`(푸시 경로)는 그대로다** — `Epoch`는 **부기**일 뿐,

View file

@ -14,7 +14,8 @@
| 번호 | 갈래 | 단위 | 심각도 | 한 줄 | 상태 |
|---|---|---|---|---|---|
| — | — | — | — | **[2026-08-28 기준] 아직 발견 없음** | — |
| `H-165` | ① | 1 | 🟡 | `quad-types``export type`을 더하면 pesde shim이 그걸 모른다 — `pesde install` 재실행 없이는 `QuadTypes.Ref`가 Unknown type | ✅ 반영(`project-setup-plan.md`) |
| `H-166` | ① | 1 | 🟢 | `Ref.Revision` 초기값을 어느 문서도 안 정했다 | ✅ 반영(`ref-plan.md`: `0`) |
## 상세
@ -22,6 +23,26 @@
(파일:줄 / `base/` 절), (2) 무엇이, (3) 문서가 이미 답을 갖고 있는가, (4) 어떻게
처리했는가(①이면 커밋 해시).)
### 단위 1 — 공통 기반 (2026-08-28)
### `H-165` 🟡 — pesde shim은 생성 시점의 export 타입만 안다
- **어디서**: `quad-base/roblox_packages/quad_types.luau`(pesde 생성물, 커밋 안 됨) /
`base/project-setup-plan.md``test.sh` 절.
- **무엇이**: `quad-types/src/init.luau``Ref<T>`/`Relate`/`Epoch`를 `export type`으로
추가하고 `test.sh`를 돌리자 `luau-analyze``Unknown type 'QuadTypes.Ref'`. shim이
`export type Quad = module.Quad` / `CheckedQuad`만 손으로 나열한 파일이라
`return module`로는 타입이 안 넘어온다. `relink.sh`는 복사만 갱신한다.
- **문서가 답을 갖고 있었나**: 아니다 — 첫 함정(심볼릭 링크)만 적혀 있었다.
- **처리**: `pesde install` 재실행으로 shim 재생성(같은 세션 실측), `project-setup-plan.md`
둘째 함정으로 기록. `test.sh`가 이제 `luau-analyze`를 같이 돌려 조용히 지나가지 않는다.
### `H-166` 🟢 — `Ref.Revision` 초기값이 문서에 없다
- **어디서**: `base/ref-plan.md` "`Ref`는 `Epoch`를 만족한다" 절 / `base/state-epoch-plan.md` §2.
- **무엇이**: 갱신식(`bit32.bnot(-rev)`)과 표만 있고 시작값이 없다.
- **처리**: `0`으로 구현하고 그 절에 한 줄 추가. 계약이 `==`/`~=`뿐이라 값은 무관.
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
| 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? |
@ -35,4 +56,24 @@
(탐사자가 실제로 돌려보고 계약대로였던 자리 — 다음 탐사자가 다시 파지 않게.)
**단위 1 (메인 세션, 2026-08-28)**:
- `Relate.luau`(M1) ↔ `relate-plan.md` "API"/"실제 구조": 4 메서드, lazy 서브테이블,
공유 `{__mode="v"}` 메타테이블, `inst` weak — 전부 일치(`spec.relate.luau`가 고정).
- `lifecycle-pattern.md` (0)/(1) 스케치는 mock 시그널 위에 그대로 돌아간다 — `Destroy`
`gcconn.Connected=false``canExecute` false/`canBound` true, gchold 강참조, 조기
해제, 이중 바인드 게이트 모양. 스케치의 한국어 에러 문구는 같은 문서가 이미
*"실제 문구는 영어"*라 밝힌 자리표시자라 문서 결함 아님(코드는 영어 + `level 2`).
- `ref-plan.md` `:Set` 블록을 한 줄씩 옮겼고 계약 9개가 테스트로 고정됐다. 함수키
dedup(강+약 동시 등록 시 1회)과 순회 중 해제 skip이 실제로 성립한다.
- `brand-plan.md` 합성 술어(`isState = isSource or StateBrand`, `isRef = isPreRef or
isPostRef or RefBrand`)와 weak-key 멤버십 — 성립.
**툴링 사실 둘**(설계 아님, 다음 단위가 알아야 함):
- `require("@self/X")`**`init.luau`에서만** 통한다 — 일반 파일에서 `@self`는 그 파일
자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`.
- GC 테스트 함정 둘: 같은 프레임의 죽은 레지스터가 임시값을 붙잡는다(별도 함수 안에서
만들 것) / **불변 업밸류만 잡는 클로저는 Luau가 프로토에 캐시해 영영 GC되지 않는다**
(테스트 클로저가 업밸류를 직접 변경하게 할 것 — `lifecycle-pattern.md` (0)의
`false or` 트릭이 막는 것과 같은 최적화).
## §6 남은 의심 / 못 본 것

View file

@ -2015,3 +2015,12 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
이중 claim은 셋업 유무로 error(*"claim 은 slot 이랑 무관"* — 리뷰 전제 기각) /
**`PlayerGui`는 공동 소유 객체라 claim 대상 아님**, 루트는 `ScreenGui`·`SurfaceGui` /
`FrameParam<E>` 원소 타입 파라미터.
- **`session/2026-08-28-03-m2-unit1-common-base.md`** — **M2 착수.** 다른 에이전트가
초안한 자율 구현 규약을 검토해 순서 오류 하나(`EpochMap`이 `Effect` 뒤 → State 본체
앞)와 소스 단일화를 고쳐 채택(`qa-request/pre-implementation-handtrace-round11-brief.md`,
세 갈래 분류 / 단위 넷 / 두 층 커밋 게이트, `HUMAN_TODO.md` 2번 닫힘). 첫 단위(공통
기반)를 사용자 확인(*"진행하면 될것 같아"*) 뒤 구현 — `Void`/`Brand`(인스턴스 15 +
`is*` 11 한 잎 파일)/`LifetimeHandle`(`InitLifetimeHandle` 에러 스텁)/`Ref` 최소형/
`quad-types` 타입/mock `installLifetime`(+`Destroy`가 Connection 전부 끊음)/spec 5개,
`test.sh``spec.*`+`luau-analyze`. 발견 `H-165`(pesde shim은 생성 시점 export만 —
`pesde install` 재실행)·`H-166`(`Revision` 초기값 0) 둘 다 ①. ②/③ 없음.

View file

@ -0,0 +1,39 @@
# 2026-08-28 (03) — M2 착수: 자율 구현 규약 채택 + 첫 단위(공통 기반) 구현
## 경위
- 다른 에이전트가 초안한 "M2 자율 구현 규약" 프롬프트를 사용자가 가져와 *"어떻게
봐? 진행 하면 될것같아?"* — 대조 결과 **순서 오류 하나**(`EpochMap`이 `Effect` 뒤;
`ROADMAP.md` "반응형 본체"는 `EpochMap`이 State 본체보다 먼저라 못 박음)와 소스
단일화 몇 건(규약 위치는 `base/`가 아니라 `conventions.md` 한 줄 + brief 파일 /
`Void` 체크박스 부재 / `HUMAN_TODO.md` 2번 / 커밋 게이트 두 층 / `TODO(H-nnn)`
마커 형식 / 단위를 넷으로)을 지적. 사용자: *"수정하고 너가 진행하자. epochmap
순서 하나 고치고 진행할 수 있겠니?"* → 규약 커밋 `f94234a`.
- 첫 단위 계획(§6)의 배치 결정 셋(브랜드 인스턴스를 `Brand.luau` 한 파일에 / mock
생명주기를 `mock.luau` 안에 / 테스트 `spec.*` + analyze)을 보여주고 사용자
*"진행하면 될것 같아"*`92721d7`.
## 구현 (전부 `./scripts/test.sh` ALL PASS, `luau-analyze` 0건)
- `Void.luau` / `Brand.luau`(생성자 + 인스턴스 15 + `is*` 11) / `LifetimeHandle.luau`
(`InitLifetimeHandle` — 모듈 인스턴스에 에러 스텁 4종) / `Ref.luau` 최소형 /
`init.luau` 재export / `quad-types``Quad`·`Ref<T>`·`Relate`·`Epoch` 타입 /
`Relate.luau`는 타입만 `quad-types`에서 재export(구현 무변경, 대조 일치).
- `test/mock.luau`: `installLifetime(quad)`(`lifecycle-pattern.md` (0)/(1) 스케치
그대로) + `Destroy`가 모든 Connection을 끊도록 보강(gcconn 판정의 근거).
- `scripts/test.sh`: `spec.*` 수집 + `luau-analyze quad-base/src + spec + mock`.
- spec 5개(brand/relate/lifetime/ref/void).
## 발견 (`qa-request/pre-implementation-handtrace-round11.md`)
- `H-165` ① pesde shim은 생성 시점 export 타입만 안다 → `pesde install` 재실행,
`project-setup-plan.md`에 둘째 함정으로 기록.
- `H-166``Ref.Revision` 초기값 미정 → `0`, `ref-plan.md`.
- 툴링 사실: `@self``init.luau` 전용 / GC 테스트 함정 둘(죽은 레지스터, 불변
업밸류 클로저 캐시) — §5.
- ②/③ 갈래 발견 **없음** — §4 표는 비어 있다.
## 다음
단위 끝 절차(규약 §4): 감사 루프 → `/code-review high` → 커밋 → fable 탐사자 →
사용자에게 "§4를 보라".

View file

@ -272,7 +272,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> 여기 있는 것 전부 State-free이자 dispatch-free라 어느 쪽에도 안 걸립니다.
> 이 절이 끝나야 아래 반응형 본체를 짤 수 있습니다.
- [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`
- [x] **[2026-08-28 완료 — `quad-base/src/Brand.luau` + `test/spec.brand.luau`, 브랜드 인스턴스 15개와 M2 `is*` 11개가 이 잎 파일에]** `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`
브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`,
**다중 태깅 허용**(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`).
옛 공유 레지스트리 + `Brand.get(x) -> tag`
@ -302,7 +302,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
않는다"*로 확정 — `isNone``None.luau`(M3) 쪽에 산다. 이 마일스톤
분리로 처음 눈에 띈 잔재를 **[2026-08-24]** 정정한 것) —
`brand-plan.md``Brand` 절, 2026-08-07 여덟 번째 세션 신설)
- [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) —
- [x] **[2026-08-28 완료 — `relate-plan.md` 대조 일치, `test/spec.relate.luau`, 타입은 `quad-types`로 옮겨 재export]** `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) —
**[2026-08-28 확인] 파일은 M1 커밋 `205af32`에 이미 있다**(`RunInit`이
쓴다) — 이 체크박스의 남은 일은 `base/relate-plan.md` 대조와 테스트뿐.
`Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`.
@ -310,7 +310,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
생성(첫 `Set` 호출 시에만), `WeakMap`은 공유 메타테이블(`{__mode="v"}`)
재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를
대체(2026-08-08 세션 신설).
- [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/
- [x] **[2026-08-28 완료 — `InitLifetimeHandle(module)`이 모듈 인스턴스에 영어 `level 2` 에러 스텁 4종 설치, `test/spec.lifetime.luau`]** `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/
`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨
함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래
M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의
@ -347,7 +347,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M2 체크박스
참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라
이 게이트를 그대로 탐
- [ ] **⭐ [2026-08-27 9라운드 `H-128` 신설] `Ref.luau` 최소형** — 아래
- [x] **[2026-08-28 완료 — `quad-base/src/Ref.luau` + `test/spec.ref.luau`(`:Set` 순서·`bit32` 랩·dedup·weak GC·스냅샷 순회·thread 소진)]** **⭐ [2026-08-27 9라운드 `H-128` 신설] `Ref.luau` 최소형** — 아래
`Effect(fn, ...deps)``Ref` dep 분기(`isRef(d)` →
`d:WeakCallback(onRefFire)``self._epochs:Sync(d)`)가 **M2 안에서
실제로 돌려면** 필요한 표면만: `.Value`/`.Revision`/`:Set(value)`/
@ -363,7 +363,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
**[2026-08-27 `/code-review`]** 아래 `H-80` 탑레벨 목록의 규칙(*"이
마일스톤이 얹는 탑레벨 값 전부"*)대로 **`quad-types``Quad``Ref`
생성자 필드도 여기서** 추가한다 — M8의 `H-25` 체크박스는 이걸로 흡수.
- [ ] **[2026-08-28 `H-162`] `Void`** — 단일 no-op 함수 export. no-op 클로저를
- [x] **[2026-08-28 완료 — `quad-base/src/Void.luau` + `test/spec.void.luau`]** **[2026-08-28 `H-162`] `Void`** — 단일 no-op 함수 export. no-op 클로저를
돌려주는 자리는 새 클로저 대신 이것. 아래 `H-80` 탑레벨 목록에만 있고
여기 체크박스가 없어 "개수·목록은 소스 하나" 규약에 어긋나던 것을 M2
착수 규약 커밋에서 신설.
@ -629,7 +629,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
- [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
- [ ] **[2026-08-28 부분 — 첫 단위분(`Relate`/`Void`/`Ref`/`is*` 11개/생명주기 4종)은 `quad-types` `Quad`에 추가됨, `Source`/`Store`/`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`) /
@ -675,7 +675,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(영어 메시지). 타입은 `Source<T>`를 요구하지만 `--!nocheck`/동적
코드가 raw 값을 넘기면 지금 스케치(`table.clone`)는 조용히 받고 첫
`:Get()`에서 엉뚱한 에러로 죽는다. 생성 시 1회라 hot path 아님
- [ ] **[2026-08-25 신설, `H-97`]** mock 백엔드용 생명주기 4종 최소 구현 —
- [x] **[2026-08-28 완료 — 첫 단위로 당겨서 `test/mock.luau``installLifetime(quad)`, `lifecycle-pattern.md` (0)/(1) 스케치 그대로; mock `Destroy`가 모든 Connection을 끊도록 보강]** **[2026-08-25 신설, `H-97`]** mock 백엔드용 생명주기 4종 최소 구현 —
`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`. 안 하면
**아래 "mock 대상 테스트"가 전파 루프를 한 번도 못 돈다**(루프가 매
발화마다 `canExecute`를 부르는데 그건 M8 구현이고 미주입 슬롯은

136
quad-base/src/Brand.luau Normal file
View file

@ -0,0 +1,136 @@
--[[
Brand — instance brands, one weak-key set per brand.
`.claude/base/brand-plan.md` "구현 — 인스턴스 브랜드" / "`isX` wrapper" sections,
as-is.
`Brand()` makes one brand object holding a weak-key member set; a value
registers itself into the brand(s) it belongs to (multi-tagging is the
point — `Source` is both `SourceBrand` and `EpochBrand`). There is no
reverse lookup (`Brand.get(x) -> tag` was reversed, see
`archive/brand-shared-registry-reversed.md`).
Every brand instance lives in this leaf file (M2 first-unit plan,
`qa-request/pre-implementation-handtrace-round11-brief.md` §6): `EpochBrand`
is shared by `Source`/`Ref`/`GateNode`, so keeping the instances per type
module would force a circular require. Each *type* still registers only
into its own brand at its own construction sites; subtype relations are
expressed once, in the predicate (`isState`/`isRef` below), never by
double registration.
Method names are lowercase (`:register`/`:is`) on purpose — this is a
base-internal utility, not a public quad surface (`brand-plan.md`).
`Brand` depends on nothing. In particular there is no `None` special
case here — `isNone` is `v == None` and lives with `None.luau` (M3).
]]
export type Brand = {
register: (self: Brand, x: any) -> (),
is: (self: Brand, x: any) -> boolean,
}
local function Brand(): Brand
local members = setmetatable({}, { __mode = "k" }) :: { [any]: true }
return {
register = function(_self: Brand, x: any)
members[x] = true
end,
is = function(_self: Brand, x: any): boolean
return members[x] == true
end,
}
end
-- Each type owns exactly one brand. The full list is declared here even for
-- types that arrive in later milestones (`Tag`/`Attribute`/`Tween`/`Slot`)
-- so that the set of brands has one source; their `is*` predicates are added
-- with the type.
local ObserverBrand = Brand()
local EffectBrand = Brand()
local TagBrand = Brand()
local AttributeBrand = Brand()
local TweenBrand = Brand()
local BlockerBrand = Brand()
local StateBrand = Brand()
local SourceBrand = Brand()
local StoreBrand = Brand()
local SlotBrand = Brand()
local RefBrand = Brand()
local PreRefBrand = Brand()
local PostRefBrand = Brand()
local ModifierBrand = Brand()
local EpochBrand = Brand()
-- Plain identity predicates.
local function isEpoch(x: any): boolean
return EpochBrand:is(x)
end
local function isSource(x: any): boolean
return SourceBrand:is(x)
end
local function isStore(x: any): boolean
return StoreBrand:is(x)
end
local function isObserver(x: any): boolean
return ObserverBrand:is(x)
end
local function isEffect(x: any): boolean
return EffectBrand:is(x)
end
local function isBlocker(x: any): boolean
return BlockerBrand:is(x)
end
local function isModifier(x: any): boolean
return ModifierBrand:is(x)
end
-- Subtype relations: the more specific predicate is defined first and the
-- wider one is composed on top of it, so the direction of inclusion is
-- visible in the code (`brand-plan.md`).
local function isState(x: any): boolean
return isSource(x) or StateBrand:is(x) -- Source structurally satisfies State
end
local function isPreRef(x: any): boolean
return PreRefBrand:is(x)
end
local function isPostRef(x: any): boolean
return PostRefBrand:is(x)
end
local function isRef(x: any): boolean
-- PreRef/PostRef reuse the Ref runtime = both are a kind of Ref.
-- They are exclusive siblings of each other.
return isPreRef(x) or isPostRef(x) or RefBrand:is(x)
end
return {
Brand = Brand,
ObserverBrand = ObserverBrand,
EffectBrand = EffectBrand,
TagBrand = TagBrand,
AttributeBrand = AttributeBrand,
TweenBrand = TweenBrand,
BlockerBrand = BlockerBrand,
StateBrand = StateBrand,
SourceBrand = SourceBrand,
StoreBrand = StoreBrand,
SlotBrand = SlotBrand,
RefBrand = RefBrand,
PreRefBrand = PreRefBrand,
PostRefBrand = PostRefBrand,
ModifierBrand = ModifierBrand,
EpochBrand = EpochBrand,
isEpoch = isEpoch,
isSource = isSource,
isState = isState,
isStore = isStore,
isObserver = isObserver,
isEffect = isEffect,
isBlocker = isBlocker,
isModifier = isModifier,
isRef = isRef,
isPreRef = isPreRef,
isPostRef = isPostRef,
}

View file

@ -0,0 +1,44 @@
--[[
InitLifetimeHandle(module) — the *interface* of the four top-level
lifetime primitives, installed on the module instance as error stubs.
`.claude/base/lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/
`unbindLifetime` — 확정" section; injection rule from
`.claude/base/module-lifecycle-plan.md` ("base 유틸(... 생명 바인드 유틸)이
인터페이스만 두고 실제 구현은 백엔드 팩토리가 뮤테이션으로 주입").
Contract (types in `quad-types`):
bindLifetime(inst, value) -- inst is needed here and only here
unbindLifetime(value) -- early release of one value; no-op if unbound
canBound(value) -> boolean -- "may I bind this now?" true = not bound anywhere
canExecute(value)-> boolean -- "may this fire now?" emit-propagation gate
`canBound(v) == not canExecute(v)` — both wrap one private predicate
(`isBoundAlive`) that belongs to the backend, not here.
quad-base knows nothing about Instances, so these slots default to stubs
that error loudly (never a silent no-op — base cannot guess an arbitrary
engine's "right" default). quad-roblox overwrites them on the module
instance (M8); tests overwrite them with the mock backend
(`quad-base/test/mock.luau` `installLifetime`, ROADMAP `H-97`).
They are flat top-level functions, not `LifetimeHandle.bind(...)` —
first-class primitives handler authors call directly, like `isState`.
]]
local function notInstalled(name: string): (...any) -> ...any
return function(...: any)
error(
`quad: {name} is not available — no backend has installed the lifetime primitives `
.. "(quad-roblox does this when it wraps the module; tests use mock.installLifetime)",
2
)
end
end
local function Init(module: any)
module.bindLifetime = notInstalled("bindLifetime")
module.unbindLifetime = notInstalled("unbindLifetime")
module.canBound = notInstalled("canBound")
module.canExecute = notInstalled("canExecute")
end
return Init

113
quad-base/src/Ref.luau Normal file
View file

@ -0,0 +1,113 @@
--[[
Ref — general value box. Minimal M2 form (`ROADMAP.md` M2 "공통 기반",
9라운드 `H-128`): `.Value` / `.Revision` / `:Set` / `:Callback` /
`:WeakCallback` / `:Uncallback` + `isRef`, and `EpochBrand` membership.
`:Wait`, `PreRef`/`PostRef` and the dispatch handlers stay in M8.
Sources, as-is:
- `.claude/base/ref-plan.md` "API 모양" (surface, self-returning
mutations, "already filled → call once on registration"),
"`.Callbacks`는 … 해시맵 셋" (dedup contract, snapshot before
iterating), "`:WeakCallback(fn)`" (weak-key table `.WeakCallbacks`,
Weak is the primitive and `:Callback` only adds the GC keep),
"`:Set(value)`의 순서" (value → revision → callbacks; the code below
is that block), "`Ref`는 `Epoch`를 만족한다" (`.Revision`, brand).
- `.claude/base/state-epoch-plan.md` §2: revision bump is the uint32
wrap-around decrement `bit32.bnot(-rev)`; only `==`/`~=` are contract.
Callback signature is `fn(value, ref)` — the second argument is the Ref
itself, i.e. the `Epoch` a consumer (`Effect`) feeds to `EpochMap:Update`.
Plain user callbacks just ignore it.
`Ref` is NOT a State: no emit propagation, no `Get`/`Compute`. Being an
`Epoch` only means "carries a mark distinguishable from the previous one".
]]
local Brand = require("./Brand")
local QuadTypes = require("../roblox_packages/quad_types")
export type Ref<T> = QuadTypes.Ref<T>
type Callback<T> = QuadTypes.RefCallback<T>
local RefBrand = Brand.RefBrand
local EpochBrand = Brand.EpochBrand
local WEAK_KEY_MT = { __mode = "k" }
local RefImpl = {}
RefImpl.__index = RefImpl
function RefImpl.Set<T>(self: Ref<T>, value: T?): Ref<T>
self.Value = value -- (1) settle the value first
self.Revision = bit32.bnot(-self.Revision) -- (2) before callbacks (`H-108`)
-- (3) snapshot before iterating (`H-23`): registering/unregistering
-- during the walk must not disturb `pairs`.
local snapshot: { any } = {}
local callbacks = self.Callbacks
local weakCallbacks = self.WeakCallbacks
for k in pairs(callbacks) do
table.insert(snapshot, k)
end
for k in pairs(weakCallbacks) do
-- A key present in both tables is carried once — otherwise that
-- callback fires twice. Function keys only: `:Wait()` (M8) puts its
-- thread waiters in `.Callbacks` only, so draining below is right.
if callbacks[k] == nil then
table.insert(snapshot, k)
end
end
for _, k in ipairs(snapshot) do
if callbacks[k] == nil and weakCallbacks[k] == nil then
continue -- released while we were walking
end
if type(k) == "thread" then
callbacks[k] = nil -- waiters are consumed
coroutine.resume(k, self) -- the Ref itself, not the value
else
-- value + the Ref itself (= `Epoch`). The cast only quiets the checker:
-- `k` comes from a `{ any }` snapshot and the `thread` branch is above.
(k :: Callback<T>)(value, self)
end
end
return self
end
-- "Weak" is the primitive: same behaviour, same guards, only the GC keep
-- removed. Registration calls the callback once right away with whatever
-- is there — nil/unset included (`H-120`; the nil guard is the caller's,
-- `base/lifecycle-hooks-plan.md`).
function RefImpl.WeakCallback<T>(self: Ref<T>, fn: Callback<T>): Ref<T>
self.WeakCallbacks[fn] = true
fn(self.Value, self)
return self
end
function RefImpl.Callback<T>(self: Ref<T>, fn: Callback<T>): Ref<T>
self.Callbacks[fn] = true -- the strong keep, on top of the weak primitive
fn(self.Value, self)
return self
end
-- Detaches a user-registered callback. Both tables — a weakly registered
-- callback is otherwise impossible to remove and keeps firing.
-- Dedup on registration means there is never a "how many times" question.
function RefImpl.Uncallback<T>(self: Ref<T>, fn: Callback<T>): Ref<T>
self.Callbacks[fn] = nil
self.WeakCallbacks[fn] = nil
return self
end
local function Ref<T>(default: T?): Ref<T>
local self = setmetatable({
Value = default,
Revision = 0,
Callbacks = {},
WeakCallbacks = setmetatable({}, WEAK_KEY_MT),
}, RefImpl)
RefBrand:register(self)
EpochBrand:register(self) -- multi-tagging: a Ref is also an Epoch
return (self :: any) :: Ref<T>
end
return Ref

View file

@ -3,12 +3,10 @@
`.claude/base/relate-plan.md` "API"/"실제 구조" 절 그대로 구현.
]]
export type Relate = {
SetStrong: (self: Relate, inst: any, key: any, value: any) -> (),
GetStrong: (self: Relate, inst: any, key: any) -> any?,
SetWeak: (self: Relate, inst: any, key: any, value: any) -> (),
GetWeak: (self: Relate, inst: any, key: any) -> any?,
}
local QuadTypes = require("../roblox_packages/quad_types")
-- 타입의 소스는 `quad-types`(`Quad.Relate` 필드가 같은 타입을 써야 함) — 여기선 재export만.
export type Relate = QuadTypes.Relate
type Bucket = {
StrongMap: { [any]: any }?,

14
quad-base/src/Void.luau Normal file
View file

@ -0,0 +1,14 @@
--[[
Void — the single no-op function quad exports.
`.claude/base/architecture.md` source tree `Void.luau` line / `H-162`.
Every place that would otherwise hand out a fresh `function() end`
(handler retractors, cleanup slots, ...) returns this one value instead.
Dependency-free leaf on purpose: `Dispatch/init.luau` (M3) binds it at
file scope, so defining it on the top-level `init.luau` would be a
circular require.
]]
local function Void(...: any) end
return Void

View file

@ -14,8 +14,12 @@
]]
local Relate = require("@self/Relate")
local Brand = require("@self/Brand")
local Ref = require("@self/Ref")
local Void = require("@self/Void")
local QuadTypes = require("./roblox_packages/quad_types")
local InitDebug = require("@self/Debug")
local InitLifetimeHandle = require("@self/LifetimeHandle")
type Quad = QuadTypes.Quad
@ -27,6 +31,22 @@ local function New(): Quad
local module = {
New = New,
Version = "0.0.0",
-- M2 공통 기반 — 의존 없는 잎 모듈 재export(`ROADMAP.md` M2 `H-80`)
Relate = Relate,
Void = Void,
Ref = Ref,
isEpoch = Brand.isEpoch,
isSource = Brand.isSource,
isState = Brand.isState,
isStore = Brand.isStore,
isObserver = Brand.isObserver,
isEffect = Brand.isEffect,
isBlocker = Brand.isBlocker,
isModifier = Brand.isModifier,
isRef = Brand.isRef,
isPreRef = Brand.isPreRef,
isPostRef = Brand.isPostRef,
} :: Quad
function module.RunInit(self, initFn)
@ -46,6 +66,7 @@ local function New(): Quad
end
module:RunInit(InitDebug)
module:RunInit(InitLifetimeHandle) -- 생명주기 4종 에러 스텁 — 백엔드가 덮어씀
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
return module

View file

@ -10,11 +10,25 @@
엔진 userdata의 GC 동일성을 흉내내려는 것이고, 그 동일성 문제는
quad-roblox의 LifetimeHandle(gcconn 트릭)이 다루는 자리라 quad-base
정적 스냅샷 테스트 범위 밖.
**[2026-08-28 M2, ROADMAP `H-97`] `installLifetime(quad)`** — 생명주기 4종
(`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`)의 mock 백엔드.
quad-roblox가 할 모듈 뮤테이션을 그대로 흉내내며, 본문은
`.claude/base/lifecycle-pattern.md` (0)/(1)의 실 구현 스케치를 그대로 옮긴
것(gcconn = 절대 안 발화하는 `ClassName` 변경 시그널의 Connection,
gchold = inst에 매달린 값들의 강참조 홀더, `InstData`/`BindData` 두 Relate).
이걸 위해 `Destroy`가 Roblox처럼 **그 인스턴스의 모든 Connection을 끊는다**
(`Connected = false`) — gcconn의 `.Connected`가 곧 생존 판정이라서.
Roblox와 다른 점 하나: gcconn/gchold 셋업(`nativeClaim`)은 quad가 Instance를
만들 때 하는 일인데 mock Instance는 quad 밖에서 만들어지므로 여기선
`bindLifetime` 첫 호출에 lazy로 한다 — quad-roblox는 그러면 안 된다
(userdata 동일성 구멍, 같은 문서 (0) 절).
]]
export type Signal = {
Connect: (self: Signal, fn: (...any) -> ()) -> Connection,
Fire: (self: Signal, ...any) -> (),
DisconnectAll: (self: Signal) -> (),
}
export type Connection = {
@ -46,6 +60,15 @@ function Signal:Connect(fn: (...any) -> ()): Connection
return (conn :: any) :: Connection
end
-- Destroy가 부른다 — Roblox는 Destroy 시 그 인스턴스의 모든 연결을 끊는다.
function Signal:DisconnectAll()
local connections = (self :: any).connections
for i = #connections, 1, -1 do
connections[i].Connected = false
connections[i] = nil
end
end
function Signal:Fire(...: any)
-- 발화 도중 연결이 끊기는 경우를 대비해 스냅샷을 뜬 뒤 순회(Vide 선례)
local snapshot = table.clone((self :: any).connections)
@ -205,6 +228,12 @@ end
function methods.Destroy(inst: any)
local data = getData(inst)
data.destroying:Fire()
-- Roblox처럼 이 인스턴스의 모든 연결을 끊는다(Destroying 포함) —
-- gcconn(`ClassName` 변경 시그널)의 `.Connected`가 생존 판정의 근거.
data.destroying:DisconnectAll()
for _, sig in data.changed do
sig:DisconnectAll()
end
if data.parent then
local siblings = data.parent.children
local i = table.find(siblings, data)
@ -220,8 +249,121 @@ local function isMockInstance(value: any): boolean
return dataOf[value] ~= nil
end
--[[
installLifetime(quad) — `.claude/base/lifecycle-pattern.md` (0)/(1) 스케치를
mock 시그널 위에 그대로 옮김. quad 모듈 인스턴스를 뮤테이션한다(quad-roblox와
같은 경로). 반환값은 없고, 같은 quad에 두 번 부르면 두 번째는 무시된다.
]]
local Relate = require("../src/Relate")
local installed = setmetatable({}, { __mode = "k" }) :: { [any]: true }
local function installLifetime(quad: any)
if installed[quad] then
return
end
installed[quad] = true
local isObserver, isEffect = quad.isObserver, quad.isEffect
local InstData = Relate() -- inst -> gchold/gcconn ((0)에서 채움)
local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움)
-- `false or`: local 함수가 상수 접힘/인라인되면 아래 클로저의 업밸류 캡처가 사라진다
local nop = (false or function(...: any) end) :: (...any) -> ()
-- (0) nativeClaim 상당 — mock에선 lazy(헤더 주석 참고)
local function claim(inst: any)
if InstData:GetWeak(inst, "gchold") ~= nil then
return
end
local gchold = {}
local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
nop(gchold, inst) -- 절대 발화 안 함. 클로저가 gchold와 inst를 업밸류로 붙잡는 게 전부
end)
gchold[1] = gcconn -- 배열 자리 1번은 gcconn 전용(값들은 해시 자리에)
InstData:SetWeak(inst, "gchold", gchold)
InstData:SetWeak(inst, "gcconn", gcconn)
end
-- 비공개 — canBound/canExecute가 공유하는 실제 판정.
local function isBoundAlive(value: any): boolean
-- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음
local gcconn = BindData:GetWeak(value, "gcconn")
if gcconn ~= nil and gcconn.Connected then
return true
end
-- (b) 전역 경로: 구독 경로(강/약)가 세운 것. Observer/Effect에만 있는 필드.
if isObserver(value) or isEffect(value) then
return value.Subscribed == true
end
return false
end
local function canBound(value: any): boolean
return not isBoundAlive(value)
end
local function canExecute(value: any): boolean
return isBoundAlive(value)
end
local function bindLifetime(inst: any, value: any)
if not isMockInstance(inst) then
error("bindLifetime: inst is not a mock instance", 2)
end
if not canBound(value) then
-- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건
-- 인덱싱하면 안 됨 — value가 평범한 클로저일 수도 있음.
local isGlobal = isObserver(value) or isEffect(value)
if isGlobal then
isGlobal = value.Subscribed == true
end
error(
if isGlobal
then "bindLifetime: value is already subscribed"
else "bindLifetime: value is already bound to another Instance",
2
)
end
claim(inst)
local gchold = InstData:GetWeak(inst, "gchold")
gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1)
-- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2). 둘 다 weak.
BindData:SetWeak(value, "gchold", gchold)
BindData:SetWeak(value, "gcconn", InstData:GetWeak(inst, "gcconn"))
if isObserver(value) then
value:_catchUp() -- [H-159] 묶이기 전 홀드된 emit 1회 (단위 3에서 합류)
end
if isEffect(value) then
value:_bindDestroying(inst) -- Destroying 연결 + 홀드 캐치업 1회 (단위 3)
end
end
local function unbindLifetime(value: any)
-- bind의 대칭 — cleanup은 부르지 않고, Ref 콜백/내부 Observer도 안 뗀다.
if isEffect(value) then
value:_unbindDestroying() -- Destroying 연결만 끊는다 (단위 3)
end
local gchold = BindData:GetWeak(value, "gchold")
if gchold then
gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제
end
BindData:SetWeak(value, "gchold", nil)
BindData:SetWeak(value, "gcconn", nil)
end
quad.bindLifetime = bindLifetime
quad.unbindLifetime = unbindLifetime
quad.canBound = canBound
quad.canExecute = canExecute
end
return {
Instance = Instance,
newSignal = newSignal,
isMockInstance = isMockInstance,
installLifetime = installLifetime,
}

View file

@ -0,0 +1,109 @@
--[[ Brand 계약 — `.claude/base/brand-plan.md` "구현 — 인스턴스 브랜드" / "`isX` wrapper" ]]
local Brand = require("../src/Brand")
local Quad = require("../src")
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
print("=== 1. register/is — 등록한 값만 통과 ===")
do
local b = Brand.Brand()
local x, y = {}, {}
b:register(x)
assert(b:is(x) == true, "registered value must pass")
assert(b:is(y) == false, "unregistered value must not pass")
assert(b:is(nil) == false and b:is(1) == false and b:is("s") == false, "non-table values are never members")
print("PASS")
end
print()
print("=== 2. 다중 태깅 — 한 값이 여러 브랜드에 동시에 ===")
do
local a, b = Brand.Brand(), Brand.Brand()
local x = {}
a:register(x)
b:register(x)
assert(a:is(x) and b:is(x), "a value may belong to several brands at once")
print("PASS")
end
print()
print("=== 3. 브랜드 간 독립 — 한 브랜드 등록이 다른 브랜드에 안 샘 ===")
do
local a, b = Brand.Brand(), Brand.Brand()
local x = {}
a:register(x)
assert(b:is(x) == false, "registration must not leak across brands")
print("PASS")
end
print()
print("=== 4. weak-key — 등록만으로는 값이 살아남지 않음 ===")
do
local b = Brand.Brand()
local weak = setmetatable({}, { __mode = "v" })
do
local x = {}
b:register(x)
weak[1] = x
end
collectgarbage()
collectgarbage()
assert(weak[1] == nil, "brand membership must not keep the value alive")
print("PASS")
end
print()
print("=== 5. isRef 계층 — PreRef/PostRef는 Ref의 한 종류, 서로는 배타 ===")
do
local pre, post, plain = {}, {}, {}
Brand.PreRefBrand:register(pre)
Brand.PostRefBrand:register(post)
Brand.RefBrand:register(plain)
assert(Brand.isRef(pre) and Brand.isRef(post) and Brand.isRef(plain), "isRef passes {Ref, PreRef, PostRef}")
assert(Brand.isPreRef(pre) and not Brand.isPreRef(post) and not Brand.isPreRef(plain), "isPreRef is the most specific identity")
assert(Brand.isPostRef(post) and not Brand.isPostRef(pre) and not Brand.isPostRef(plain), "isPostRef is the most specific identity")
-- Leaf 매치 핸들러가 쓰는 좁힘(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)
local function isPlainRef(v)
return Brand.isRef(v) and not Brand.isPreRef(v) and not Brand.isPostRef(v)
end
assert(isPlainRef(plain) and not isPlainRef(pre) and not isPlainRef(post), "plain-Ref narrowing")
print("PASS")
end
print()
print("=== 6. isState 계층 — Source는 State를 구조적으로 만족 ===")
do
local src, st = {}, {}
Brand.SourceBrand:register(src)
Brand.StateBrand:register(st)
assert(Brand.isState(src) and Brand.isState(st), "isState passes both")
assert(Brand.isSource(src) and not Brand.isSource(st), "isSource is the narrower one")
print("PASS")
end
print()
print("=== 7. 단순 항등 술어 + Quad 탑레벨 재export ===")
do
local pairsToCheck: { { brand: Brand.Brand, name: string } } = {
{ brand = Brand.EpochBrand, name = "isEpoch" },
{ brand = Brand.StoreBrand, name = "isStore" },
{ brand = Brand.ObserverBrand, name = "isObserver" },
{ brand = Brand.EffectBrand, name = "isEffect" },
{ brand = Brand.BlockerBrand, name = "isBlocker" },
{ brand = Brand.ModifierBrand, name = "isModifier" },
}
for _, pair in pairsToCheck do
local brand, name = pair.brand, pair.name
local x = {}
assert((Quad :: any)[name] == (Brand :: any)[name], name .. " must be re-exported on Quad as the same function")
assert((Brand :: any)[name](x) == false, name .. " false before registration")
brand:register(x)
assert((Brand :: any)[name](x) == true, name .. " true after registration")
end
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,139 @@
--[[
LifetimeHandle 계약 — `.claude/base/lifecycle-pattern.md` "`bindLifetime`/`canBound`/
`canExecute`/`unbindLifetime` — 확정" 절 + mock 백엔드(`mock.luau` installLifetime, ROADMAP `H-97`).
Observer/Effect의 `.Subscribed` 경로는 단위 3(Observer/Effect)에서 합류.
]]
local Quad = require("../src")
local mock = require("./mock")
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
print("=== 1. 미주입 스텁 — 네 슬롯 전부 영어 메시지로 error, 호출부(level 2)를 가리킴 ===")
do
local fresh = Quad.New()
for _, name in { "bindLifetime", "unbindLifetime", "canBound", "canExecute" } do
local ok, err = pcall(function()
return (fresh :: any)[name]({}, {})
end)
assert(not ok, name .. " must error before a backend is installed")
assert(string.find(tostring(err), name, 1, true) ~= nil, name .. ": message names the slot: " .. tostring(err))
assert(string.find(tostring(err), "not available", 1, true) ~= nil, name .. ": message is English: " .. tostring(err))
-- level 2 = 이 파일(호출부)을 가리킨다, LifetimeHandle.luau가 아니라
assert(string.find(tostring(err), "spec.lifetime.luau", 1, true) ~= nil, name .. ": error level must point at the caller: " .. tostring(err))
end
print("PASS")
end
local quad = Quad.New()
mock.installLifetime(quad)
local Instance = mock.Instance
print()
print("=== 2. 주입 후 — bind 전엔 canBound/‖canExecute, bind 후엔 반대 ===")
do
local inst = Instance.new("Frame")
local value = {}
assert(quad.canBound(value) == true and quad.canExecute(value) == false, "unbound value: may bind, may not execute")
quad.bindLifetime(inst, value)
assert(quad.canBound(value) == false and quad.canExecute(value) == true, "bound value: may not bind again, may execute")
print("PASS")
end
print()
print("=== 3. Destroy → canExecute false / canBound true (gcconn.Connected 전환) ===")
do
local inst = Instance.new("Frame")
local value = {}
quad.bindLifetime(inst, value)
inst:Destroy()
assert(quad.canExecute(value) == false, "after Destroy the value may not execute")
assert(quad.canBound(value) == true, "after Destroy the value may be bound again")
print("PASS")
end
print()
print("=== 4. 이중 바인딩 게이트 — `if not canBound(v) then error(...)` 모양, level 2 ===")
do
local a, b = Instance.new("Frame"), Instance.new("Frame")
local value = {}
quad.bindLifetime(a, value)
local ok, err = pcall(function()
quad.bindLifetime(b, value)
end)
assert(not ok, "binding an already-bound value must error")
assert(string.find(tostring(err), "already bound", 1, true) ~= nil, "message: " .. tostring(err))
assert(string.find(tostring(err), "spec.lifetime.luau", 1, true) ~= nil, "level 2 points at the caller: " .. tostring(err))
-- 같은 inst에 다시 묶는 것도 이중 바인딩
local ok2 = pcall(function()
quad.bindLifetime(a, value)
end)
assert(not ok2, "rebinding to the same inst is also a double bind")
print("PASS")
end
print()
print("=== 5. unbindLifetime — 특정 값 하나만 조기 해제, inst는 그대로 ===")
do
local inst = Instance.new("Frame")
local x, y = {}, {}
quad.bindLifetime(inst, x)
quad.bindLifetime(inst, y)
quad.unbindLifetime(x)
assert(quad.canExecute(x) == false and quad.canBound(x) == true, "x is released")
assert(quad.canExecute(y) == true, "y is untouched")
quad.unbindLifetime(x) -- 안 걸려있던 값에 불러도 안전한 no-op
quad.unbindLifetime({})
quad.bindLifetime(inst, x) -- 해제됐으니 다시 묶을 수 있음
assert(quad.canExecute(x) == true, "x can be rebound after release")
print("PASS")
end
print()
print("=== 6. gchold — inst가 사는 동안 value 생존 보장(계약 1), inst가 죽고 놓이면 같이 회수 ===")
do
local inst = Instance.new("Frame")
local weak = setmetatable({}, { __mode = "v" })
do
local value = {}
quad.bindLifetime(inst, value)
weak[1] = value
end
collectgarbage()
collectgarbage()
assert(weak[1] ~= nil, "bound value must survive while inst lives (gchold strong ref)")
inst:Destroy()
inst = nil :: any
collectgarbage()
collectgarbage()
assert(weak[1] == nil, "after Destroy + dropping inst, the value is collectable")
print("PASS")
end
print()
print("=== 7. 첫 인자는 mock Instance여야 함 ===")
do
local ok, err = pcall(function()
quad.bindLifetime({}, {})
end)
assert(not ok and string.find(tostring(err), "not a mock instance", 1, true) ~= nil, "non-instance first arg: " .. tostring(err))
print("PASS")
end
print()
print("=== 8. installLifetime은 quad 인스턴스별 — 다른 New()엔 안 퍼짐, 두 번 불러도 무시 ===")
do
local other = Quad.New()
local ok = pcall(function()
other.canBound({})
end)
assert(not ok, "another Quad instance still has the stubs")
local before = quad.bindLifetime
mock.installLifetime(quad)
assert(quad.bindLifetime == before, "second install is ignored")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,206 @@
--[[
Ref 최소형 계약 — `.claude/base/ref-plan.md` "API 모양" / "`.Callbacks`는 … 해시맵 셋" /
"`:WeakCallback(fn)`" / "`:Set(value)`의 순서" / "`Ref`는 `Epoch`를 만족한다",
`.claude/base/state-epoch-plan.md` §2(리비전 랩).
]]
local Quad = require("../src")
local Ref = Quad.Ref
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
print("=== 1. 생성 — Value/Revision 초기값, 브랜드(isRef + isEpoch), Callback 테이블 둘 ===")
do
local r = Ref(5)
assert(r.Value == 5, "default lands in .Value")
assert(r.Revision == 0, "initial revision is 0")
assert(Quad.isRef(r) and Quad.isEpoch(r), "a Ref is both Ref and Epoch (multi-tagging)")
assert(not Quad.isPreRef(r) and not Quad.isPostRef(r) and not Quad.isState(r), "plain Ref only")
assert((getmetatable(r.WeakCallbacks :: any) :: any).__mode == "k", "WeakCallbacks is weak-keyed")
assert(getmetatable(r.Callbacks) == nil, "Callbacks is a plain strong set")
local empty = Ref()
assert(empty.Value == nil, "Ref() has no value")
print("PASS")
end
print()
print("=== 2. :Set 순서 — 콜백이 볼 때 .Value와 .Revision이 이미 새 것, fn(value, ref) ===")
do
local r = Ref(1)
local seen: { any } = {}
r:Callback(function(value, ref)
table.insert(seen, { value = value, refValue = ref.Value, rev = ref.Revision, ref = ref })
end)
assert(#seen == 1 and seen[1].value == 1 and seen[1].ref == r, "registration calls once with the current value + the Ref itself")
local rev0 = r.Revision
local ret = r:Set(2)
assert(ret == r, ":Set returns self")
assert(#seen == 2, "one fire per Set")
assert(seen[2].value == 2 and seen[2].refValue == 2, ".Value is settled before callbacks")
assert(seen[2].rev ~= rev0 and seen[2].rev == r.Revision, ".Revision is bumped before callbacks")
print("PASS")
end
print()
print("=== 3. 리비전 — bit32.bnot(-rev) 랩어라운드 감소: 0 → 4294967295 → 4294967294, 매번 다름 ===")
do
local r = Ref()
r:Set(1)
assert(r.Revision == 4294967295, "0 wraps to 4294967295, got " .. r.Revision)
r:Set(2)
assert(r.Revision == 4294967294, "then decrements, got " .. r.Revision)
local last = r.Revision
for _ = 1, 100 do
r:Set(0)
assert(r.Revision ~= last, "every Set changes the revision")
last = r.Revision
end
print("PASS")
end
print()
print("=== 4. 즉시 1회 호출 — nil/미설정이어도 그 상태 그대로 (H-120), Weak도 동일 ===")
do
local r = Ref()
local calls = 0
local weakCalls = 0
r:Callback(function(value)
calls += 1
assert(value == nil, "nil is passed as-is")
end)
r:WeakCallback(function(value)
weakCalls += 1
assert(value == nil, "nil is passed as-is")
end)
assert(calls == 1 and weakCalls == 1, "both registrations fire once immediately")
print("PASS")
end
print()
print("=== 5. 중복 등록은 dedup — 같은 fn을 여러 번/양쪽에 걸어도 Set당 1회 ===")
do
local r = Ref(0)
local calls = 0
local function fn()
calls += 1
end
r:Callback(fn):Callback(fn):WeakCallback(fn)
calls = 0
r:Set(1)
assert(calls == 1, "same fn registered strongly + weakly fires once per Set, got " .. calls)
print("PASS")
end
print()
print("=== 6. :Uncallback — 양쪽 테이블에서 뗌, self 반환, 안 걸린 fn은 no-op ===")
do
local r = Ref(0)
local strong, weak = 0, 0
local function s()
strong += 1
end
local function w()
weak += 1
end
r:Callback(s):WeakCallback(w)
strong, weak = 0, 0
assert(r:Uncallback(s) == r, "Uncallback returns self")
r:Uncallback(w)
r:Uncallback(function() end)
r:Set(1)
assert(strong == 0 and weak == 0, "detached callbacks do not fire")
assert(r.Callbacks[s] == nil and r.WeakCallbacks[w] == nil, "entries are removed from both tables")
print("PASS")
end
print()
print("=== 7. WeakCallback — 다른 곳에서 안 잡으면 GC 뒤 침묵, Callback은 살아남음 ===")
do
local r = Ref(0)
local strongCalls, weakCalls = 0, 0
-- ⚠️ 두 가지 GC 함정을 피한 모양: (1) 별도 함수 안에서 등록 — 같은 프레임의
-- 죽은 레지스터가 클로저를 붙잡는 스택 잔재를 피한다. (2) 클로저가 업밸류를
-- **직접 변경**한다 — 불변 업밸류만 잡는 클로저는 Luau가 프로토에 캐시해
-- 강참조로 붙들어 영영 GC되지 않는다(`lifecycle-pattern.md`의 `false or`
-- 트릭이 막는 것과 같은 최적화).
local function register()
r:Callback(function()
strongCalls += 1
end)
r:WeakCallback(function()
weakCalls += 1
end)
end
register()
collectgarbage()
collectgarbage()
strongCalls, weakCalls = 0, 0
r:Set(1)
assert(strongCalls == 1, "strongly registered callback survives GC")
assert(weakCalls == 0, "weakly registered callback is collected and silent")
assert(next(r.WeakCallbacks) == nil, "WeakCallbacks entry is gone")
print("PASS")
end
print()
print("=== 8. 발화 중 Uncallback/Callback 안전 — 스냅샷 순회, 순회 중 해제된 건 skip ===")
do
local r = Ref(0)
local order: { string } = {}
local walking = false -- 등록 즉시 1회 호출과 Set 순회를 구분
local a: (number?, any) -> ()
local b: (number?, any) -> ()
-- a와 b가 서로를 떼므로 pairs 순서와 무관하게 정확히 하나만 돈다
a = function()
if not walking then
return
end
table.insert(order, "a")
r:Uncallback(b)
r:Callback(function()
if walking then
table.insert(order, "late") -- 등록 즉시 1회만, 이번 파동의 순회엔 안 낌
end
end)
end
b = function()
if not walking then
return
end
table.insert(order, "b")
r:Uncallback(a)
end
r:Callback(a):Callback(b)
walking = true
r:Set(1)
walking = false
local counts: { [string]: number } = { a = 0, b = 0, late = 0 }
for _, v in order do
counts[v] = (counts[v] or 0) + 1
end
assert(counts.a + counts.b == 1, "exactly one of a/b runs — the other was released mid-walk and skipped, got " .. table.concat(order, ","))
assert(counts.late == counts.a, "a callback registered mid-walk fires its registration call only, got " .. table.concat(order, ","))
print("PASS")
end
print()
print("=== 9. thread 키 — 대기자는 1회 소진, resume 인자는 Ref 자신 (M8 :Wait의 기반) ===")
do
local r = Ref(0)
local got: any = nil
local co = coroutine.create(function()
got = coroutine.yield()
end)
coroutine.resume(co)
r.Callbacks[co] = true -- :Wait()가 M8에서 할 등록을 직접 흉내
r:Set(1)
assert(got == r, "waiter is resumed with the Ref itself")
assert(r.Callbacks[co] == nil, "waiter is consumed")
assert(coroutine.status(co) == "dead", "waiter ran to completion")
r:Set(2) -- 소진됐으니 다시 resume 안 함(죽은 코루틴 resume이면 여기서 티가 남)
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,95 @@
--[[ Relate 계약 — `.claude/base/relate-plan.md` "API (확정)" / "실제 구조 (확정, 2026-08-08 세션)" ]]
local Relate = require("../src/Relate")
local Quad = require("../src")
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
print("=== 1. 네 메서드 — Strong/Weak는 서로 다른 슬롯, 같은 키라도 안 섞임 ===")
do
local r = Relate()
local inst = {}
r:SetStrong(inst, "k", 1)
r:SetWeak(inst, "k", {})
assert(r:GetStrong(inst, "k") == 1, "GetStrong reads what SetStrong wrote")
assert(type(r:GetWeak(inst, "k")) == "table", "GetWeak reads what SetWeak wrote")
assert(r:GetStrong(inst, "missing") == nil and r:GetWeak(inst, "missing") == nil, "unknown key is nil")
assert(r:GetStrong({}, "k") == nil and r:GetWeak({}, "k") == nil, "unknown inst is nil")
assert(Quad.Relate == Relate, "Relate must be re-exported on Quad")
print("PASS")
end
print()
print("=== 2. lazy 서브테이블 — 읽기는 쓰기를 유발하지 않음, Set이 처음 불릴 때만 생성 ===")
do
local r = Relate()
local inst = {}
local buckets = (r :: any).buckets
assert(r:GetStrong(inst, "k") == nil, "read on fresh relate")
assert(buckets[inst] == nil, "a read must not create the bucket")
r:SetStrong(inst, "k", true)
assert(buckets[inst] ~= nil and buckets[inst].StrongMap ~= nil, "SetStrong creates bucket + StrongMap")
assert(buckets[inst].WeakMap == nil, "WeakMap stays unmade until SetWeak")
r:SetWeak(inst, "w", {})
assert(buckets[inst].WeakMap ~= nil, "SetWeak creates WeakMap")
print("PASS")
end
print()
print("=== 3. 공유 메타테이블 — 모든 WeakMap이 같은 {__mode='v'} 객체 ===")
do
local r = Relate()
local a, b = {}, {}
r:SetWeak(a, "k", {})
r:SetWeak(b, "k", {})
local buckets = (r :: any).buckets
local mtA, mtB = getmetatable(buckets[a].WeakMap), getmetatable(buckets[b].WeakMap)
assert(mtA ~= nil and mtA == mtB, "WeakMap metatable is shared")
assert(mtA.__mode == "v", "WeakMap is weak-valued")
assert(getmetatable(buckets[a].StrongMap) == nil, "StrongMap has no metatable")
print("PASS")
end
print()
print("=== 4. Weak 값은 다른 곳에서 안 잡으면 GC됨, Strong 값은 살아남음 ===")
do
local r = Relate()
local inst = {}
do
r:SetWeak(inst, "w", {})
r:SetStrong(inst, "s", {})
end
collectgarbage()
collectgarbage()
assert(r:GetWeak(inst, "w") == nil, "weakly held value is collected")
assert(r:GetStrong(inst, "s") ~= nil, "strongly held value survives")
print("PASS")
end
print()
print("=== 5. inst는 항상 weak — inst를 놓으면 버킷째 사라짐 ===")
do
local r = Relate()
local buckets = (r :: any).buckets
do
local inst = {}
r:SetStrong(inst, "s", {})
end
collectgarbage()
collectgarbage()
assert(next(buckets) == nil, "bucket keyed by a dead inst must be collected")
print("PASS")
end
print()
print("=== 6. 비싱글톤 — 인스턴스마다 독립 ===")
do
local a, b = Relate(), Relate()
local inst = {}
a:SetStrong(inst, "k", 1)
assert(b:GetStrong(inst, "k") == nil, "separate Relate instances do not share buckets")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,22 @@
--[[ Void 계약 — `H-162`: 단일 no-op 함수, 반환값 없음, 항상 같은 함수 ]]
local Quad = require("../src")
local Void = require("../src/Void")
print("=== 1. 항등 — Quad.Void와 Void.luau가 같은 함수, 매 require도 같음 ===")
do
assert(Quad.Void == Void, "Quad.Void is the very same function as Void.luau")
assert(require("../src/Void") == Void, "module caching keeps one identity")
print("PASS")
end
print()
print("=== 2. no-op — 어떤 인자로 불러도 아무것도 반환하지 않음 ===")
do
assert(select("#", Void()) == 0, "Void() returns nothing")
assert(select("#", Void(1, "a", {}, nil)) == 0, "Void(...) returns nothing regardless of args")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -17,12 +17,66 @@
local TypeVersionCheck = require("./luau_packages/type_version_check")
-- `Epoch` — 최소 인터페이스(`.claude/base/state-epoch-plan.md` §2). 판정은
-- identity + "직전과 다른 Revision"뿐, 순서 비교 없음. 런타임 판별은 `isEpoch`.
export type Epoch = { Revision: number }
-- `Relate` — inst를 weak 키로 하는 릴레이션(`.claude/base/relate-plan.md` "API").
-- `inst`는 항상 weak, `Weak`/`Strong`은 value의 보관 방식만 가리킨다.
export type Relate = {
SetStrong: (self: Relate, inst: any, key: any, value: any) -> (),
GetStrong: (self: Relate, inst: any, key: any) -> any?,
SetWeak: (self: Relate, inst: any, key: any, value: any) -> (),
GetWeak: (self: Relate, inst: any, key: any) -> any?,
}
-- `Ref<T>` 최소형(`.claude/base/ref-plan.md`, M2 `H-128`). 콜백은 `fn(value, ref)` —
-- 두 번째 인자가 Ref 자신(= `Epoch`). `:Wait`는 M8.
export type RefCallback<T> = (value: T?, ref: Ref<T>) -> ()
export type Ref<T> = {
Value: T?,
Revision: number,
Callbacks: { [RefCallback<T> | thread]: true }, -- 강한 해시맵 셋(+ M8 `:Wait` 대기자)
WeakCallbacks: { [RefCallback<T>]: true }, -- weak-key 테이블
Set: (self: Ref<T>, value: T?) -> Ref<T>,
Callback: (self: Ref<T>, fn: RefCallback<T>) -> Ref<T>,
WeakCallback: (self: Ref<T>, fn: RefCallback<T>) -> Ref<T>,
Uncallback: (self: Ref<T>, fn: RefCallback<T>) -> Ref<T>,
}
export type Quad = {
Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것
debug: boolean,
New: () -> Quad,
RunInit: (self: Quad, initFn: (Quad) -> any) -> (),
AddPlugin: <Self, P>(self: Self, pluginFn: (Self) -> P) -> Self & P,
-- M2 공통 기반이 얹는 탑레벨 값(`ROADMAP.md` M2 `H-80`). `Source`/`Store`/
-- `Effect`/`Blocker`는 각 단위에서 추가된다.
Relate: () -> Relate,
Void: (...any) -> (),
Ref: <T>(default: T?) -> Ref<T>,
-- 생명주기 4종 — quad-base는 인터페이스(에러 스텁)만, 백엔드가 주입
-- (`.claude/base/lifecycle-pattern.md`). `canBound(v) == not canExecute(v)`.
bindLifetime: (inst: any, value: any) -> (),
unbindLifetime: (value: any) -> (),
canBound: (value: any) -> boolean,
canExecute: (value: any) -> boolean,
-- 브랜드 술어(`.claude/base/brand-plan.md`). 나머지(`isTag`/`isAttribute*`/
-- `isTween`/`isSlot`)는 그 타입의 마일스톤에서.
isEpoch: (x: any) -> boolean,
isSource: (x: any) -> boolean,
isState: (x: any) -> boolean,
isStore: (x: any) -> boolean,
isObserver: (x: any) -> boolean,
isEffect: (x: any) -> boolean,
isBlocker: (x: any) -> boolean,
isModifier: (x: any) -> boolean,
isRef: (x: any) -> boolean,
isPreRef: (x: any) -> boolean,
isPostRef: (x: any) -> boolean,
}
--[[

View file

@ -1,15 +1,19 @@
#!/usr/bin/env bash
# 스모크 테스트 — 리링크를 먼저 돌린다(scripts/relink.sh 주석 참고).
# 테스트 — 리링크를 먼저 돌린다(scripts/relink.sh 주석 참고). smoke.* = M1 스모크, spec.* = 모듈 계약 테스트.
set -euo pipefail
shopt -s nullglob
cd "$(dirname "$0")/.."
./scripts/relink.sh
files=(quad-base/test/smoke.*.luau)
files=(quad-base/test/smoke.*.luau quad-base/test/spec.*.luau)
if [ "${#files[@]}" -eq 0 ]; then
echo "no smoke tests found (quad-base/test/smoke.*.luau)" >&2
echo "no tests found (quad-base/test/{smoke,spec}.*.luau)" >&2
exit 1
fi
fail=0
# 타입 검사 — relink 뒤라 심볼릭 링크 때문에 조용히 통과하는 "거짓 클린"이 없다.
# smoke.*는 M1 임시 스모크라 제외(느슨하게 쓰였음) — src와 spec/mock만 strict로 본다.
echo "=== luau-analyze quad-base/src quad-base/test/spec.*.luau quad-base/test/mock.luau"
luau-analyze quad-base/src quad-base/test/spec.*.luau quad-base/test/mock.luau || fail=1
for f in "${files[@]}"; do
echo "=== $f"
luau "$f" || fail=1