feat: round11 §4 전량 종결(회신 3) — H-198 재시작 모델, H-186 UB, H-205 level 3, H-208/H-209/H-211

- H-198(사용자 안): 상류 스탬프(dep:_track)를 fn 직전으로 + Get을 수렴까지의
  재시작 루프로 — 계약 강화: 모든 Get이 fn 도중 변경(재진입·게이트 유보)을
  같은 호출 안에서 수렴시켜 항상 최신 반환. 매 패스 자기 dep Set은 UB.
  state-epoch-plan §4 세 절 + H-85 bullet 정정, spec.state 6 새 계약,
  spec.gate 10(리뷰의 영구 stale 재현 → 같은 Get에서 99 + flush 통지만)
- H-186(b): 교차 인스턴스 값 혼용 UB 문서화 — architecture 13번 + content-map
  §4 22번(H-116 이웃), 코드 주석. M5 재검토
- H-205(a): Modifier 가드 level 2→3(직접 Get이면 유저 호출부), spec.state 12 단언
- H-208: Ref:Set 스냅샷을 table.clone + 집합 병합으로(사용자 — 더 싸고 dedup 공짜)
- H-209: src 전 파일 pairs/ipairs → generalized iteration(사용자 — 최적화로 더
  빠름; 메타테이블 있는 테이블의 raw 순회 실측 확인). 문서 의사코드 표기는
  H-178과 같은 급으로 무변경
- H-211: Relate:SetWeak의 캐스트 없는 setmetatable 대입이 IDE(strict)에서
  TypeError(플레인 luau-analyze는 솔버 차로 조용) → 로컬 주석 + :: any 경유
- §4 열린 문항 0, 코드 마커 0. 감사 2라운드 수렴(확실 1·의심 1 → 0)

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
This commit is contained in:
qwreey-agent-selene 2026-08-31 13:33:09 +09:00
parent 274da35923
commit e0cb530b6b
No known key found for this signature in database
20 changed files with 235 additions and 98 deletions

View file

@ -73,7 +73,7 @@
| `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 **⚠️ [2026-08-26, 8라운드 `H-120`] 실제로는 `Callback(guard(fn))`이다** — `Ref` 콜백의 *"등록 즉시 1회, 값이 nil이어도"* 계약 때문에 맨 `fn`을 걸면 **생성 시점에 `fn(nil)`이 먼저 불려** `inst`를 바로 쓰는 콜백이 pre-pass에 닿기도 전에 죽는다. `guard(fn) = function(v) if v ~= nil then fn(v) end end`. `Ref` 계약은 안 건드리고 슈가 쪽에서 막는다. |
| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker``state:Apply(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "마일스톤 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 같은 마일스톤**으로 해소.) 구현은 M2 — **[2026-08-29 완료]** `State.luau``:Gate`/`GateNode` + `Blocker.luau`(단위 4) |
| `claim-plan.md` | **[2026-08-28 신설·같은 날 확정, `research/`에서 승격]** 이미 있는 트리(PlayerGui, `Clone()` 사본, Studio에서 만든 GUI)를 quad가 **소유**하는 `Claim(inst, D.Mapper.<Class>(key) {…}) -> inst` — 루트가 Slot일 수 없던 공백(`H-146`/`H-148`)을 `drive` 위의 한 겹(DFS 이름 해석 → bottom-up `drive`)으로 닫는다. 계약은 claim-once·own-all(부기 대상 자식 전부 매핑, 디스크립터 순서가 정본, 이름 중복·부재 UB + debug 검사, 같은 `inst` 이중 claim error, 다중 quad UB). 루트 키는 센티널 `D.Mapper.Root`, props 타입은 `D.<Class>`와 `type <Class>Param` 공유, `Claim`은 타입 인자 없음(추론). **루트의 `.Parent`는 부기 밖이라 밖에서 대입 허용**(`H-146` 예외를 좁혀 복원 — 여러 스크립트가 한 `PlayerGui`를 쓰는 흔한 경우의 답, PlayerGui 직하 Slot 공유는 중간 모듈). `archive/existing-instance-bind-rejected.md`와 다름(재바인드 아님). 프로바이더 op `nativeFindChild` + **`nativeClaim`**(gcconn/gchold 셋업의 유일한 자리 — `New`도 호출; 이중 claim은 그 셋업 유무로 error). **`PlayerGui`류 공동 소유 컨테이너는 claim 대상 아님**(루트는 `ScreenGui`·`SurfaceGui`). `type <Class>Param<E>` 원소 타입 파라미터. **M5 스코프**(`H-161`) |
| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`**배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만** 돌며 값만 앞당기고(**[2026-08-25 `H-85`]** 옛 `rawInvalid` 불린은 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체 — 재계산 *도중* 도착한 무효화를 꼬리가 지우던 것과, `fn`이 던졌을 때 계산된 적 없는 캐시를 유효하다고 확신하던 것 둘을 같이 닫음) 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **[2026-08-31 `H-198`]** §4 "재계산이 끝나면"의 라이브 리비전 "전부 갱신"에 실측 구멍 발견(닫힌 게이트 너머 `fn` 도중 `Set` → 영구 stale — `qa-request/pre-implementation-handtrace-round11.md` §4 회신 대기, 그 절에 ⚠️ 배너). 그 외 열린 설계 항목 없음. **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 — **[2026-08-28 완료]** `EpochMap.luau`/`State.luau`(단위 2) |
| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`**배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만** 돌며 값만 앞당기고(**[2026-08-25 `H-85`]** 옛 `rawInvalid` 불린은 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체 — 재계산 *도중* 도착한 무효화를 꼬리가 지우던 것과, `fn`이 던졌을 때 계산된 적 없는 캐시를 유효하다고 확신하던 것 둘을 같이 닫음) 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **[2026-08-31 `H-198` 확정]** `fn` 도중 닫힌 게이트 상류의 `Set`이 영구 stale이던 구멍은 사용자 확정으로 닫힘 — 상류 스탬프를 `fn` **직전**으로 옮기고 `Get`을 수렴할 때까지 재시작하는 루프로(매 패스 자기 dep을 `Set`하는 `fn`은 UB). **열린 설계 항목 없음.** **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 — **[2026-08-28 완료]** `EpochMap.luau`/`State.luau`(단위 2) |
## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설)

View file

@ -157,6 +157,14 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
바로 접근**한다. `New()` 자신이 내부적으로 어떤 형태로 조립되는지(v1
스타일 `InitXxx(module)` 팩토리 체이닝, 타입 재익스포트)는
`module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고.
- **⭐ [2026-08-31 `H-186`, 사용자 확정] 다중 `New()` 지원은 "같은
인스턴스 안"이 전제다 — 인스턴스를 **가로지르는** 값 혼용(`A`의
`Effect``B``Source`를 dep으로, `A`의 핸들을 `B`
`bindLifetime`에)은 **UB**. 막는 가드를 일부러 안 둔다 — dep 쪽
가드는 절반만 막고(교차 `bindLifetime`은 base가 못 봄), 완전히
막으려면 주입 op 계약까지 번진다. 문서화 대상
(`research/documentation-content-map.md` §4 — `H-116` 두 벌 공존
항목의 이웃), M5에서 실 백엔드가 둘이 되면 재검토("추후 생각해볼 점").
- **M0 스캐폴딩에 주는 함의 — [2026-08-19] 정해짐, 실제로는 M0가 아니라
`ROADMAP.md` M1(실제 스캐폴딩)에 적용됨.** 레지스트리를 module-level
upvalue로 직접 잡아두면 나중에 다중 인스턴스화할 때 전면 수정이

View file

@ -248,19 +248,18 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
function Ref:Set(value)
self.Value = value -- (1) 먼저 확정
self.Revision = bit32.bnot(-self.Revision) -- (2) 콜백보다 **앞**
local snapshot = {} -- (3) [`H-23`] 순회 전 스냅샷
for k in pairs(self.Callbacks) do table.insert(snapshot, k) end
for k in pairs(self.WeakCallbacks) do
-- 양쪽에 다 있는 키는 한 번만 — 안 그러면 그 콜백이 두 번 불린다.
-- ⭐ [2026-08-26, `/code-review high`] **thread는 이 경우가 없다**
-- 대기자를 만드는 `:Wait()``.Callbacks`(강)에만 넣는다
-- (`WeakWait`는 없다). 그래서 아래 소진이 `.Callbacks`만 비우는
-- 게 맞다. 이 dedup은 **함수 키** 전용이다(같은 `fn`
-- `:Callback``:WeakCallback`으로 각각 건 경우).
if self.Callbacks[k] == nil then table.insert(snapshot, k) end
end
-- (3) [`H-23`] 순회 전 스냅샷 — ⭐ [2026-08-31 `H-208`, 사용자] 배열 재구축이
-- 아니라 **`table.clone` + 집합 병합**("table.clone 으로 찍어도 충분해 —
-- 더 싸"). 강한 평범한 클론이라 순회 동안 약한 등록도 살아 있고, 양쪽에
-- 다 있는 키의 dedup은 집합 의미론으로 공짜다(순서는 원래 계약이 아님 —
-- "`.Callbacks`는 배열이 아니라" 절).
-- ⭐ [2026-08-26, `/code-review high`] **thread는 양쪽에 있을 수 없다**
-- 대기자를 만드는 `:Wait()``.Callbacks`(강)에만 넣는다(`WeakWait`는
-- 없다). 그래서 아래 소진이 `.Callbacks`만 비우는 게 맞다.
local snapshot = table.clone(self.Callbacks)
for k in self.WeakCallbacks do snapshot[k] = true end
local revision = self.Revision -- (4) [2026-08-28 `H-169`] 이 파동의 표식
for _, k in ipairs(snapshot) do
for k in snapshot do
if self.Revision ~= revision then
break -- 콜백이 `:Set`을 재진입했다 — 안쪽 파동이 이미 새 값을 전부
-- 돌렸으므로 이 파동의 나머지는 놓는다(아래 재진입 절)

View file

@ -350,6 +350,15 @@ end
목적은 하나뿐 — **못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에
막혀 있었다거나 전파 파동이 아직 이 가지에 안 닿았다거나). `true`가 나오면
`rawInvalid = true`로 만들고 재계산한다.
- **⭐ [2026-08-31 `H-198`, 사용자 확정] 이 판정 전체가 `Get` 안의 루프다 —
한 번 재계산하고 끝이 아니라, 두 갈래(카운터 상이 / `Refresh` 드리프트) 다
거짓인 깨끗한 패스가 나올 때까지 재시작한다.** `fn` 도중에 온 `Set`(재진입이든,
닫힌 게이트에 유보돼 `_receive`에 안 온 것이든)을 그 `Get`이 반환하기 전에
다시 계산해, **모든 `Get`이 최신 읽기로 수렴한 값을 돌려준다**(사용자: *"그럼
항상 최신 값만 읽게되어 문제가 없다"*). 게이트 너머 움직임의 탐지는 아래
"재계산이 끝나면"의 스탬프 위치 이동이 맡는다. **`fn`이 매 패스마다 자기
(전이적) dep을 `Set`하면 수렴하지 않는다 — UB**(사용자 확정; 관측상 무한
재계산 루프).
- **⭐ 순회는 `emitEpochMap`을 건드리지 않고, 뒤로 emit 하지도 않는다.**
그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 **하류까지 정상적으로
전파된다**(사용자: *"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*).
@ -385,16 +394,24 @@ end
-- 재계산
local gen = self.cacheTargetCount -- fn 직전 스냅샷
self.cache = self.fn(self, self.cache, ...)
for _, d in self.deps do d:_track(self.valueEpochMap) end
-- ⭐ [2026-08-31 `H-198`] 스탬프도 fn **직전**
-- fn 도중 움직인 것(게이트 유보 포함)이 뒤에
-- Refresh 드리프트로 보인다. 한때 fn 뒤였고,
-- 그러면 그 움직임을 "본 것"으로 오인해 영구
-- stale이었다(아래 "재계산이 끝나면" 참고)
self.cache = self.fn(self, self.cache, ...)
self.cacheCurrCount = gen -- ⭐ 성공했을 때만
-- 재계산 판정 (아래 `rawInvalid == true` 자리)
-- 재계산 판정 (아래 `rawInvalid == true` 자리 — [2026-08-31 `H-198`] Get 안의 루프,
-- 위 "재계산 판정" 절의 두 갈래가 다 거짓일 때까지 재시작)
if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end
```
- **재계산 도중 도착한 무효화**가 `cacheTargetCount`를 앞서게 만들어
다음 `Get`이 반드시 재계산한다.
- **재계산 도중 도착한 무효화**가 `cacheTargetCount`를 앞서게 만든다 —
**[2026-08-31 `H-198`]** 그걸 다시 계산하는 주체는 "다음 `Get`"이 아니라
**같은 `Get`의 재시작 루프**다(위 "재계산 판정" 절): 그 호출이 수렴한
최신 값을 돌려준다.
- **`fn`이 던지면** `cacheCurrCount`가 안 갱신되므로 *계산된 적 없는
캐시를 유효하다고 확신*하는 일이 없다 — **사용자 지적**: *"특히 에러가
난다고 하면, 다시 계산 안하고 이전 결과를 다시 쓰겠네?"* (그래서
@ -420,24 +437,26 @@ if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end
### 재계산이 끝나면
> **⚠️ [2026-08-31, `H-198` 결정 대기]** 이 절의 "전부 갱신"(라이브 리비전)에
> 실측 구멍이 발견됐다 — `fn` **도중** 닫힌 게이트 상류에서 난 `Set`은 emit이
> 유보돼 카운터 쌍(`H-85`)이 안 움직이는데, 꼬리의 `_track`이 라이브 리비전을
> 찍어 "본 것"으로 기록하므로 나중 flush가 `_invalidate` 없이 통지만 하고
> 캐시가 영구 stale이 된다(재현 실측). 문항과 선택지는
> `qa-request/pre-implementation-handtrace-round11.md` §4의 `H-198` — 회신
> 전까지 이 절을 무결한 확정으로 읽지 말 것(코드는 이 절 그대로 + `TODO(H-198)`).
- **`rawInvalid = false`**(위 카운터로는 `cacheCurrCount = gen`), 그리고 **`valueEpochMap`은 자기가 읽은 상류
전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이
`Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해
최신이다. 사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 전부
업데이트 하는건 맞아보입니다."*
- **⭐ [2026-08-31 `H-198`, 사용자 확정] 그 "전부 갱신"의 자리는 `fn` 뒤가
아니라 `fn` **직전**이다**(위 블록). "재계산이 끝나면"에 남는 건 카운터
갱신뿐이다. fn 뒤에 라이브 리비전을 찍으면 **fn 도중 닫힌 게이트 상류에서
`Set`**(emit이 유보돼 카운터 쌍이 못 보는 자리)까지 "본 것"으로 기록돼
`Refresh` 안전망이 눈멀고 나중 flush가 `_invalidate` 없이 통지만 해서 —
캐시가 **영구 stale**이었다(단위 2 리뷰 실측 재현). fn 직전 스탬프면 그
움직임이 스탬프보다 뒤라 `Get` 루프(위 "재계산 판정" 절)의 `Refresh`
드리프트로 잡혀 같은 `Get` 안에서 재계산된다. `EpochMap` 새 연산은 필요
없다(사용자: *"이미 있는 표면들로 충분히 구현 가능"*).
- **`emitEpochMap`은 안 건드린다 — 계산은 통지가 아니다.**
- 이 "전부 갱신"이 실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가
`Get()`으로 앞당겨 읽는 경우**다. 그때 `valueEpochMap`이 앞서 있으므로,
나중에 게이트가 풀며 보내는 통지는 위 2번(통지만)으로 떨어져 **같은 값을
다시 계산하지 않는다.**
다시 계산하지 않는다**(스탬프가 fn 직전으로 가도 그대로다 — 재계산 시작
시점의 리비전이 이미 유보된 변경의 새 리비전이다).
## 5. emit 페이로드 — `Epoch | EpochSet`, 게이트는 안 싣는다

View file

@ -14,8 +14,8 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터
코어 — Source/State/Store)** — **⭐ [2026-08-28] M2 착수, 자율 구현 구간 진행
중**(규약 `qa-request/pre-implementation-handtrace-round11-brief.md`, 발견
`-round11.md`; **[2026-08-29]** 단위 넷 전부 구현 완료·단위 3·4 끝 절차 완료,
**[2026-08-31]** 단위 2 `/code-review`·§4 배치 회신 1차 처리 완료 — 남은 문항
셋(재질문·보류)의 목록은 `.claude/todos.md` 00번이 소스)(마일스톤이
**[2026-08-31]** 단위 2 `/code-review`·§4 배치 회신 **전량 종결**(열린 문항 0·
코드 마커 0) — 남은 마무리는 `.claude/todos.md` 00번이 소스)(마일스톤이
넘어갈 때 루트 `CLAUDE.md` 머리말도
같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음). **⚠️ [2026-08-24] M2와
M3의 번호·순서가 맞바뀌었다** — 열려 있던 마일스톤 순서 문제가 (a) 순서

View file

@ -31,7 +31,7 @@
| `H-183` | **②** | 3 | 🟡 | Observer 설치 발화 안에서 `self:Subscribe()`/`bindLifetime(inst, self)`를 부르면 `_catchUp``fn`을 중첩 재생하고 생성자가 "already subscribed"로 죽음 — Effect의 `isRunning` 가드(`H-147`)에 해당하는 것이 Observer엔 없음 | ✅ (a) 사용자 확정(2026-08-31) — Observer `_running` 가드(네 진입점 + `_assertBindable`; fn error 시 플래그 잔류는 설계상 인정), `spec.observer` 9 |
| `H-184` | **②** | 1·3 | 🟡 | `bindLifetime`이 부기를 커밋한 **뒤** `_bindDestroying``isRunning` 가드가 던지면 Effect가 묶인 채(`canExecute` 참) `Destroying` 연결 없이 남음 — 순서는 `lifecycle-pattern.md` (1) 그대로라 quad-roblox도 상속 | ✅ (a) 사용자 확정(2026-08-31) — `bindLifetime`이 커밋 전 `value:_assertBindable()` 문의(Effect·Observer 공통 훅), `lifecycle-pattern.md` (1)·mock 동시 반영, `spec.effect` 10·`spec.observer` 9 |
| `H-185` | **②** | 3 | 🟢 | `EffectFn``-> ...(() -> ())` 팩(H-95)인데 런타임은 첫 반환만 cleanup으로 저장 — `return stopA, stopB`가 타입은 통과하고 `stopB`는 조용히 버려짐 | ✅ 사용자 확정(2026-08-31, (a) 아님) — cleanup은 **하나만**: 목록 소진은 표면만 넓혀 기각, 다중 정리는 클로저로 묶는 게 계약(문서·타입 주석 명시, 런타임 무변경) |
| `H-186` | **②** | 3 | 🟡 | 교차 인스턴스 dep(`A.Effect(fn, B.Source(0))`)을 막지도 정의하지도 않음 — dep의 백엔드가 게이팅하고 에러가 엉뚱한 인스턴스를 가리킴; `architecture.md` 13번은 다중 `New()`를 지원으로 서술 | 🔁 재질문 중(2026-08-31) — 사용자: 임플이 `module`을 알아도 dep의 소속 비교엔 노출 표면이 필요, 새 메커니즘이 불가피하지 않은가? → §4 회신 블록의 답변 참고, 결정 대기 (`-- TODO(H-186)`) |
| `H-186` | **②** | 3 | 🟡 | 교차 인스턴스 dep(`A.Effect(fn, B.Source(0))`)을 막지도 정의하지도 않음 — dep의 백엔드가 게이팅하고 에러가 엉뚱한 인스턴스를 가리킴; `architecture.md` 13번은 다중 `New()`를 지원으로 서술 | ✅ (b) 사용자 확정(2026-08-31 회신 3) — **UB 문서화**, 추후 생각해볼 점으로("확인. UB로 놓는게 맞아보여") — `architecture.md` 13번 `H-186` 항목 + `documentation-content-map.md` §4, 코드는 주석만 |
| `H-187` | **②** | 3·4 | 🟢 | `quad-types`의 새 타입 별칭 이름 넷(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`)이 `base/`에 없는 이름 — 시그니처는 문서 그대로, 이름은 구현이 붙임 | ✅ (a) 사용자 확정(2026-08-31) — 이름 그대로 승인("부분부분 타입을 뽑아 재사용성 … 이의 없음"), `quad-types-plan.md`에 기록 |
| `H-188` | ① | 4 | 🟡 | `state:Gate(setup)`가 검증 실패로 error할 때 반쯤 만든 노드가 상류 `_subs`에 남아 다음 `Set``nil` 호출로 죽음(GC 타이밍 의존) | ✅ 실패 시 detach + setup 전 `_onUpstreamEmit = Void`, `spec.gate` 1 |
| `H-189` | ① | 3 | 🟢 | `Observer.Subscribed`가 초기화되지 않아 `nil`(타입은 `boolean`, Effect는 `false`) | ✅ `false`로, `spec.observer` 1 |
@ -47,16 +47,20 @@
| `H-195` | ① | 3 | 🟢 | `effect-plan.md` "의사코드 — 생성자" 절의 `_consumeCleanup` 머리 주석 *"`_cleanup`의 유무가 곧 '설치돼 있는가'가 된다"*가 바로 아래 ⚠️ 문단(*"`_cleanup`의 유무로 판정하면 안 된다"*)·코드 주석과 정면 충돌 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-196` | ① | 3 | 🟢 | `source-state-plan.md` "`state:Observer(fn)`" 절(*"owning leaf가 이미 죽었으면 no-op"*)·"Slot 생존 확인" 절(*"거짓이면 그냥 no-op"*)이 `H-159` 이전 서술 — 지금은 홀드 뒤 재바인드 시 1회 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-197` | ① | 3·4 | 🟢 | `spec.init.luau` 1이 `Effect`/`Blocker`/`onDestroying`의 존재를 안 본다 — 그 파일 헤더가 *"이 런타임 확인이 유일한 가드"*라 하는 `H-80` 드리프트 가드에 단위 3·4 값이 빠짐 | ✅ 반영(2026-08-29, 메인 세션)|
| `H-198` | **②** | 2 | 🔴 | `_recompute` 꼬리의 `dep:_track`**라이브** 리비전을 찍어서, `fn` 도중 닫힌 게이트 상류에서 난 `Set`을 "본 것"으로 오인 — 카운터 쌍은 게이트가 `_receive`를 삼켜 안 움직이고 `Refresh`도 눈멀어, 나중 flush가 `_invalidate` 없이 하류로 전달(영구 stale 캐시, 실측 재현). §4 확정 의사코드 자체의 구멍 | 🔁 재질문 중(2026-08-31) — 사용자 대안: 상류 Set을 플래그로 알리고 재계산을 처음부터 재시작(항상 최신 읽기) → §4 회신 블록의 분석 참고, 결정 대기 (`-- TODO(H-198)`) |
| `H-198` | **②** | 2 | 🔴 | `_recompute` 꼬리의 `dep:_track`**라이브** 리비전을 찍어서, `fn` 도중 닫힌 게이트 상류에서 난 `Set`을 "본 것"으로 오인 — 카운터 쌍은 게이트가 `_receive`를 삼켜 안 움직이고 `Refresh`도 눈멀어, 나중 flush가 `_invalidate` 없이 하류로 전달(영구 stale 캐시, 실측 재현). §4 확정 의사코드 자체의 구멍 | ✅ 사용자 확정(2026-08-31 회신 3, 사용자 안) — 상류 스탬프를 `fn` **직전**으로 + `Get`을 수렴까지 **재시작 루프**로(항상 최신 반환, `EpochMap` 새 연산 없음 — "이미 있는 표면들로 충분"), 매 패스 자기 dep `Set`은 UB. `state-epoch-plan.md` §4·`spec.gate` 10·`spec.state` 6 |
| `H-199` | ① | 2 | 🟡 | `With`/`Compute`의 vararg 수집(`deps[#deps + 1]`)이 nil dep을 조용히 버림 — 중간 nil은 뒤 dep들을 왼쪽으로 밀어 `fn`의 positional lazy 인자가 엉뚱한 자리에 감(실측) | ✅ `collectDeps` 공유 + `dep #N is nil` error(level 3), `spec.state` 13 |
| `H-200` | **②** | 4 | 🟡 | `Impl.Gate`의 detach(`H-188`)는 "비함수 반환" 경로만 — `setup`**던지면** 반쯤 만든 노드가 상류 `_subs`에 좀비로 남음(`_onUpstreamEmit = Void`라 크래시는 없지만 유보 집합을 영영 못 비움). `spec.gate` 1의 "아무것도 안 남긴다" 단언은 반환 경로만 검사 | ✅ (b) 사용자 확정(2026-08-31) — `setup`이 도는 동안 `_subs`에서 떼어두고 성공 후 재등록(pcall 없음), `gate-plan.md` 조립 절·`spec.gate` 1 |
| `H-201` | ① | 2 | 🟡 | `store:Of(nil)`이 RESERVED·rawget을 통과해 Source를 할당한 **뒤** `self[name] =`에서 Luau 내부 에러로 죽고, `Of(123)`은 조용히 성공해 `Names(): { string }`이 숫자를 반환 | ✅ `type(name) ~= "string"` → error level 2, `spec.store` 3 |
| `H-202` | ① | 2 | 🟢 | `Compute``fn`이 함수인지 안 봄(형제 표면 Gate/Observer/Apply는 전부 검증) — 첫 `Get``_recompute` 안 내부 프레임에서 죽음 | ✅ level 2 검증(형제와 같은 급), `spec.state` 13 |
| `H-203` | **②** | 4 | 🟡 | `Off()`의 스냅샷 순회가 `IsBlocked`를 다시 안 읽음 — 첫 게이트 flush의 하류가 `b:On()`을 다시 켜도 남은 핸들이 전부 flush돼 `IsOn() == true`인데 통지가 새어나감(실측). 문서의 미지원 선언은 네스팅(`On`/`On`/`Off` 카운팅)만 다룸 | ✅ (a) 사용자 확정(2026-08-31) — 순회가 핸들마다 `IsBlocked` 재확인, 멈추면 잔여는 다음 `Off`로(절반 emit 상태는 quad 정의상 무문제), `blocker-plan.md`·`spec.blocker` 8 |
| `H-204` | ① | 2 | 🟢 | `Store(defaults)``defaults`가 평범한 테이블인지 안 봄 — 비테이블은 `table.clone` 내부 프레임에서, 맨 Source는 내부 필드(`Revision`)를 지목하는 에러로 죽음 | ✅ 테이블·메타테이블 검증(level 2), `spec.store` 2 |
| `H-205` | **②** | 2 | 🟢 | `_recompute`의 Modifier 가드 `error(…, 2)`가 항상 `Impl.Get`의 자기 줄을 지목(유일한 호출자가 내부 두 줄) — `architecture.md` error 계약이 없애려던 바로 그 결과. lazy 체인이라 어떤 고정 level도 유저 코드에 못 닿음 | ⏸ 보류(2026-08-31) — 사용자: "H205 부터는 이후에 결정할게" (`-- TODO(H-205)`) |
| `H-205` | **②** | 2 | 🟢 | `_recompute`의 Modifier 가드 `error(…, 2)`가 항상 `Impl.Get`의 자기 줄을 지목(유일한 호출자가 내부 두 줄) — `architecture.md` error 계약이 없애려던 바로 그 결과. lazy 체인이라 어떤 고정 level도 유저 코드에 못 닿음 | ✅ (a) 사용자 확정(2026-08-31 회신 3, "권고대로") — level 3(직접 `Get`이면 유저 호출부, 체인 경유면 현행과 동일), `spec.state` 12 |
| `H-206` | ① | 2·3 | 🟢 | `implsOf` 헬퍼 + 4줄 ephemeron 근거 주석이 세 파일(`State`/`Observer`/`Effect`)에 verbatim 중복 — 저장 방식이 바뀌면 세 곳을 다 고쳐야 하고, 미묘하게 다른 네 번째 사본이 레지스트리를 조용히 가름 | ✅ `ImplRegistry.luau` 신설(순수 데이터 접근 — 공유 허용 범위) |
| `H-207` | ① | 2 | 🟢 | `Source.Set``Emit`의 꼬리(리비전 범프 + `emitDown` + self 반환) 전체를 복제 — 한 파일 안에 같아야 하는 계약 두 벌 | ✅ `Set``Impl.Emit(self)` 직접 호출로 위임(같은 타입·같은 임플 — 공유 허용 범위) |
| `H-208` | ① | 1 | 🟢 | (사용자 코드 검토, 2026-08-31) `Ref:Set`의 스냅샷을 배열 재구축으로 뜨고 있었다 — `table.clone` + 집합 병합이 더 싸고 dedup도 공짜 | ✅ 반영(`Ref.luau`, `ref-plan.md` `:Set` 블록) |
| `H-209` | ① | 1~4 | 🟢 | (사용자 코드 검토, 2026-08-31) 전반의 `pairs`/`ipairs` 순회 — Luau generalized iteration(`for k, v in t do`)이 최적화로 더 빠름(`next`와 다르게 동작) | ✅ src 전 파일 일괄 전환(메타테이블 있는 테이블도 raw 순회 실측 확인). 문서 의사코드의 `pairs` 표기는 `H-178`(코드 관례)과 같은 급으로 무변경 |
| `H-210` | ① | 툴링 | 🟡 | (사용자 발견, 2026-08-31) 루트 `default.project.json`이 각 패키지의 `roblox_packages`를 트리에 안 실어 rojo 통합 luau-lsp가 *"Unknown require: game/ReplicatedStorage/roblox_packages/quad_types"* — pesde 가이드는 패키지마다 `src`·`roblox_packages` 형제 매핑 | ✅ 패키지별 Folder(`quad-base`/`quad-roblox`) 아래 `src`+`roblox_packages`로(공유 하나면 quad-roblox 의존성 생기는 순간 충돌 — 사용자 지적), `luau-lsp analyze` unknown-require 0 실측. `project-setup-plan.md` 셋째 함정 |
| `H-211` | ① | 툴링·1 | 🟢 | (사용자 발견, 2026-08-31) `Relate:SetWeak``bucket.WeakMap = setmetatable(…)` 대입이 IDE(luau-lsp, strict)에서 TypeError — 메타테이블 붙은 타입은 평범한 인덱서의 서브타입이 아님. 플레인 `luau-analyze`(test.sh)는 조용(솔버 차) | ✅ 로컬 주석 + `:: any` 경유(모든 솔버 통과). 이웃 `::` 직접 캐스트들(Brand/EpochMap/Observer/Relate 생성자)은 사용자 IDE에서 무발화라 무변경 — 신형 솔버 CLI만 "unrelated"로 봄, 그쪽이 게이트가 되면 그때 일괄 |
## 상세
@ -518,7 +522,7 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
`H-169`: *"callback 도중에 set 된다면 처음부터 다시 수행. 후행의 요소는 set으로 인해
과거의 값을 받지는 않음"*(기존 확정과 동일 의미 — 안쪽 파동이 전부 돌고 바깥 순회는
리비전 비교로 놓음) / `H-170`: (a) 재확인 + **한계 문서화 지시**(원문은 `ref-plan.md`
`:Wait` 절, 등록은 `documentation-content-map.md` §4의 22번). 확정 근거 인용:
`:Wait` 절, 등록은 `documentation-content-map.md` §4의 `H-170` 항목). 확정 근거 인용:
- **`H-182`** (a) + 네이밍: *"slot 에서 _destroyed 이라는 단어를 썼었고 그거와 다른 점은
이건 '죽는 도중만' 확인한다는것 — slot 은 죽으면 재바운딩 못 하지만, Effect 는 다시
@ -561,8 +565,19 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
"Get마다 1회 재계산"으로 수렴하던 것이 무한 재시작 루프가 된다 — 그 모양을 UB로 접을지
같이 결정 필요. 상세는 세션 보고, 결정 대기.
**[2026-08-31 회신 3 — 잔여 셋 + 코드 검토 둘, §4 전량 종결]** 사용자 원문: *"H186: 확인.
UB로 놓는게 맞아보여. 그렇게 닫아두고, 추후 생각해볼 점으로 두면 돼. H198: 확인. epoch
자체에 peek 해볼 수도 있고, 이미 있는 표면들로 충분히 구현 가능해서 동의함. 재시작
모델에서 조건 없이 무한 루프 되는건 UB로 접는거 그대로 두면 될것 같아. 어차피
스텍오버플로우로 터지거든. H205 는 권고대로 가고, H174 는 이미 a로 결정했던거 같아."*
(`H-174`는 맞다 — 2026-08-28 회신에서 이미 (a) 확정, 표도 그렇게 기록돼 있다.) `H-198`
구현 각주 하나: `Get` 재시작을 재귀가 아니라 **루프**로 짰으므로 무한 케이스의 관측은
스택오버플로우가 아니라 무한 재계산(프리즈)이다 — UB 분류는 동일. 같은 회신의 코드 검토
둘은 `H-208`(`table.clone` 스냅샷)/`H-209`(generalized iteration)로 등록, 둘 다 ① 반영.
**이로써 §4의 열린 문항은 0, 코드 마커도 0.**
코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야
한다. **[2026-08-28 기준] 마커 0개**였고 **[2026-08-29 단위 3·4 리뷰 뒤] `H-182`~`H-187` 여섯 개**, **[2026-08-31 단위 2 리뷰 뒤] `H-198`/`H-200`/`H-203`/`H-205` 넷이 더해져 열 개**였다가, **[2026-08-31 회신 2] 일곱이 확정 반영되어 남은 마커는 셋**(`H-186`/`H-198`/`H-205` — 앞 둘은 재질문, 뒤는 사용자 보류)이다(`grep -rn "TODO(H-" quad-base quad-types`). 마커 없이 답변된 §4 행 중 셋(`H-168`~`H-170`)은 단위 1 모듈을 막지 않아 코드는 문서
한다. **[2026-08-28 기준] 마커 0개**였고 **[2026-08-29 단위 3·4 리뷰 뒤] `H-182`~`H-187` 여섯 개**, **[2026-08-31 단위 2 리뷰 뒤] `H-198`/`H-200`/`H-203`/`H-205` 넷이 더해져 열 개**였다가, **[2026-08-31 회신 2] 일곱이 확정 반영되어 셋**(`H-186`/`H-198`/`H-205`)이 남았고, **[2026-08-31 회신 3] 그 셋도 전부 확정 반영되어 지금 마커는 0개**다(`grep -rn "TODO(H-" quad-base quad-types`). 마커 없이 답변된 §4 행 중 셋(`H-168`~`H-170`)은 단위 1 모듈을 막지 않아 코드는 문서
블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제).
## §5 이상 없다고 확인한 것

View file

@ -187,7 +187,14 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부
하나를 만들거나 claim하고 Slot을 반환하는 중간 모듈 패턴; **`PlayerGui`·`CoreGui`
같은 공동 소유 컨테이너는 claim 대상이 아니다**(`base/claim-plan.md` §7-11) —
"왜 PlayerGui를 claim하면 안 되는가"를 설명할 것
22. **[2026-08-31 신설, `H-170` 재확인] `ref:Wait`의 에러 전파 한계** —
22. **[2026-08-31 신설, `H-186` 확정] 교차 인스턴스 값 혼용은 UB** — 위
20번(quad **두 벌** 공존)의 이웃: 같은 벌 안에서도 `New()`로 만든 서로
다른 인스턴스의 값을 섞으면(`A.Effect(fn, B.Source(0))`, `A`의 핸들을
`B.bindLifetime`에) 동작이 정의되지 않는다 — dep 쪽은 상대 백엔드의
스텁/게이트가 엉뚱한 인스턴스를 지목하는 에러로, 생명주기 쪽은 조용히
영영 안 울리는 것으로 나타난다(실측). 막지 않는 이유와 재검토 시점은
`base/architecture.md` 13번의 `H-186` 항목이 소스
23. **[2026-08-31 신설, `H-170` 재확인] `ref:Wait`의 에러 전파 한계** —
`:Set`이 올려주는 건 **단일 resume에서 즉시 돌아온 실패뿐**이다. 대기자가
다시 yield한 뒤(다른 곳에서 resume되어) 나는 에러는 quad의 처리 관할이
아니다 — 다단 yield가 필요하면 사용자가 루프 resume으로 감싸거나 안에서

View file

@ -2053,4 +2053,8 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
반영(`H-182` `_dying` / `H-183` Observer `_running` / `H-184` `_assertBindable` 커밋 전
문의 / `H-185` 단일 cleanup 문서화(권고 기각) / `H-187` / `H-200` detach-중-setup /
`H-203` 순회 중 `IsBlocked` 재확인), 재질문 둘(`H-186`/`H-198` — 메인 답변은
그 §4 회신 2 블록), 보류 하나(`H-205`). 남은 코드 마커 셋.
그 §4 회신 2 블록), 보류 하나(`H-205`). **같은 날 회신 3으로 §4 전량 종결**
`H-186` UB 문서화 / `H-198` 사용자 안(스탬프를 fn 직전 + `Get` 재시작 루프 — 계약
강화: 모든 `Get`이 최신 수렴값 반환, 무한 케이스 UB) / `H-205` level 3 / 코드 검토
`H-208`(`table.clone` 스냅샷)·`H-209`(generalized iteration 전환). 열린 문항 0·마커 0,
남은 마무리는 round11 파일명 변경과 M2 종료 보고.

View file

@ -66,7 +66,7 @@ notification 시점에 파인더 4개가 미완인 채 멈춤. 이번엔 opus
첫 줄에서 이 훅으로 이동, level 3), Gate 생성이 setup 동안 `_subs`에서 떼었다 성공 후
재등록, `Blocker.runHandles`가 핸들마다 `IsBlocked` 재확인. 문서는 `effect-plan`/
`lifecycle-pattern`/`source-state-plan`/`gate-plan`/`blocker-plan`/`ref-plan`(+
`documentation-content-map` 22번, `quad-types-plan` `H-187`)에 반영. 스펙 넷 추가
`documentation-content-map` `H-170` 항목, `quad-types-plan` `H-187`)에 반영. 스펙 넷 추가
(`spec.effect` 10 / `spec.observer` 9 / `spec.gate` 1 확장 / `spec.blocker` 8).
코드 마커는 셋 남음(`H-186`/`H-198`/`H-205`).
@ -76,3 +76,42 @@ notification 시점에 파인더 4개가 미완인 채 멈춤. 이번엔 opus
재시작 루프는 성립하되 게이트 너머 움직임의 탐지는 결국 fn 직전 스냅샷 비교로 귀결 —
사용자 안 = 스냅샷 탐지 + `Get` 재시작 루프, 캐비엇은 자기-dep `Set`의 무한 재시작
(UB로 접을지 함께 결정 필요).
## 같은 날 후속 2 — §4 전량 종결 (2026-08-31, 회신 3)
사용자가 잔여 셋과 코드 검토 둘을 회신: `H-186` (b) UB 문서화("확인. UB로 놓는게
맞아보여" — `architecture.md` 13번 + content-map §4, 추후 생각해볼 점) / `H-198`
사용자 안 확정("이미 있는 표면들로 충분히 구현 가능해서 동의함") — 상류 스탬프를
`fn` 직전으로 + `Get` 재시작 루프, 무한 케이스 UB(구현이 루프라 관측은 스택오버플로우가
아니라 무한 재계산 — round11 §4 회신 3 블록에 각주) / `H-205` (a) level 3 /
`H-174`는 기결정 (a) 재확인 / **`H-208`** `Ref:Set` 스냅샷을 `table.clone`+집합
병합으로("더 싸") / **`H-209`** 전반 `pairs`/`ipairs` → generalized iteration
("최적화로 인해 더 빠르거든") — 메타테이블 있는 테이블(weak/`__index`)의 raw 순회를
스크래치로 실측 확인 후 src 전 파일 전환, 문서 의사코드 표기는 `H-178`과 같은 급으로
무변경. `H-198` 반영으로 **계약이 강화**됐다: `Get`은 이제 fn 도중 온 변경(재진입이든
게이트 유보든)을 같은 호출 안에서 수렴시켜 항상 최신을 돌려준다 — `spec.state` 6이
옛 계약("다음 Get이 재계산")을 단언하고 있어 새 계약으로 갱신, `spec.gate` 10에 리뷰
재현 시나리오(영구 stale이던 그것)가 "같은 Get에서 99 + flush는 통지만"으로 고정.
`spec.state` 12는 level 3 프레임 단언 추가. **§4 열린 문항 0, 코드 마커 0** — 남은
마무리는 파일명 변경(사용자와 이름 결정)과 M2 종료 보고.
## 같은 날 후속 3 — 툴링 픽스 둘(H-210/H-211)과 감사 반영
- **`H-210`** (사용자 발견): 루트 `default.project.json``roblox_packages`가 없어
rojo 통합 luau-lsp가 *"Unknown require: game/ReplicatedStorage/roblox_packages/
quad_types"*. 처음엔 `ReplicatedStorage.roblox_packages` 하나로 붙였다가 **사용자
정정**(*"quad-base quad-roblox 안에 따로 roblox_packages 가 들어가야"*) — 공유 하나면
quad-roblox가 의존성을 갖는 순간 충돌. 패키지별 Folder 아래 `src`+`roblox_packages`
형제 매핑으로(pesde 가이드의 멀티 패키지 판), `rojo sourcemap` + `luau-lsp analyze`
unknown-require 0 실측. 커밋 `274da35`, `project-setup-plan.md` 셋째 함정.
- **`H-211`** (사용자 발견): `Relate:SetWeak`의 캐스트 없는
`bucket.WeakMap = setmetatable(…)` 대입이 IDE(strict)에서 TypeError — 메타테이블
붙은 타입은 평범한 인덱서의 서브타입이 아님. 플레인 `luau-analyze`(test.sh 게이트)는
솔버 차로 조용 — **IDE와 CLI가 다른 걸 본다는 사실 자체가 기록 대상**. 처방은 로컬
주석 + `:: any` 경유(모든 솔버 통과). 전 파일 luau-lsp 스윕에서 이웃 `::` 직접
캐스트들(Brand/EpochMap/Observer/Relate 생성자)이 신형 솔버 CLI에서만 "unrelated"로
걸리는 것도 확인 — 사용자 IDE·현행 게이트 모두 무발화라 무변경(관측된 문제만),
신형 솔버가 게이트가 되는 날 일괄 전환.
- 회신 3 감사 1라운드(확실 1·의심 1) 반영: `state-epoch-plan.md` H-85 절의 옛 계약
문장("다음 Get이 반드시 재계산")을 H-198 재시작 루프로 정정, `todos.md` 00의 H-번호
나열을 소스 포인터로 축소(머리말 규칙).

View file

@ -13,13 +13,14 @@
단위 3(`Observer`/`Effect`)·단위 4(`GateNode`/`Blocker`) 구현 완료(M2 체크박스 전부 `[x]`), 단위 3·4 끝 절차(감사·리뷰·탐사)도 완료. **[2026-08-31]** 체크포인트에
미완으로 남았던 단위 2 파일 `/code-review high`도 완료(포크 재개 지시로 완주) —
발견 10건 중 ① 여섯 반영, ② 넷(`H-198`/`H-200`/`H-203`/`H-205`)이 §4 합류.
**같은 날 §4 배치 회신 1차 처리 완료** — 확정 일곱 반영(`H-182`~`H-185`/`H-187`/
`H-200`/`H-203`), **남은 재개 지점은 셋: `H-186`·`H-198`(재질문 회신 대기, 메인
답변은 `round11.md` §4 "[2026-08-31 회신 2]" 블록)과 `H-205`(사용자 보류)** —
진행 원문은 `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절과
`session/2026-08-31-01-unit2-code-review.md`. 그 뒤 할 일 하나:
**같은 날 §4 배치 회신 셋(1차·재질문·코드 검토)으로 전량 종결** — 문항 열과
코드 검토 발견들 전부 확정·반영(개별 항목·인용은 그 발견 문서(`-round11.md`)의 요약 표와 §4
회신 블록들이 소스 — 여기서 세지 않는다), **§4 열린 문항 0·코드 마커 0** —
진행 원문은
`session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절과
`session/2026-08-31-01-unit2-code-review.md`. 남은 할 일:
`pre-implementation-handtrace-round11*` 파일명 변경(사용자 2026-08-31 — 구현 중
문서라 pre-implementation이 안 맞음; 새 이름은 그때 사용자와 정할 것).
문서라 pre-implementation이 안 맞음; 새 이름은 사용자와 정할 것)과 M2 종료 보고.
아래는 착수 전(2026-08-26) 서술:
**[2026-08-26] 8라운드까지 전부 처리 완료 — M2 착수 게이트가 0이다.**

View file

@ -6,8 +6,8 @@ M2(반응형 코어 — Source/State/Store)** — **⭐ [2026-08-28] M2 착수,
구간 진행 중**(규약은 `.claude/qa-request/pre-implementation-handtrace-round11-brief.md`,
발견·배치 문항은 `-round11.md`; **[2026-08-29]** 단위 넷 전부 구현 완료 — `ROADMAP.md` M2
체크박스 전부 `[x]`, 단위 3·4 끝 절차 완료; **[2026-08-31]** 단위 2 `/code-review`
§4 배치 회신 1차 처리까지 완료 — 남은 문항 셋(재질문·보류)의 목록은
`.claude/todos.md` 00번이 소스). **⚠️ [2026-08-24] M2와 M3의
§4 배치 회신 **전량 종결**(열린 문항 0·코드 마커 0) — 남은 마무리(파일명 변경,
M2 종료 보고)는 `.claude/todos.md` 00번이 소스). **⚠️ [2026-08-24] M2와 M3의
번호·순서가 맞바뀌었다** — 예전엔 M2=디스패치, M3=반응형이었는데 의존이
한 방향(디스패치 → 반응형)이라 반응형을 먼저 짓기로 확정했다. 그래서
**2026-08-24 이전에 쓰인 `session/`·`archive/`·`qa-request/` 문서의

View file

@ -52,10 +52,10 @@ end
-- Shared by Off/OffWithoutEmit — the only difference is the flag passed on.
local function runHandles(self: any, doEmit: boolean)
local snapshot: { (boolean) -> () } = {}
for handle in pairs(self._handles) do -- `H-63` (3): snapshot, then walk
for handle in self._handles do -- `H-63` (3): snapshot, then walk
snapshot[#snapshot + 1] = handle
end
for _, handle in ipairs(snapshot) do
for _, handle in snapshot do
if self.IsBlocked then
-- `H-203` (a): a downstream fired by an earlier handle's flush re-blocked
-- this blocker — stop; the remaining handles keep their withheld batches

View file

@ -228,8 +228,9 @@ local function createImpl(module: any)
-- (0) deps validation — once, in the constructor. `select("#")` so a nil hole
-- does not silently truncate the list.
-- TODO(H-186): no check that a dep belongs to THIS quad instance — a cross-instance dep
-- is gated by the dep's backend (`d:Observer` resolves through its own impl).
-- `H-186` (user-confirmed 2026-08-31): a dep from ANOTHER quad instance is UB —
-- deliberately not checked (a guard here closes only half the hole: a foreign
-- `bindLifetime` on our handle is unreachable from base). Documented, revisit at M5.
local seen = {}
for i = 1, select("#", ...) do
local d = (select(i, ...))
@ -257,7 +258,7 @@ local function createImpl(module: any)
local function onStateFire(_targetState: any, _observer: any, from: any)
fire(from) -- Observer: the 3rd arg is the source
end
for d in pairs(seen) do
for d in seen do
if isRef(d) then
self._deps[d] = onRefFire -- strong owner = Effect
d:WeakCallback(onRefFire) -- the Ref side is weak

View file

@ -61,7 +61,7 @@ function EpochMapImpl.Update(self: EpochMap, from: Epoch | EpochSet): boolean
return updateOne(map, from :: Epoch)
end
local changed = false
for epoch in pairs(from :: EpochSet) do
for epoch in from :: EpochSet do
if changed then
map[epoch] = epoch.Revision -- answer already fixed: write only
else
@ -77,7 +77,7 @@ function EpochMapImpl.Peek(self: EpochMap, from: Epoch | EpochSet): boolean
local epoch = from :: Epoch
return map[epoch] ~= epoch.Revision
end
for epoch in pairs(from :: EpochSet) do
for epoch in from :: EpochSet do
if map[epoch] ~= epoch.Revision then
return true
end
@ -88,7 +88,7 @@ end
function EpochMapImpl.Refresh(self: EpochMap): boolean
local map = (self :: any)._map
local changed = false
for epoch, revision in pairs(map) do
for epoch, revision in map do
local live = epoch.Revision
if live ~= revision then
changed = true
@ -105,14 +105,14 @@ function EpochMapImpl.Sync(self: EpochMap, from: Epoch | EpochSet)
map[epoch] = epoch.Revision
return
end
for epoch in pairs(from :: EpochSet) do
for epoch in 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
for epoch in (other :: any)._map do
map[epoch] = epoch.Revision -- live revision, not other's stored one
end
end

View file

@ -44,24 +44,20 @@ 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 } = {}
-- (3) snapshot before iterating (`H-23`): registering/unregistering during
-- the walk must not disturb the iteration. `H-208` (user, 2026-08-31):
-- `table.clone` + set-merge — cheaper than rebuilding an array, and the
-- strong plain clone keeps weak-registered callbacks alive for the walk.
-- Both-table dedup is free (set semantics); `:Wait()` (M8) puts its thread
-- waiters in `.Callbacks` only, so draining below stays right.
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
local snapshot: { [any]: true } = table.clone(callbacks)
for k in weakCallbacks do
snapshot[k] = true
end
local revision = self.Revision
for _, k in ipairs(snapshot) do
for k in snapshot do
if self.Revision ~= revision then
-- [`H-169`, user-confirmed 2026-08-28] a callback re-entered `:Set`: that
-- inner walk already delivered the newer value to everyone, so this

View file

@ -60,7 +60,9 @@ function RelateImpl:SetWeak(inst: any, key: any, value: any)
local bucket = getOrCreateBucket(self, inst)
local weakMap = bucket.WeakMap
if weakMap == nil then
local newMap = setmetatable({}, WEAK_VALUE_MT)
-- `:: any` detour: the metatable-carrying type `{ @metatable WEAK_VALUE_MT, {} }`
-- is not a subtype of the plain indexer `Bucket.WeakMap` expects (`H-211`).
local newMap: { [any]: any } = setmetatable({}, WEAK_VALUE_MT) :: any
bucket.WeakMap = newMap
newMap[key] = value
return

View file

@ -74,13 +74,13 @@ local function createImpl(module: any)
-- ── 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.
-- H-23: adding a key during iteration is undefined, so snapshot first.
-- "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약.
local snap = {}
for sub in pairs(self._subs) do
for sub in self._subs do
snap[#snap + 1] = sub
end
for _, sub in ipairs(snap) do
for _, sub in snap do
(sub :: any):_receive(from) -- every subscriber is an `EmitReceive` (`H-163`)
end
end
@ -88,7 +88,7 @@ local function createImpl(module: any)
-- ── node creation ─────────────────────────────────────────────────
local function newNode(deps: { any }, fn: any, mt: any?): any
for i, dep in ipairs(deps) do
for i, dep in deps do
if not isState(dep) then
error(`State: dep #{i} is not a State/Source`, 3) -- 3: past Compute/With/Gate to the user's call
end
@ -105,7 +105,7 @@ local function createImpl(module: any)
}, mt or Impl)
StateBrand:register(self) -- `H-152`: a GateNode is a State too — first line of its assembly
-- §4 seeding: valueEpochMap takes every upstream epoch, live.
for _, dep in ipairs(deps) do
for _, dep in deps do
dep._subs[self] = true
if isEpoch(dep) then
self._valueEpochMap:Sync(dep)
@ -141,35 +141,46 @@ local function createImpl(module: any)
function Impl._recompute(self: any)
local gen = self._cacheTargetCount -- snapshot right before fn (`H-85`)
local hold = self._hold
-- `H-198` (user-confirmed 2026-08-31): stamp upstream revisions BEFORE fn —
-- anything that moves DURING fn (a reentrant Set, or one withheld by a
-- closed gate, which never reaches our `_receive`) then shows up afterwards
-- as `Refresh` drift and `Get`'s loop recomputes. Stamping after fn marked
-- those moves as already seen (permanent stale cache).
for _, dep in hold do
dep:_track(self._valueEpochMap)
end
-- `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
-- TODO(H-205): level 2 here always blames Impl.Get's own line (its only
-- callers are internal) — the contract wants user-code blame; level TBD.
error("State: a Compute function returned a Modifier — State/Source cannot hold Modifiers", 2)
-- `H-205`: level 3 — `_recompute`'s only callers are Get's own lines, so 2
-- always blamed quad internals; 3 reaches the user on a direct `Get` and is
-- never worse through a pass-through chain (no fixed level reaches user code there).
error("State: a Compute function returned a Modifier — State/Source cannot hold Modifiers", 3)
end
self._cache = result
for _, dep in ipairs(hold) do
-- TODO(H-198): tracking LIVE revisions here marks as seen an upstream Set
-- that landed DURING fn but whose emit a closed gate withheld — the later
-- flush then forwards downstream without `_invalidate` (permanent stale cache).
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
-- `H-198` (user-confirmed): a LOOP, not a single pass — restart until one
-- recompute completes with nothing moving under it, so every Get returns a
-- value computed from the latest reads ("항상 최신 값만 읽게되어"). Counter
-- mismatch catches Sets our `_receive` saw (`H-85`); `Refresh` drift catches
-- the ones it never did (behind a closed gate). Values only — notification
-- still waits for the real emit. An fn that re-Sets its own (transitive)
-- dep on every pass never converges: UB (user-confirmed).
while true do
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
else
return self._cache
end
end
end
-- ── derived-node methods (shared by Source through `__index`) ─────
local function passThrough(s: any)
@ -229,7 +240,7 @@ local function createImpl(module: any)
if isEpoch(from) then
withheld[from] = true
else
for epoch in pairs(from) do
for epoch in from do
withheld[epoch] = true
end
end

View file

@ -69,7 +69,7 @@ local function Init(module: any)
function Impl.Names(self: any): { string }
local names = {}
for name in pairs(self) do
for name in self do
names[#names + 1] = name
end
return names
@ -88,7 +88,7 @@ local function Init(module: any)
end
end
local self = table.clone(defaults or {}) -- `or {}`: `Store<<{}>>()` is valid (`H-83`)
for name, value in pairs(self) do
for name, value in self do
if RESERVED[name :: any] then
error(`Store: "{tostring(name)}" is a reserved store key`, 2)
end

View file

@ -258,5 +258,36 @@ do
print("PASS")
end
print()
print("=== 10. H-198 — 닫힌 게이트 너머 fn 도중의 Set: 같은 Get 안에서 재시작해 최신으로 수렴, flush는 통지만 ===")
do
-- 리뷰 재현 모양(영구 stale이던 그 시나리오): fn 도중의 S:Set(99)는 게이트가
-- emit을 유보해 N의 _receive에 안 오지만, fn 직전 스탬프와 Refresh 드리프트로
-- 잡혀 같은 Get이 재시작한다(H-85의 카운터가 못 보는 자리를 스냅샷이 본다).
local S = Source(1)
local b = Quad.Blocker()
local g2: State<number> = S:Apply(b)
b:On()
local first = true
local runs = 0
local N: State<number> = g2:Compute(function(x)
runs += 1
local v = x:Get()
if first then
first = false
S:Set(99) -- lands DURING fn, behind the closed gate
end
return v
end)
assert(N:Get() == 99 and runs == 2, "the same Get restarted and returned the post-Set value")
assert(N:Get() == 99 and runs == 2, "stable — no further recompute")
-- 나중 flush는 규칙 2로 떨어진다: 통지만, 재계산 없음
local p = probe(N)
b:Off()
assert(p.count == 1, "the flush still notifies downstream (rule 2)")
assert(runs == 2, "…but recomputes nothing — the value was already current")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -142,7 +142,7 @@ do
end
print()
print("=== 6. 캐시 카운터 쌍 (H-85) — 재계산 도중 온 무효화는 다음 Get이 반드시 다시 계산 ===")
print("=== 6. 캐시 카운터 쌍 (H-85) + Get 재시작 루프 (H-198) — 재계산 도중 온 무효화는 같은 Get이 수렴할 때까지 다시 계산 ===")
do
local s = Source(1)
local runs = 0
@ -155,8 +155,9 @@ do
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")
-- [H-198, 사용자 확정 2026-08-31] 옛 계약("다음 Get이 다시 계산")에서 강화 —
-- Get이 루프라 같은 호출 안에서 재시작해 항상 최신으로 수렴한 값을 돌려준다.
assert(d:Get() == 2 and runs == 2, "the same Get restarted and returned the converged value")
assert(d:Get() == 2 and runs == 2, "then stable")
-- fn이 던지면 cacheCurrCount가 안 갱신돼 다음 Get이 다시 시도한다
local boom = true
@ -306,7 +307,7 @@ do
end
print()
print("=== 12. 가드 — Compute 결과가 Modifier면 캐싱 전에 error(level 2); dep이 State가 아니면 error ===")
print("=== 12. 가드 — Compute 결과가 Modifier면 캐싱 전에 error(level 3, H-205); dep이 State가 아니면 error ===")
do
local mod = {}
Brand.ModifierBrand:register(mod)
@ -318,6 +319,9 @@ do
d:Get()
end)
assert(not ok and string.find(tostring(err), "Modifier", 1, true) ~= nil, "rejected: " .. tostring(err))
-- [H-205, 사용자 확정 2026-08-31] level 3 — 직접 Get이면 사용자 호출부를 가리킨다
-- (체인 경유면 상류 Get의 내부 프레임 — 어떤 고정 level도 거기선 유저 코드에 못 닿음)
assert(string.find(tostring(err), "spec.state.luau", 1, true) ~= nil, "direct Get: blames the caller, not quad internals")
assert((d :: any)._cache == nil, "nothing was cached")
local ok2, err2 = pcall(function()
s:Compute(function(x)