- Observer.luau: Observer.Init(module) — 인스턴스별 임플 + Subscribed/WeakSubscribed(H-99), _receive(canExecute 게이팅, 홀드 H-159)/_catchUp(유일한 재생 자리, from=nil H-164), 네 진입점 인라인(H-149, Weak 프리미티브 H-111, 관대 H-133, 엄격), 생성자 순서(fn → 플래그 → _subs). - Effect.luau: Effect.Init(module) — deps 검증(H-70), dep 종류별 클로저(H-107), fire(from==nil 가드, _epochs Update만 H-151, _blocker 없음 H-150), rawRerun(force)/Rerun(_pending 지연, 홀드, 사망 계약), _bindDestroying/_unbindDestroying/_consumeCleanup(_cleanupRunning H-160), 네 진입점 자기 본문(H-144) + isRunning 가드(H-147) + resubscribeTail. - State:Observer → ObserverImpl.new. LifetimeHandle: onDestroying 에러 스텁. mock: onDestroying. - quad-types: EpochSet/Observer/ObserverFn/EffectHandle/EffectFn, State.Observer, Quad.Effect·onDestroying. - spec.observer(8절)·spec.effect(9절) ALL PASS, analyze 0. ROADMAP 단위 3 체크박스(mock 전파 루프 테스트 포함), round11 §5 단위 3 확인 목록 + H-178(사적 필드 _ 접두), 세션 원문·요약. Co-authored-by: qwreey <me@qwreey.moe>
34 KiB
M2 구현 11라운드 — 발견 원문 + 배치 문항지
이 파일이 무엇인가: [2026-08-28 신설] M2 자율 구현 구간 (
-round11-brief.md가 규약)에서 나온 발견 전부. 앞 라운드들과 달리 종이 트레이싱이 아니라 실제 코드를 옮기고 돌리다 나온 것이다. 번호는H-165부터 (10라운드 후속이H-164까지 썼다).갈래 표기(규약 §2): ① 자율로 고침(같은 커밋에서
base/+코드) / ② §4 표에 쌓아 배치 회신 대기 / ③ 즉시 중단·보고.상태의 소스는 이 파일 자신 — 요약 표의 상태 열이 최신.
요약 표
| 번호 | 갈래 | 단위 | 심각도 | 한 줄 | 상태 |
|---|---|---|---|---|---|
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) |
H-167 |
① | 1 | 🟡 | 옮기며 Ref<T>(default: T?)/.Value: T?로 바꿔 놓았다 — 문서는 Ref<T>(T) 단일 파라미터, nil은 Ref<<T?>>(nil)로 |
✅ 코드를 문서에 맞춤(감사 2라운드) |
H-168 |
② | 1 | 🟡 | Ref<T>(T)면 무인자 Ref()가 strict에서 TypeError("expects 1 argument") — 그런데 ref-plan.md/lifecycle-hooks-plan.md/debounce-throttle-plan.md가 Ref()/PreRef()를 관용구로 가르친다 |
✅ (a) 사용자 확정 — 시그니처 유지, 관용구는 Ref<<T?>>()로 읽음(ref-plan.md "제네릭 시그니처") |
H-169 |
② | 1 | 🟡 | :Set 블록이 스냅샷 콜백에 닫힌 인자 value를 넘겨서, 콜백 안 재진입 ref:Set(new) 뒤 남은 콜백이 옛 값을 받는다 — 문서 블록 그대로인데 문서의 "옛 값이 보이는 창이 없다"와 어긋남 |
✅ 사용자 확정(권고 (a) 아님) — 순회가 자기 리비전이 바뀌면 놓고 후행 Set이 전부 호출(spec.ref 11) |
H-170 |
② | 1 | 🟡 | coroutine.resume(k, self)는 에러를 올리지 않는다 — 대기자 안의 에러·죽은 thread resume이 :Set에서 조용히 삼켜짐. ref-plan.md *"나중에 coroutine.resume이 에러남"*은 사실이 아님 |
✅ (a) 사용자 확정 — 즉시 반환된 실패만 error(err, 0)(spec.ref 10) |
H-171 |
① | 1 | 🟡 | mock lazy claim: Destroy된 inst에 다시 bind하면 GC 전엔 죽은 gcconn 재사용, GC 뒤엔 새 Connected gcconn — 결과가 GC 타이밍에 따라 갈림 | ✅ mock: 죽은 inst의 새 gcconn은 즉시 Disconnect(spec.lifetime 6b) |
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-177 |
① | 2 | 🟢 | InitSource/InitStore가 State.implFor만 불러 New()의 RunInit 순서에 의존했다 — module-lifecycle-plan.md는 "각 InitXxx가 require처럼 멱등하게 자기 의존성을 당겨온다"로 확정 |
✅ 각 Init이 module:RunInit(dep)를 직접 호출(감사 3라운드) |
H-178 |
① | 3 | 🟢 | 코드의 사적 필드는 _ 접두(_valueEpochMap·_emitEpochMap·_subs·_hold…)인데 base/ 의사코드는 valueEpochMap처럼 접두 없이 쓴다 — 이름은 1:1이고 밑줄만 다르다 |
✅ 기록만(문서 무변경 — 코드 관례, H-174 조립 세부와 같은 급) |
H-175 |
① | 1 | 🟢 | §5 "불변 업밸류만 잡는 클로저는 프로토에 캐시"는 범위가 넓다 — 실제 규칙은 업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬일 때만(컴파일러 shouldShareClosure). 함수 인자·지역을 잡는 클로저(단위 3 Effect의 콜백 모양)는 정상 GC됨을 실측 |
✅ §5·spec.ref 주석 좁힘 |
상세
(단위별로 ### \H-nnn` 🔴/🟡/🟢 — 제목절을 이어 붙인다. 각 절엔 (1) 어디서 (파일:줄 /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으로 구현하고 그 절에 한 줄 추가. 계약이==/~=뿐이라 값은 무관.
H-167 🟡 — 구현이 Ref 시그니처를 T?로 바꿔 놓았다 (자기 실수)
- 어디서:
quad-types/src/init.luau·quad-base/src/Ref.luauvsbase/ref-plan.md"제네릭 시그니처(2026-08-07 확정)". - 무엇이: 문서는
Ref<T>(T) -> Ref<T>,.Value: T— nil이 올 수 있는 자리는 호출자가Ref<<T?>>(nil)로 넓힌다(그 문서의 언바인딩 절도 같은 전제). 옮기면서Ref()관용구를 타입에서 받으려고default: T?/Value: T?로 적었는데 그러면Ref(5).Value까지 nil 검사를 강요한다 — 문서가 이미 기각한 모양. 감사 2라운드가 발견. - 처리: 코드를 문서대로 되돌림. 테스트의
Ref()는Ref<<number?>>(nil)로.
H-168 🟡 — Ref<T>(T)와 무인자 Ref() 관용구가 양립하지 않는다 (②)
- 어디서:
quad-base/src/Ref.luau생성자 /base/ref-plan.md"제네릭 시그니처" vs 같은 문서의Ref():Callback(fn)관용구(H-120문단),base/lifecycle-hooks-plan.md의PreRef():Callback(guard(fn)),base/debounce-throttle-plan.md의Handle = Ref(). - 무엇이:
H-167로default: T가 되자local r = Quad.Ref()가 strict에서 "Argument count mismatch. Function expects 1 argument, but none are specified"(리뷰 재현). 문서는 명시 확장Ref<<T?>>(nil)만 다루고 빈 호출은 안 다룬다. M8 훅 슈가가 문서대로 짜이면 타입 검사를 못 통과한다. - 갈래: §4.
H-169 🟡 — 재진입 :Set 뒤 남은 콜백이 옛 value를 받는다 (②)
- 어디서:
Ref.luau:Set의k(value, self)—base/ref-plan.md":Set(value)의 순서" 블록을 그대로 옮긴 것. - 무엇이: 콜백 A가
ref:Set(2)를 다시 부르면 안쪽 파동이 먼저 다 돌고, 바깥 파동의 남은 콜백 B는.Value == 2인 채로 인자value == 1을 받는다(리뷰 재현: B가(2,2)다음(1,2)). 문서의 *"모든 콜백이 새 값을 본다 … 옛 값이 보이는 창이 없다"*와 어긋남. 문서가 재진입을 안 다뤄서 문서 블록 수정이 필요한 자리 — 코드 임의 변경 금지. - 갈래: §4.
H-170 🟡 — coroutine.resume은 에러를 올리지 않는다 (②)
- 어디서:
Ref.luau:Set의coroutine.resume(k, self)/base/ref-plan.md:Wait(thread)절 "나중에coroutine.resume이 에러남" /spec.ref.luau9번의 꼬리Set. - 무엇이: resume은
(false, err)를 반환하지 던지지 않는다. 대기자 안에서 난 에러와 죽은 thread resume이:Set에서 조용히 사라진다 —architecture.md"예외 안전성 계약 — 감싸지 않는다"의 취지(에러는 전파된다)와 어긋나고, 문서 서술과 테스트 주석의 전제가 틀려 있었다(주석은 고침). - 갈래: §4.
H-171 🟡 — mock lazy claim이 GC 타이밍에 따라 갈린다
- 처리:
claim이 이미 Destroy된 inst면 새 gcconn을 즉시Disconnect— Roblox에선nativeClaim이 생성 시 1회라 이 경로가 없다.spec.lifetime.luau6b가 GC 전/후 둘 다 고정.
H-172 🟡 — mock Destroy 의미론
- 처리: Destroying 발화 → Parent nil(변경 시그널 관측 가능) → 자손 재귀 Destroy → 연결 해제, 이미 파괴됐으면 no-op. 6c가 조상 파괴 계약(5라운드 "조상 파괴 시 unowned 요소도 같이 죽는다")을 mock에서 검증 가능하게 한다.
H-173 🟢 — TweenBrand/isTween 위치 잔재 둘
- 처리:
ROADMAP.mdM7 체크박스,tween-plan.md"base 프리미티브 아님" 문단.
단위 1 — 탐사자 (2026-08-28, 커밋 d9898d6..325f3f0)
H-174 🔴 — canExecute/canBound는 인스턴스 필드인데 반응형 의사코드는 자유 함수로 부른다 (②)
- 어디서:
quad-base/src/LifetimeHandle.luau(Init(module)이module.bindLifetime = …네 슬롯을 인스턴스마다 심음) +quad-base/test/mock.luauinstallLifetime(quad)(그quad하나만 덮어씀) +spec.lifetime.luau8("다른New()엔 안 퍼짐"을 고정) — vsbase/source-state-plan.md"전파 루프 — 확정 의사코드"의Observer:_receive(if canExecute(self) then),base/lifecycle-pattern.md(2)의Observer:WeakSubscribe(if not canBound(self) then),base/effect-plan.md의rawRerun(canExecute(self))과EffectHandle네 진입점. - 무엇이: 이 단위가 확정대로 옮긴 결과 네 함수는 자유 함수가 아니라
quad인스턴스의 필드다(lifecycle-pattern.md186행 "평평한은 모듈 인스턴스의 필드라는 뜻",architecture.md13번 "모듈이 하나의 인스턴스(… canExecute 등 계약 필드 하나)"). 그런데 다음 단위가 옮길 의사코드는 전부canExecute(self)처럼 어느 인스턴스인지 말하지 않고 부른다.Ref.luau처럼Source.luau/State.luau/Observer.luau를 인스턴스 간 공유되는 잎 모듈로 쓰면(spec.init27행이Ref/Void/Relate에 대해 고정한 모양) 발화 시 어느quad의canExecute를 부를지 알 수 없다 — 백엔드가 둘이면(mock + roblox,New()의 존재 이유) 잘못된 인스턴스의 게이트를 타거나, 아직 스텁인 인스턴스에서error가 난다. 코퍼스를 grep해도(InitSource/InitState/InitObserver/_quad/_module) 반응형 모듈의 조립 형태를 정한 문장이 없다 —module-lifecycle-plan.md"New()의 내부 구성"이 정한 건InitDispatch(module)하나의 예시뿐이고,ROADMAP.md"반응형 본체" 체크박스도 침묵한다. - 같이 걸리는 하위 함정(결정과 무관하게 참): 백엔드는
New()가 끝난 뒤 필드를 덮어쓴다(mock도 같은 순서). 그래서 반응형 모듈이Init시점에local canExecute = module.canExecute로 스텁을 캡처하면 영원히 스텁이다 — 발화 시점마다module.canExecute(self)로 늦게 읽어야 한다.spec.lifetime8은quad.bindLifetime == before(재설치 무시)만 보고 이 순서는 안 본다. - 문서가 답을 갖고 있나: 아니다.
architecture.md13번의 *"module-level state를 참조하는 코드들이 모듈 인스턴스를 인자로 받도록 손을 대야 한다 —InitModule(module)등"*이 방향만 준다. 어느 모양이든 새 조립 메커니즘이라 §4. - 갈래: §4. 단위 1 코드는 어느 선택지에서도 그대로다(③ 아님).
단위 2 — EpochMap → Source/State/Store (2026-08-28)
H-176 🟡 — 타입팩 deps 선언은 strict에서 안 산다 (①)
- 어디서:
quad-types/src/init.luauState<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 gotSource<number>"*로 막혔다 — 팩이 콜백 파라미터 쪽으로 역추론되며Get을 read-only...unknown반환으로 뒤튼다. 문서가 "실측 필요"로 남겨둔 바로 그 (B)다. - 처리: deps 자리
...any, 콜백 안에서dep: StateData<U>주석(콜백 파라미터 주석 관례 그대로). 런타임 계약 무변경. 문서에 실측 기록, 스파이크15닫힘.
H-177 🟢 — 반응형 Init의 순서 의존 (①)
- 어디서:
quad-base/src/{Source,Store}.luau의Init/init.luau의RunInit순서 주석 vsbase/module-lifecycle-plan.md"New()의 내부 구성" 절의 "순서 의존성은 각InitXxx를require처럼 멱등하게 만들어서 해소한다". - 무엇이: 단위 2 첫 커밋은
init.luau가 State → Source → Store 순서를 지키게 하고implFor가 어기면 error하게 했다 — 동작은 하지만 문서 원칙과 다른 길. 감사 3라운드가 "사용자 판단"으로 올렸으나 문서가 이미 답을 갖고 있어 ①. - 처리:
Source.Init이module:RunInit(State.Init),Store.Init이module:RunInit(InitSource)를 직접 호출.init.luau순서 주석을 "무관"으로.
단위 3 — Observer → Effect (2026-08-29)
H-178 🟢 — 사적 필드의 _ 접두 (①)
- 어디서:
effect-plan.md생성자 의사코드의d.valueEpochMap/state-epoch-plan.md§4의self.valueEpochMapvs 코드_valueEpochMap(단위 2부터). - 무엇이: 단위 2가 State의 사적 필드를 전부
_접두로 옮겼고(_subs/_hold/_cache/ 카운터 둘/맵 둘), 단위 3Effect가d._valueEpochMap로 읽는다. 문서는 접두 없음. 뜻·개수는 1:1이라 문서를 고칠 이유가 없고(의사코드는 문서 안에서 자기완결), 코드 쪽 관례로 기록만. - 처리: 없음 — 다음 단위(
GateNode가valueEpochMap을 컴포지션)도 같은 접두로.
H-175 🟢 — 클로저 캐시 규칙의 범위 (①)
- 어디서: 이 파일 §5 "툴링 사실 둘"의 둘째 항,
spec.ref.luau7번 주석. - 무엇이: *"불변 업밸류만 잡는 클로저는 프로토에 캐시돼 영영 GC되지 않는다"*는 범위가
넓다. Luau 컴파일러
shouldShareClosure의 실제 조건은 업밸류가 없거나, 전부functionDepth == 0(톱레벨) 불변 로컬일 때뿐이다 — 함수 안의 지역/인자를 잡는 클로저는 불변이어도 공유되지 않는다(컴파일러 주석: 공유하면 *"임시 객체가 영구가 된다"*라 휴리스틱으로 좁혀둠). 실측(스크래치,luau):mk(handle)가 돌려준 클로저는 GC됨, 업밸류 없는 클로저는 GC 뒤에도 살아남음(생존 1/2). 단위 3Effect가handle을 잡고ref:WeakCallback(cb)로 거는 모양을 그대로 흉내내면WeakCallbacks항목이 0으로 사라진다 — 즉 지금 문장대로면 단위 3 GC 테스트가 잘못 경보를 낼 수 있다. - 처리: 미반영(탐사자는 고치지 않음). §5 문장을 *"업밸류가 없거나 톱레벨 불변 업밸류만 잡는 클로저"*로 좁히면 된다.
§4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
| 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? |
|---|---|---|---|---|---|
H-168 |
Ref<T>(T) vs 무인자 Ref() 관용구 |
(a) 시그니처 유지, 문서의 빈 호출 관용구를 전부 Ref<<T?>>()(nil-able 파라미터는 생략 가능)로 고쳐 씀 / (b) default: T?, .Value: T?(H-167 이전 모양 — Ref(5).Value까지 nil 검사 강요) / (c) 두 오버로드 Ref<T>(T) ∪ Ref<T>() -> Ref<T?> |
(a) | "제네릭 시그니처" 확정(단일 파라미터, 명시 확장으로 넓힘)을 그대로 두고 관용구만 그 규칙에 맞추는 것 — (b)는 문서가 기각한 모양, (c)는 그 절이 기각한 2-파라미터 솔버 문제의 재개방 | (b)가 그렇다 |
H-169 |
재진입 :Set 뒤 남은 콜백의 인자 |
(a) 블록을 k(self.Value, self)로(항상 최신 값, 문서 불변식 그대로) / (b) 문서에 "재진입 시 바깥 파동의 남은 콜백은 자기 파동의 값을 받는다"로 계약화(코드 유지) / (c) 재진입 자체를 금지(error) |
(a) | 문서 불변식("옛 값이 보이는 창이 없다")이 이미 (a)를 말하고 있고 한 토큰 차이 — (b)는 인자와 .Value가 다른 창을 계약으로 열고, (c)는 새 가드 |
아니오 |
H-170 |
resume이 삼키는 에러 | (a) local ok, err = coroutine.resume(k, self); if not ok then error(err, 0) end — 대기자 에러를 :Set 호출부로 다시 올림 / (b) UB로 문서화(대기자 에러는 사라진다) / (c) 대기자 소진을 task.spawn류 주입 op로(Roblox task.spawn은 에러를 콘솔로 보냄) |
(a) | architecture.md "예외 안전성 계약"(감싸지 않는다 = 에러는 전파)과 같은 결. 다만 새 코드 두 줄(re-raise) 이라 사용자 결정 자리. (c)는 새 주입 op |
아니오 |
H-174 (탐사자, 단위 2 착수 전 필요) |
반응형 모듈이 자기 quad 인스턴스의 canExecute/canBound에 닿는 법 |
(a) Source.luau/State.luau/Observer.luau/Effect.luau를 InitSource(module)류 팩토리로 — 클래스와 Subscribed/WeakSubscribed 레지스트리를 module을 닫은 클로저 안에서 만들고 module.Source = …로 심음(InitDispatch와 같은 모양, EpochMap/Brand/Ref는 그대로 잎). 공개 필드가 없는 State/Observer 클래스는 Init의 반환값이나 module.RunInit 뒤의 비공개 필드로 형제 Init에 넘김 / (b) 잎 모듈 유지 + 값마다 역참조 필드(observer._quad)를 두고 self._quad.canExecute(self) / (c) 잎 모듈 유지 + 모듈 로컬 슬롯 하나를 백엔드가 채움(인스턴스 격리 포기) |
(a) | architecture.md 13번("모듈 인스턴스를 인자로 받도록")과 module-lifecycle-plan.md "New()의 내부 구성"이 이미 이 모양이고, Observer의 두 레지스트리가 인스턴스별이 되어 *"완전히 별도의 새 Quad 네임스페이스"*와 맞음. (b)는 새 필드 + 값이 자기 모듈을 강참조(Relate 되참조 계열 위험), (c)는 spec.lifetime 8·New()의 존재 이유와 충돌. 어느 쪽이든 필드는 발화 시점에 늦게 읽는다(상세 절의 하위 함정) |
아니오 |
[2026-08-28 회신 — 단위 1 배치 전량 확정] 사용자 원문: "174 는 module.canExecute 로
lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복문을 자신 epoch 를 보며
달라져버렸다면 놓으면 될듯. 후행 Set 이 전부 호출하도록. (진짜 항상 콜백이 받는건
최신임) / ref 쪽에서는 local function A<T>(a: T)end A<<{}?>>() 로 쓰면 없어도 호출
가능해짐. 시그니처 유지 권고를 따르고자 해. / coroutine.resume 은 애초에 에러가 날지 안
날지 후행 yield 로 나가는건 우리가 처리 어렵긴 해. 그치만 당장 돌아오는 결과 false 은
확인 해줄 수 있는듯. a 권고사항 따르기로". H-169만 권고 (a)(k(self.Value, self))가
아니라 사용자 안(리비전 비교로 파동을 놓음)으로 — 권고안은 같은 파동에서 콜백이 두
번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열.
코드 쪽 잔여 마커: grep -rn "TODO(H-" quad-base/src — 이 표의 문항과 1:1이어야
한다. [2026-08-28 기준] 마커 0개 — 위 셋은 단위 1 모듈을 막지 않아 코드는 문서
블록 그대로 두고 문항만 올렸다(H-168은 코드가 아니라 M8 문서의 관용구 문제).
§5 이상 없다고 확인한 것
(탐사자가 실제로 돌려보고 계약대로였던 자리 — 다음 탐사자가 다시 파지 않게.)
단위 1 (메인 세션, 2026-08-28):
Relate.luau(M1) ↔relate-plan.md"API"/"실제 구조": 4 메서드, lazy 서브테이블, 공유{__mode="v"}메타테이블,instweak — 전부 일치(spec.relate.luau가 고정).lifecycle-pattern.md(0)/(1) 스케치는 mock 시그널 위에 그대로 돌아간다 —Destroy→gcconn.Connected=false→canExecutefalse/canBoundtrue, 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 멤버십 — 성립.
단위 2 (메인 세션, 2026-08-28):
EpochMap6 연산 ↔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)로SourceInit에 건넨다(StoreInit은 이 임플을 직접 안 받고module.Source를 통해서만 State에 닿는다; 각 Init이module:RunInit(dep)로 의존성을 직접 당겨오므로New()순서 무관 —H-177;implFor의 level 1 error는 그래도 남겨둔 불변식 검사).Source의__index체인이 그 임플을 가리켜With/Compute/Apply가 위임된다.Ref/Void/Relate/Brand/EpochMap만 인스턴스 간 공유 잎. - 입력 검증 하나 추가(
architecture.mderror 계약의 예 "dep #3 is not a State/Source/Ref" 그대로):newNode가 dep이isState가 아니면error(…, 3)— 없으면dep._subsnil 인덱스로 quad 내부 줄에서 죽는다. 새 메커니즘이 아니라 계약이 이미 요구하는 자리라 ①로 둔다. Apply의 팩토리 파라미터가 유니온(함수 |__apply객체)이라 콜백 파라미터 무주석 추론이 안 된다 —typing-limits.md§1②가 이미 캐비엇으로 적어둔 자리(:Apply의 factory는 주석 필요). 테스트는 주석으로.
단위 3 (메인 세션, 2026-08-29):
Observer— 생성자 순서(fn 1회 → 플래그 내림 →_subs),_receive의canExecute게이팅과 홀드,_catchUp이 유일한 재생 자리(출처nil), 네 진입점(Weak 프리미티브·인라인 게이트· 관대/엄격·양쪽 테이블 해제), 강/약 GC, 파동 중 구독자 스냅샷 —spec.observer.luau8절.Effect— deps 검증 셋(H-70), dep 종류별 클로저 둘(H-107),fire의from == nil가드,_epochs갱신은fire뿐(H-151), 다이아몬드 1회, cleanup 세 자리(루프 머리/Unsubscribe/Destroying), cleanup 없는fn의 재바인드 무재실행(H-58회귀), 재진입 지연(_pending), 네 진입점의isRunning가드가fn·cleanup 안 전부 막음(H-147), 에러 시 사망 계약, 강한 주인 GC —spec.effect.luau9절.- 조립 세부:
Observer.luau가 인스턴스별 레지스트리 둘(_Subscribed/_WeakSubscribed)을 임플에 매달고Effect.luau가implFor로 받는다(H-99의 "모듈 내부 export, 이름은 구현 시").State.Init이Observer.Init을 당겨state:Observer를ObserverImpl.new로 위임. onDestroying스텁의 자리:LifetimeHandle.luau의Init이 생명주기 4종과 같이 에러 스텁을 설치(주입 op 목록의 소스는architecture.mdEngineOps 줄 — 스텁 파일 배치는 코드 배치). mock은installLifetime이inst.Destroying:Connect(fn)으로 채운다.- 색인이 짚은 문서 긴장(
ss:1197"Effect의 내부 Observer가 설치 발화를from == nil로 거른다" vsH-164)은 코드에서 모순 없음 — Observer 계약은nil= 출처 없음이고, Effect의fire가 그걸Update못 하는 값으로 자기 사정으로 거를 뿐이다. - 테스트 작성 함정:
table.insert(t, nil)은 길이를 안 늘린다 —emitFrom == nil발화를 셀 땐 래퍼로 기록할 것(한 번 오진했다).
툴링 사실 둘(설계 아님, 다음 단위가 알아야 함):
require("@self/X")는init.luau에서만 통한다 — 일반 파일에서@self는 그 파일 자신이라could not resolve child component. 형제 모듈은./X, 패키지는../roblox_packages/....- GC 테스트 함정 둘: 같은 프레임의 죽은 레지스터가 임시값을 붙잡는다(별도 함수 안에서
만들 것) / 업밸류가 없거나 전부 톱레벨(함수 깊이 0) 불변 로컬인 클로저는 Luau가
프로토에 캐시해 영영 GC되지 않는다([
H-175정정] 함수 인자·지역을 잡는 클로저는 정상 GC됨 — 테스트의 탑레벨 클로저는 업밸류를 직접 변경하게 할 것,lifecycle-pattern.md(0)의false or트릭이 막는 것과 같은 최적화).
단위 1 (탐사자, 2026-08-28) — 커밋 d9898d6..325f3f0, 신선한 컨텍스트, git stash 안 씀:
- 실행:
./scripts/test.sh전체 — relink 30 갱신,luau-analyze quad-base/src + spec.* + mock.luau진단 0, 9파일 전부ALL PASS.grep -rn "TODO(H-" quad-base/src0건(§4 표와 1:1 — 표의 넷은 전부 코드 밖 문항). - 한 줄 대조(코드 ↔
base/절), 어긋난 곳 없음:Brand.luau↔brand-plan.md"구현 — 인스턴스 브랜드"/"isXwrapper"(생성자·15 인스턴스·11 술어·합성 방향·소문자 메소드·None무의존) /Relate.luau↔ "API (확정)"/"실제 구조 (확정, 2026-08-08 세션)"(범위 안 diff는 타입 재export뿐, 본문 무변경 확인) /LifetimeHandle.luau↔lifecycle-pattern.md"탑레벨 평범한 함수로 확정"의 2026-08-28 정정 +architecture.mderror 계약(영어,level 2, 스텁은 조용한 no-op 아님) /Ref.luau↔ref-plan.md":Set(value)의 순서" 블록(토큰 단위 동일 —H-169/H-170은 블록 자체의 문제), "API 모양"(self 반환, 등록 즉시 1회,fn(value, ref)), ".Callbacks는 배열이 아니라", ":WeakCallback(fn)"(weak-key 별도 테이블,Uncallback은 양쪽), "Ref는Epoch를 만족한다"(Revision공개,EpochBrand다중 태깅),state-epoch-plan.md§2(bit32.bnot(-rev)) /mock.luauinstallLifetime↔ (0)/(1)/(2) 스케치(isBoundAlive두 경로, 게이트 모양,gchold[value],BindData둘 다 weak, Observer_catchUp/Effect_bindDestroying·_unbindDestroying,unbindLifetime이 cleanup·콜백을 안 뗌) — mock만의 추가는isMockInstance검사와H-171분기뿐 /init.luau·quad-typesQuad↔ brief §6 표(재export 목록·술어 11개 이름 일치). - 다음 단위가 쓸 표면, 타입 레벨 실측(
luau-analyze, strict, 진단 0):Ref<T>가QuadTypes.Epoch에 그대로 들어감(sync(e: Epoch)에Ref(5)),Ref<number>→Ref<any>인자 전달 OK(Effect(fn, ...deps)의 dep 합집합에 필요),{ [Epoch]: number }weak-key 맵에Ref를 키로 OK(EpochMap내부 모양),Epoch | Ref<any>합집합 OK, 콜백 인자 생략 (function(v)/function()) OK, 두 번째 인자를Epoch로 넘기기 OK.H-168의 전제와 (a) 둘 다 실측:Quad.Ref()는 "Function expects 1 argument",Quad.Ref<<number?>>()는 인자 없이도 진단 0이고.Value: number?— 권고 (a)의 관용구가 실제로 성립한다. - 런타임 실측(스크래치, 인라인): mock의
.Subscribed경로는 지금 이미 돈다(ObserverBrand등록 +Subscribed = true→canExecutetrue/canBoundfalse,bindLifetime→ "already subscribed") /ObserverBrand등록값을_catchUp없이 바인드하면attempt to call missing method '_catchUp'— 단위 2의Observer가 이 메소드를 반드시 갖는다는 뜻(EffectBrand는_bindDestroying/_unbindDestroying, 단위 3) /Ref를bindLifetime의 value로 — 정상(바인드 후canExecutetrue,Destroy후 false) /Effect모양(handle을 잡는 클로저를WeakCallback)의 항목은handle을 놓으면 GC 뒤 0개(H-175) / thread 키를 양쪽 테이블에 넣으면 첫:Set이.Callbacks만 비우고 둘째:Set이 죽은 코루틴을 조용히 resume —ref-plan.md가 적은 불변식("대기자는.Callbacks에만")은 가드가 아니라 관례이고 M8:Wait가 유일한 쓰기 지점이므로 지금은 문제 아님. - mock의 인스턴스 불멸성(툴링 사실):
claim된 mock 인스턴스는 참조를 놓아도Destroy전엔 GC되지 않는다(실측: inst를 떨어뜨리고 GC 2회 뒤에도 묶인 값 생존). 이유는 의도가 아니라 **dataOf가 weak-key·강한 값(Luau엔 ephemeron 없음)**이라data → changed 시그널 → gcconn 클로저 → inst로 키가 자기 값을 통해 살아남기 때문. 결과적으로lifecycle-pattern.md(0)의 *"quad가 만든 Instance는 … 반드시Destroy로 회수된다"*와 같은 관측을 주지만 근거가 다르다 — 다음 단위 GC 테스트는 만든 인스턴스를 **반드시Destroy**할 것(안 하면 그 인스턴스에 묶인 값 전부가 뒤 테스트의 GC 단언에 남는다).
단위 1 끝 절차 기록: 감사 루프 8라운드(발견 3→4→1→2→4→4→1→0, 5·6라운드 절반은
각도를 넓혀 잡힌 옛 같은-파일 절 인용 부채) → /code-review high 1회 10건: ② 셋(H-168~
H-170), ① 셋(H-171~H-173), 잔손질 셋(smoke.init 5절 → typechecked spec.init.luau /
conventions.md의 단위 나열 제거 / 코드 주석의 절 인용을 제목 앞부분으로), 그리고
기각 셋(gcconn 슬롯 중복·mock claim 불멸·공유 본문 빌더 — 전부 lifecycle-pattern.md
(0)/(1) 그대로이거나 claim-plan.md §7-9가 이미 기각한 모양).
§6 남은 의심 / 못 본 것
단위 1 (탐사자, 2026-08-28):
H-174의 결정이EpochMap엔 안 걸리지만GateNode/Blocker(단위 4)엔 걸릴 수 있다 —GateNode는 State 노드라canExecute를 안 보지만(H-56),Blocker가 Observer/Effect의 구독 표면을 만지면 같은 인스턴스 문제를 상속한다. 단위 4 전에 다시 볼 것.unbindLifetime을 한 번도 안 묶인EffectBrand값에 부르면value:_unbindDestroying()이 먼저 돈다(mock·스케치 동일) — 단위 3의_unbindDestroying은 미연결 상태에서 no-op이어야 한다(unbindLifetime의 "안 걸려있던 값에 불러도 안전한 no-op" 계약을 지키려면). 문서엔 그 요구가 명시돼 있지 않다 — 단위 3 탐사자 몫.- quad-roblox 쪽 마일스톤 표기가 둘로 갈린다:
ROADMAP.mdM5의RobloxFactory.luau가bindLifetime/canExecute를 주입한다고 하고(architecture.md소스 트리 300행도 같음), 실 구현 체크박스는 M8(1523행)이다.LifetimeHandle.luau도크스트링은 M8을 따랐다. 이 단위 범위 밖(코드는 어느 쪽이든 같음)이라 발견으로 세지 않고 여기만 적는다. - 못 본 것:
Relate핫패스 비용(relate-plan.md"M2 착수 시 실측 확인")은 측정하지 않았다 — 아직 핫패스를 부르는 코드가 없다.ref-plan.md"API 모양"의 *".Value(읽기 전용 필드)"*는 타입 레벨(read프로퍼티)로는 강제하지 않는다 — 문서가 타입 강제를 요구하지 않으므로 발견으로 안 올림.