diff --git a/.claude/audit/luau-test-first-run-2026-08-13.md b/.claude/audit/luau-test-first-run-2026-08-13.md new file mode 100644 index 0000000..381a7a4 --- /dev/null +++ b/.claude/audit/luau-test-first-run-2026-08-13.md @@ -0,0 +1,170 @@ +# `.claude/luau-test/` 첫 실측 결과 (2026-08-13 여섯 번째 세션) + +**배경**: 2026-08-09 열두 번째 세션에 스파이크를 만들기 시작한 이래 +**처음으로 `luau`/`luau-analyze` 바이너리가 사용 가능해져 실제로 돌려본 +결과**. 그동안 CLAUDE.md가 "M0 착수 전 남은 유일한 게이트"로 꼽아온 항목. + +사용자 요청: "지금 상황에서 문제가 생겨 프로젝트의 구조 변경이 생기면 큰 +작업인데, 이것이 더 큰 스파이크로 번지기 전에 미리 확인하고싶습니다." + +## 런타임 스파이크 (`luau`) + +| 파일 | 판정 | 요지 | +|---|---|---| +| `01-two-pass-array-hash-order` | ✅ 통과 | 배열 파트 전체 → 해시 파트 순으로 처리됨. `Dispatch.drive`의 두 패스 계약과 `PreRef` 호이스팅이 기대는 바로 그 순서 | +| `02-none-sentinel-vs-nil-holes` | ✅ 통과 | `nil` 소진 시 `#t`가 50→**49**로 무너지고 순회가 흐트러짐 / `None` 소진은 `#t`가 항상 50. 반대로 Ref 콜백 배열은 `None`을 쓰면 1000회 반복 후 **죽은 슬롯 1000개**가 그대로 남음 — 두 배열의 규칙이 서로 반대여야 한다는 2026-08-09 열한 번째 세션 정정이 정량적으로 확인됨 | +| `03-recursive-store-bind-dispatch` | ✅ 통과 | StoreBind 재귀 재-dispatch, `None`→`nil` 흘러가기, 무한재귀 없이 종료 | +| `04-dispatch-chain-retractFrom` | ✅ 통과 + **버그 재현** | 아래 별도 절 | +| `05-store-state-diamond-propagation` | ✅ 통과 | 다이아몬드 의존성에서 `stateC` 재계산이 정확히 1회(중복 재계산 없음), invalidate는 2번 도달하지만 2번째가 즉시 중단 | +| `06-component-boundary-nil-hole-props` | ✅ 통과 | `or None` 없으면 앞쪽 nil-hole로 `bad[1]`/`bad[2]`가 사라짐, 관용구 쓰면 항상 5칸 유지 | +| `07-relate-weak-table-gc` | ✅ 통과(**이번에 보강 후**) | 아래 별도 절 | +| `11-modifier-illegal-value-error` | ⚠️ 부분 | 대부분 의도된 가드 에러로 통과하나 "다른 Modifier" 케이스만 브랜드 판별이 크래시해 **엉뚱한 이유로 통과** — 수정 진행 | +| `17-modifier-index-tableclone-chaining` | ❌ 크래시 | `attempt to call a number value`(44행)로 죽어 **아무것도 검증 못 함** — 수정 진행 | +| `18-relate-mutual-cycle-gc` | ✅ 통과 | 아래 별도 절 | +| `20-slot-splice-index-arithmetic` | ✅ 통과 | 11개 경계 케이스 전부 참조 구현과 일치(delta 양/음, 맨앞/맨끝, 전체 교체 등) | + +`10-roblox-studio-checks.server.luau`는 Studio 전용이라 이 라운드 범위 밖 +(부분 결과는 `audit/gcconn-trick-verification.md`). + +## `04` — 이번 세션 감사가 찾은 버그가 실측으로 재현됨 (가장 중요) + +같은 스크립트가 두 시나리오를 돌림: `chains:SetStrong`을 `handler.process` +**앞**에 두는 정상 설계와, **뒤**에 두는 음성 대조군. + +| 관측 지점 | 정상(수정본) | 음성 대조군(버그) | +|---|---|---| +| [1] 최초 마운트 후 체인 깊이 | **3** (기대) | **1** ← 인덱스 2/3 retractor 유실 | +| [3] 바깥 store 재발행 후 옛 inner 구독 | **0** (끊김) | **1** ← 안 끊김 | +| [4] 죽은 store를 건드렸을 때 | `w1` 유지 | **`STALE`로 덮어써짐** | + +즉 이 버그는 "리소스가 좀 샌다" 수준이 아니라 **이미 버려진 store가 +나중에 UI를 덮어쓰는** 증상까지 간다는 게 실측으로 확인됨. 수정 +(`SetStrong` hoist + no-op 점유 마커)이 옳았다는 결정적 근거. + +## `07` — 스파이크를 보강해야 실제 검증이 됐음 + +원래 3번 섹션이 "강하게 붙잡아둔 10개가 살아있는가"라는 **sanity check만** +하고 있었고, 헤더가 내세운 핵심 주장("inst가 죽으면 중첩된 것까지 전부 +같이 GC")은 검증되지 않은 채였음. 파일 자신도 "Luau가 weak table 내부 +엔트리 개수를 세는 표준 API를 안 줘서 직접 카운트 불가"라고 적어뒀는데 +**그 전제가 틀렸음** — outer가 `__mode="k"`이므로 GC 후 죽은 엔트리는 +`pairs` 순회에서 그냥 사라져 직접 셀 수 있음. + +그래서 이번에 `relate._countEntries()`(테스트 전용)와 **weak-value canary +레지스트리**를 추가해 4번 섹션을 신설, 연쇄 GC를 직접 검증: + +``` +inst 5개만 살린 상태에서 살아남은 payload 수: 5 (기대 5 — 45개는 연쇄 GC) +relate4의 살아있는 엔트리 총 개수: 5 (기대 5) +모든 inst 참조를 놓은 뒤 살아남은 payload 수: 0 (기대 0) +relate4의 살아있는 엔트리 총 개수: 0 (기대 0) +``` + +**`base/bind-system-plan.md` "왜 GC-안전한가"와 `base/relate-plan.md` +전체가 기대고 있는 전제가 실측 확인됨** — quad의 GC-native 아키텍처 +(명시적 Destroy 강제 없음, `bindLifetime`으로 매달아둔 자원이 inst와 함께 +자동 소멸)가 실제로 성립함. + +## `18` — `Relate` 상호 순환 경고가 실측 확인됨 + +`base/relate-plan.md` "위험한 패턴" 절이 공식 문서 인용(Luau에 ephemeron +없음)으로만 뒷받침되던 주장: + +``` +1. 상호 강참조 순환: inst 살아있음 = true, value 살아있음 = true (GC 못 풂) +2. 한쪽을 weak-value로 낮춤: inst 살아있음 = false, value 살아있음 = false (풀림) +``` + +**추측이 아니라 실제로 GC가 안 되는 게 확인됨** — `Slot`의 +`kSlotMap`/`slotOwner`를 둘 다 `SetWeak`로 낮춘 2026-08-12 열세 번째 +세션 결정이 필수 조치였음이 입증됨. + +## 타입 스파이크 (`luau-analyze`) — 판정 완료 + +| 파일 | 판정 | 요지 | +|---|---|---| +| `08-type-source-satisfies-state` | ✅ 부분통과 | 핵심 질문(`Source`를 `State` 자리에 그대로 넘기기)은 클린 통과 — 구조적 서브타이핑 성립. 별개로 `State`가 **자기 자신**을 다른 타입 인자로 재귀 참조하면 `Recursive type being used with different parameters` — M0에서 타입 선언 작성 시 유의할 좁은 제약 | +| `09-type-modifier-overridden-subtype` | ✅ 통과 | 문서가 우려한 지점(`FrameModifier`↔`GuiObjectModifier`)이 그대로 재현 — `Apply`의 리턴 타입 불일치로 서브타입이 깨짐. fallback(`any`)은 정상. 확인하려던 걸 정확히 확인 | +| `12-type-attribute-generic-key-narrowing` | ❌ 실패(**설계 영향 없음**) | 제네릭 키의 `T`가 이름별로 고정 안 되고 호출마다 독립 추론돼 narrowing이 전혀 강제 안 됨. **`attribute-plan.md`가 이미 이 결과를 fallback으로 예비**("안 되면 `BooleanAttribute` 같은 타입 패밀리가 유일하게 믿을 수 있는 정적 체크 경로") — 문서의 "[실측 필요]" 마커만 "확인됨: 안 됨"으로 갱신하면 됨 | +| `13-type-ref-preref-subtype` | ✅ 통과 | `PreRef`가 `Ref` 자리에 대입 가능 — 진단 0건 | +| `14-type-nilable-default-overload` | ⚠️ 부분통과 | 의도한 오용은 정확히 막지만 **정상 nilable 사용례까지 같이 막아** 현 스케치로는 채택 불가 | +| `15-type-compute-trailing-deps-typepack` | ❌ 검증불가 | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 파일 전체가 파싱 실패. 다만 파서가 복구 후 낸 진단에서 **아래 1번 이슈**가 드러남 | +| `16-type-store-key-typefunction` | ❌ 실패 | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음. 문서가 스스로 "API 이름이 다를 수 있다"고 예비해둔 케이스 | + +## ⚠️ 실측으로 드러난 진짜 설계 이슈 — `:Compute(fn)`의 lazy 핸들 계약이 Luau 추론과 충돌 + +**이번 실측 라운드에서 나온 가장 중요한 발견.** 최소 재현으로 원인을 +정확히 좁혔음(에이전트 보고를 액면 그대로 받지 않고 직접 검증): + +| 형태 | 진단 | +|---|---| +| 콜백이 `State`(lazy 핸들)를 받음, **무주석 인라인 람다** | ❌ 2건 | +| 위 + `Get`을 `read`로 선언 | ❌ 여전 | +| 위 + `Get: (self: State) -> T` 형태로 변경 | ❌ 여전 | +| 콜백 **파라미터에 타입 주석**을 달면 | ✅ 0건 | +| 콜백이 **raw 값 `T`** 를 받으면(`fn(v)`) | ✅ **0건** | + +즉 **원인은 "콜백이 lazy `State` 핸들을 받는다"는 quad의 커링 계약 +그 자체**임. `read`/`self` 표기 조정으로는 안 풀리고, raw 값을 넘기면 +완벽히 추론됨. + +```lua +-- 지금 확정된 관용구 — 타입 추론 실패 +state:Compute(function(s) return s:Get() * 2 end) +-- 우회책 1: 파라미터 주석(가장 흔한 자리에 매번 타입을 써야 함) +state:Compute(function(s: State) return s:Get() * 2 end) +``` + +**이건 `:Compute`만의 문제가 아님** — `Effect`/`Observer`/`Animate`/ +`Operator` 카탈로그 등 "콜백이 lazy 핸들을 받는다"는 계약을 공유하는 +API 전부에 걸림. 2026-08-07 일곱 번째 세션의 커링 스타일 확정, +2026-08-12 네 번째 세션의 "`:Get()` 누락 버그 전역 감사"가 전부 이 계약 +위에 서 있음. + +**결정은 사용자 몫** — `.claude/question.md`에 올림. 선택지: +1. 계약 유지 + 파라미터 주석 필수(인체공학 손해, 가장 흔한 자리에 매번). +2. 콜백이 raw 값을 받도록 전환(추론은 완벽해지나 lazy/trailing-deps/ + `previous` 설계 전반과 충돌 — **구조 변경 규모가 큼**). +3. 혼합(무주석은 raw, 명시적으로 lazy가 필요할 때만 별도 API). + +## 그 외 — 스파이크 코드 결함이었던 것들 (전부 수정 완료) + +- **`17` 크래시의 원인이 실은 문서 결함이었음** — `modifier-plan.md`의 + "데이터를 테이블에 직접 두고"가 "self 최상위 리터럴 키"로 읽힐 여지가 + 있었는데, 그렇게 하면 `__index`가 `rawget` 성공 시 안 불리므로 **같은 + 필드를 두 번째로 변환 함수와 함께 호출하는 순간 죽음**(`attempt to call + a number value`). 그 재호출 패턴이 바로 문서 3·4번 절의 대표 용례라 + 실사용에서 즉시 터지는 경로였음 — `modifier-plan.md`에 경고 문단 + 추가하고, 필드를 내부 저장소에 두는 구조로 `17`을 재작성해 통과 확인. +- `11`의 "다른 Modifier" 케이스가 브랜드 판별 크래시로 **엉뚱하게 통과**하던 + 것 수정 — 이제 의도된 가드 에러로 검증됨(16개 케이스 전원 통과). +- `19`의 B/C 섹션을 현행 설계로 재작성(옛 `rawNew`+`owners`, 3분기 + `claimOwner` 폐기 반영). **음성 대조군을 넣어** 옛 로직이 `Slot{a,a}`와 + `Frame{slot,slot}`을 조용히 통과시키는 것까지 재현 확인. +- `07`을 보강(위 절 참고). + +**최종 런타임 상태: 12개 전원 통과**(01/02/03/04/05/06/07/11/17/18/19/20), +crash 0, FAIL 0. + +## 스파이크 자체 수정이 더 필요한 것 (설계 문제 아님) + +- `13`: B 런타임 섹션이 A의 더미 스텁에 막혀 단독 실행 불가 — 분리 필요. +- `15`: 음성 대조군을 별도 파일/블록으로 격리해 `SyntaxError`가 A/B/D + 판정을 막지 않도록. +- `16`: 설치된 버전의 `types.*` 실제 API 재확인 후 재시도. + +## 결론 + +**런타임 설계는 전부 성립** — 검증된 주장마다 예외 없이 통과했고, 특히 +GC-native 아키텍처의 핵심 전제(연쇄 GC)와 `Relate` 상호 순환 경고가 +실측으로 확정됨. 이번 세션 감사가 찾은 버그도 음성 대조군으로 재현되어 +**감사→수정 사이클이 실측으로 닫힘**. + +**타입 쪽에서 하나가 걸림** — `:Compute(fn)`의 lazy 핸들 계약이 Luau +양방향 추론과 충돌(위 절). 사용자가 우려한 "구조 변경이 생기면 큰 작업"에 +해당할 수 있는 유일한 항목이고, **선택지 2를 고르면 실제로 큰 작업**이므로 +M0 착수 전에 결정해두는 게 맞음. + +`modifier-plan.md`의 `__index` 저장 위치 모호성은 문서 결함이었고 이번에 +수정 — 스파이크가 없었으면 M2/M6 구현 중에 터졌을 건이라, 실측 라운드 +자체의 값어치를 보여준 사례. diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 338d23e..a0ca67c 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -200,7 +200,22 @@ Compute를 합치고 싶다"는 동기였는데, 이미 두 가지로 다 커버 문법 설탕이고, `mod.FontSize`는 `FontSize`가 리터럴 키로 안 박혀있으니 `__index(self, key)`가 잡음 — 그러니 `__index`가 **어떤 key가 오든** 그 key를 클로저에 캡쳐한 `function(self, arg) local clone = table.clone(self) -... end`류 함수를 즉석에서 만들어 리턴하기만 하면 끝. 즉 `:FontSize`/ +... end`류 함수를 즉석에서 만들어 리턴하기만 하면 끝. + +> **⚠️ [2026-08-13 여섯 번째 세션, 실측 중 발견] 필드 값은 `self`의 리터럴 +> 키에 저장하면 안 되고, 반드시 `__index`를 거치는 내부 저장소에 둬야 함.** +> `__index`는 `rawget`이 **실패할 때만** 불리므로, `clone.FontSize = 14`처럼 +> self 최상위에 값을 박아두면 다음번 `mod:FontSize(fn)` 호출에서 +> `mod.FontSize`가 `__index`를 안 거치고 저장된 숫자 `14`를 그대로 돌려주고, +> `(14)(mod, fn)`이 되어 `attempt to call a number value`로 죽음. 위 4번 +> 절의 "이전 값을 바탕으로 계산"(`mod:X(function(old) ... end)`)과 3번 절의 +> "상위 TextStyle을 상속해 타이틀만 1.2배" 용례가 **정확히 이 재호출 +> 패턴**이라 실사용에서 바로 터지는 경로임. 해법: 필드 데이터를 유일 테이블 +> identity 키(`FieldsKey` 등)로 분리된 내부 테이블에 담고, `table.clone`이 +> 얕은 복사라 setter 안에서 그 내부 테이블도 따로 `table.clone` 할 것. +> `luau-test/17`이 이 구조를 실측 검증함(원래 스파이크가 리터럴 키 방식으로 +> 짜여 있다가 바로 이 이유로 크래시했고, 그 과정에서 이 문서의 "데이터를 +> 테이블에 직접 두고"라는 표현이 두 가지로 읽힌다는 게 드러남). 즉 `:FontSize`/ `:Round`/앞으로 생길 어떤 필드 이름이든 전부 이 **하나의 제네릭 `__index` 구현**이 처리 가능 — 필드별로 미리 등록된 메소드가 하나도 없어도 됨. **중요한 결론**: 위 "FrameModifier 타입" 문제(클래스별로 flat 타입을 생성기로 diff --git a/.claude/luau-test/07-relate-weak-table-gc.luau b/.claude/luau-test/07-relate-weak-table-gc.luau index 71960f3..f9d3d73 100644 --- a/.claude/luau-test/07-relate-weak-table-gc.luau +++ b/.claude/luau-test/07-relate-weak-table-gc.luau @@ -63,6 +63,17 @@ local function Relate() end t.WeakMap[key] = value end + -- [2026-08-13 여섯 번째 세션 추가] 테스트 전용 — outer는 __mode="k"라 + -- 죽은 키의 엔트리는 GC 후 pairs 순회에서 사라짐. 즉 "weak table 내부 + -- 엔트리 개수를 셀 방법이 없다"던 이 파일의 옛 결론은 틀렸음. + function relate._countEntries() + local n = 0 + for _ in pairs(outer) do + n += 1 + end + return n + end + function relate.GetWeak(_, inst, key) local t = outer[inst] if not t or not t.WeakMap then @@ -123,6 +134,55 @@ for i = 1, 10 do end end print("강하게 붙잡아둔 10개 중 살아있는 것:", aliveCount, "(10이어야 함)") +print("relate3의 살아있는 엔트리 총 개수:", relate3._countEntries(), "(10이어야 함 — 죽은 90개가 실제로 걷혔는지)") + +print() +print("=== 4. 핵심 주장 직접 검증 — inst가 죽으면 *중첩된 서브테이블 안의 값*까지 같이 GC되는가 ===") +--[[ + 이게 `base/bind-system-plan.md` "왜 GC-안전한가"와 `base/relate-plan.md` + 전체가 기대고 있는 바로 그 주장. 3번은 "바깥 키 엔트리가 걷히는가"까지만 + 보는데, 실제로 중요한 건 그 안에 StrongMap으로 담아둔 **payload(gcconn, + 실행 중인 Tween, mounted Slot 등)까지 연쇄적으로 풀리는가**임. + + 기법: payload를 weak-value canary 레지스트리에도 같이 등록해두고, inst + 참조만 끊은 뒤 GC. canary 엔트리가 사라졌으면 = payload가 실제로 + 수거됨 = 연쇄 GC 확인. +]] +local relate4 = Relate() +local canary = setmetatable({}, { __mode = "v" }) -- payload를 약하게만 참조 + +do + local liveInsts = {} + for i = 1, 50 do + local inst = {} + local payload = { tag = "payload" .. i } -- StrongMap에 담길 실제 자원 흉내 + relate4:SetStrong(inst, "gchold", payload) + canary[i] = payload + if i <= 5 then + liveInsts[i] = inst -- 앞 5개만 계속 살림 + end + end + collectgarbage() + collectgarbage() + + local canaryAlive = 0 + for _ in pairs(canary) do + canaryAlive += 1 + end + print("inst 5개만 살린 상태에서 살아남은 payload 수:", canaryAlive, "(5여야 함 — 45개는 연쇄 GC돼야)") + print("relate4의 살아있는 엔트리 총 개수:", relate4._countEntries(), "(5여야 함)") + print("살아있는 inst로 payload 재조회 가능?", relate4:GetStrong(liveInsts[1], "gchold") ~= nil, "(true여야 함)") +end + +-- liveInsts까지 스코프를 벗어난 뒤 다시 확인 +collectgarbage() +collectgarbage() +local canaryAlive2 = 0 +for _ in pairs(canary) do + canaryAlive2 += 1 +end +print("모든 inst 참조를 놓은 뒤 살아남은 payload 수:", canaryAlive2, "(0이어야 함 — 전부 연쇄 GC)") +print("relate4의 살아있는 엔트리 총 개수:", relate4._countEntries(), "(0이어야 함)") print() print('=== 참고: collectgarbage("count") 메모리 변화(대략적 신호일 뿐) ===') @@ -134,12 +194,16 @@ print(collectgarbage("count"), "KB") 순간에만 생기는가(lazy 생성 실측). 2. 3번 섹션 — collectgarbage()가 실제로 동작하고(에러 안 나고), 강하게 붙잡아둔 10개는 살아있는가(당연히 그래야 함 — sanity check). - 3. **가장 중요한 미해결 관찰**: 이 스크립트는 "죽은 90개가 실제로 - GC됐는지"를 직접 카운트하지 못함(Luau가 weak table 내부 엔트리 - 개수를 세는 표준 API를 안 줌) — `collectgarbage("count")`로 전체 - 메모리 사용량 변화를 보는 정도가 간접 확인의 최선. 필요하면 위 - 3번 섹션의 루프를 더 크게(예: 100 -> 1,000,000) 돌리면서 루프 - 전후 collectgarbage("count") 차이를 비교해보면 신호가 더 뚜렷해질 - 수 있음(주의: GC는 정확한 타이밍을 보장 안 하므로 완벽한 증거는 - 아님, 참고 신호 정도로만 볼 것). + 3. **[2026-08-13 여섯 번째 세션 정정·보강] "죽은 것이 실제로 GC됐는지 + 직접 카운트할 방법이 없다"던 옛 결론은 틀렸음.** outer 테이블이 + `__mode = "k"`이므로 GC 후 죽은 키의 엔트리는 `pairs` 순회에서 + 그냥 사라짐 — 그래서 `relate._countEntries()`로 살아있는 엔트리 + 수를 직접 셀 수 있고, payload 쪽은 weak-value canary 레지스트리로 + 같이 세면 됨. 3번(엔트리 카운트)과 4번(연쇄 GC) 섹션이 그 방식. + 4. **4번 섹션이 이 파일의 진짜 핵심** — `base/bind-system-plan.md` + "왜 GC-안전한가"와 `base/relate-plan.md` 전체가 기대고 있는 + "inst가 죽으면 그 안에 StrongMap으로 매달아둔 자원(gcconn/Tween/ + mounted Slot 등)까지 연쇄적으로 풀린다"는 주장을 직접 확인함. + 여기서 살아남은 payload 수가 기대치보다 크면 **GC-native 아키텍처 + 전체의 전제가 흔들리는 것**이므로 최우선으로 파고들 것. ]] diff --git a/.claude/luau-test/11-modifier-illegal-value-error.luau b/.claude/luau-test/11-modifier-illegal-value-error.luau index 29a8d31..a0a608c 100644 --- a/.claude/luau-test/11-modifier-illegal-value-error.luau +++ b/.claude/luau-test/11-modifier-illegal-value-error.luau @@ -34,7 +34,16 @@ local function brandOf(v) return nil end local mt = getmetatable(v) - return mt and mt.__index and mt.__index.__brand + if not mt then + return nil + end + -- Modifier(아래)의 __index는 제네릭 setter를 즉석 생성하는 함수라 + -- 태그 흉내용 { __index = { __brand = name } } 테이블 모양과 다름 — + -- 함수를 테이블처럼 인덱싱하면 크래시하므로 방어(브랜드 없음으로 취급). + if type(mt.__index) ~= "table" then + return nil + end + return mt.__index.__brand end local makeRef = tag("Ref") diff --git a/.claude/luau-test/17-modifier-index-tableclone-chaining.luau b/.claude/luau-test/17-modifier-index-tableclone-chaining.luau index 2a79c71..5faff2b 100644 --- a/.claude/luau-test/17-modifier-index-tableclone-chaining.luau +++ b/.claude/luau-test/17-modifier-index-tableclone-chaining.luau @@ -17,16 +17,49 @@ 원본과 물리적으로 동일 객체인지(참조 공유 확인), (C) 원본이 각 체이닝 단계에서 전혀 mutate 안 됐는지(immutable 보장), (D) 서로 다른 체이닝 경로(형제 분기)가 서로 오염 안 시키는지가 핵심. + + [2026-08-13 수정] 1차 작성본은 필드 값을 self 최상위 테이블에 리터럴 + 키(`clone.FontSize = 14`)로 직접 저장했는데, 이러면 Lua/Luau 의미상 + `__index` 메타메소드는 rawget이 실패할 때만 불림 — 한 번 `FontSize`가 + 설정된 뒤 같은 필드를 `mod:FontSize(fn)`으로 다시 호출하면 + `mod.FontSize`가 __index를 안 거치고 저장된 raw 값(숫자)을 그대로 + 돌려줘서 `(그 숫자)(mod, fn)`로 콜 시도 -> "attempt to call a number + value" 크래시(설계가 명시적으로 요구하는 "이미 값이 있는 필드를 변환 + 함수로 다시 감싸는" 용례, modifier-plan.md 4번 절의 "이전 값을 바탕으로 + 계산" + 3번 절 "상위 TextStyle을 상속해 타이틀만 1.2배 키우는" 예시가 + 정확히 이 패턴). base가 "그러니 __index가 **어떤 key가 오든**"이라고 + 쓴 건 바로 이 재호출 케이스까지 포함한다는 뜻 — 즉 필드 값은 self의 + 리터럴 키가 아니라 **항상 __index를 거치도록 별도 프라이빗 저장소**에 + 둬야 함(literal key로 직접 저장하는 건 스파이크 코드의 버그였지 설계의 + 결함이 아님, "Getter는 만들지 않기로 확정"이라 dot-access로 raw 값을 + 직접 읽는 것도 애초에 계약에 없었음 — 아래 `fieldOf` 헬퍼로만 검증용 + 읽기를 함). 이 프라이빗 저장소 자체도 `table.clone`이 얕은 복사라 + 원본과 같은 내부 테이블을 그대로 참조하게 되므로, setter가 내부 + 필드 테이블도 별도로 `table.clone`해야 원본이 안 섞임 — 아래 구현 참고. ]] +-- 필드 저장소 전용 프라이빗 키(유일 테이블 identity) — 사용자 필드 이름과 +-- 절대 충돌 안 함, 11번 스파이크의 ModifierBrand와 같은 패턴. +local FieldsKey = {} + local function genericIndex(_, key) return function(selfArg, arg) - local clone = table.clone(selfArg) + local oldFields = selfArg[FieldsKey] + -- 내부 필드 테이블도 얕은 복사 -- 안 하면 clone과 원본이 같은 + -- 내부 테이블을 계속 공유해서 새 필드를 쓰는 순간 원본도 오염됨. + local newFields = table.clone(oldFields) + + local old = oldFields[key] + local value if type(arg) == "function" then - clone[key] = arg(selfArg[key]) + value = arg(old) else - clone[key] = arg + value = arg end + newFields[key] = value + + local clone = table.clone(selfArg) + clone[FieldsKey] = newFields return clone end end @@ -34,10 +67,18 @@ end local ModifierMT = { __index = genericIndex } local function Modifier() - return setmetatable({}, ModifierMT) + return setmetatable({ [FieldsKey] = {} }, ModifierMT) +end + +-- 검증 전용 헬퍼(Modifier의 공개 API 아님 -- 설계상 dot-access getter는 +-- 없음, "Getter는 만들지 않기로 확정" 문서 그대로). +local function fieldOf(mod, key) + return mod[FieldsKey][key] end -- (A) 필드가 하나도 미리 등록 안 돼 있어도 임의 메소드 이름으로 체이닝되는지 +-- + 이미 값이 채워진 필드를 다시 변환 함수로 감싸도(mod2:FontSize(fn)) +-- __index가 여전히 잡아주는지(핵심 재현 시나리오) local mod0 = Modifier() local mod1 = mod0:FontSize(14) local mod2 = mod1:Round(8) @@ -45,9 +86,10 @@ local mod3 = mod2:FontSize(function(old) return old * 1.2 end) -assert(mod1.FontSize == 14, "mod1.FontSize expected 14") -assert(mod2.FontSize == 14 and mod2.Round == 8, "mod2 expected FontSize=14, Round=8") -assert(mod3.FontSize == 14 * 1.2, "mod3 expected FontSize=16.8 (14*1.2)") +assert(fieldOf(mod1, "FontSize") == 14, "mod1.FontSize expected 14") +assert(fieldOf(mod2, "FontSize") == 14 and fieldOf(mod2, "Round") == 8, "mod2 expected FontSize=14, Round=8") +assert(fieldOf(mod3, "FontSize") == 14 * 1.2, "mod3 expected FontSize=16.8 (14*1.2)") +assert(fieldOf(mod3, "Round") == 8, "mod3 must keep Round=8 unchanged by the FontSize re-chain") -- (B) getmetatable이 매 clone마다 원본과 physically 동일 객체인지 -- (table.clone이 메타테이블을 복사가 아니라 참조로 공유한다는 주장의 핵심 검증) @@ -57,14 +99,25 @@ assert(getmetatable(mod2) == getmetatable(mod0), "mod2 metatable should be same assert(getmetatable(mod3) == getmetatable(mod0), "mod3 metatable should be same reference as mod0's") -- (C) 원본은 절대 mutate 안 됨(immutable 체이닝 보장) -assert(mod0.FontSize == nil, "mod0 must stay untouched (FontSize)") -assert(mod0.Round == nil, "mod0 must stay untouched (Round)") -assert(mod1.Round == nil, "mod1 must stay untouched (Round) -- clone은 앞 단계까지만 반영") +assert(fieldOf(mod0, "FontSize") == nil, "mod0 must stay untouched (FontSize)") +assert(fieldOf(mod0, "Round") == nil, "mod0 must stay untouched (Round)") +assert(fieldOf(mod1, "Round") == nil, "mod1 must stay untouched (Round) -- clone은 앞 단계까지만 반영") +assert(fieldOf(mod2, "FontSize") == 14, "mod2 must keep pre-rechain FontSize=14 untouched by mod3's re-chain") -- (D) 서로 다른 체이닝 경로(형제 분기)가 서로 오염 안 시키는지 local branchA = mod1:Round(4) local branchB = mod1:Round(100) -assert(branchA.Round == 4 and branchB.Round == 100, "sibling branches must not cross-contaminate") -assert(mod1.Round == nil, "mod1 itself must stay untouched by either branch") +assert(fieldOf(branchA, "Round") == 4 and fieldOf(branchB, "Round") == 100, "sibling branches must not cross-contaminate") +assert(fieldOf(mod1, "Round") == nil, "mod1 itself must stay untouched by either branch") + +-- (E) 이미 값이 있는 필드를 같은 분기에서 세 번, 네 번 계속 재호출해도 +-- 매번 __index가 잡아주는지(단발성 재현이 아니라 임의 깊이에서 안정적인지) +local deep = mod1 +for i = 1, 5 do + deep = deep:Round(function(old) + return (old or 0) + i + end) +end +assert(fieldOf(deep, "Round") == 1 + 2 + 3 + 4 + 5, "repeated re-chaining on the same field must accumulate correctly") print("17-modifier-index-tableclone-chaining: all checks passed") diff --git a/.claude/luau-test/19-ownership-refcount-relate-patterns.luau b/.claude/luau-test/19-ownership-refcount-relate-patterns.luau index ddb3fd3..c40cff2 100644 --- a/.claude/luau-test/19-ownership-refcount-relate-patterns.luau +++ b/.claude/luau-test/19-ownership-refcount-relate-patterns.luau @@ -1,28 +1,4 @@ --[[ - *** [2026-08-13 감사] B/C 섹션은 폐기·변경된 설계를 검증 중 — 돌리기 전에 - *** 아래대로 먼저 고칠 것. A 섹션은 그대로 유효(단 `kTagMap`은 이제 없음, - *** 클로저가 `v`를 직접 캡처하므로 `tagNameMap` 하나만 남음 — 참조 카운트 - *** 로직 자체는 안 바뀌어서 A의 검증 내용은 그대로 성립). - *** - *** B) `rawNew`+`owners` 수동 레지스트리는 2026-08-13 하루 안에 두 번 - *** 뒤집혀 완전히 사라짐. 지금 설계는 그룹이 **공개 `AttributeKey(name)`** - *** 으로 항상 인덱스 1에 `Dispatch.process`만 부르고(retractFrom은 - *** 부르지 않음 — 부르면 점유 체크가 무력화됨), 철거는 반환 클로저가 - *** 자기가 등록한 이름 전부에 대해 수행. 소유권 충돌은 별도 레지스트리가 - *** 아니라 **`Dispatch.process`의 인덱스 점유 체크**가 잡음. - *** → B는 "그룹 A가 점유한 이름을 그룹 B가 잡으면 error가 나는가"를 - *** 인덱스 점유 모델로 다시 써야 함(`base/attribute-plan.md` - *** "이름 소유권"/"메커니즘" 절). - *** - *** C) `claimOwner`가 "같은 owner면 no-op(false)"이던 3분기가 감사에서 - *** 버그로 판정돼 둘로 쪼개짐 — nested(`rawAdd`)는 **엄격**(같은 owner - *** 재클레임도 error, `Slot{a,a}`를 막기 위함), top-level만 - *** `claimOwnerAt(element, inst, k)`으로 **위치까지** 봐서 spurious - *** 재발행만 false. `destroySlotTree`/`rawRemove`의 `releaseOwner` - *** 호출도 새로 추가됨. - *** → C는 이 두 함수를 각각 검증하도록 다시 써야 함 - *** (`base/slot-plan.md` "요소 소유권 — `elementOwner`" 절). - 검증 대상: "retract는 항상 불림" 전면 정정(2026-08-12 열한 번째 세션) 이후 새로 생긴 세 가지 소유권/참조카운트 추적 로직이 실제로 짜인 대로 동작하는지 — 전부 여러 위치/여러 사이클에 걸쳐 상태가 정확히 갱신되는지가 핵심이라 @@ -31,26 +7,53 @@ `recompute`의 off-by-one). 셋 다 Roblox 엔진/GC 타이밍과 무관한 순수 Luau 테이블 로직이지만, 03/04/11번 스파이크가 커버하는 것과는 다른 새 알고리즘 모양(여러 위치가 하나의 이름/자리를 공유하는 참조 카운트, 캐싱된 키 객체의 - 사이클 간 재사용, "같은 owner면 무시/다른 owner면 error" 3분기)이라 별도로 + 사이클 간 재사용, "같은 owner면 무시/다른 owner면 error" 분기)이라 별도로 실측이 필요하다고 판단해 신규 작성함. + [2026-08-13 재작성] B/C 섹션은 원래 폐기된 설계(`rawNew`+`owners` 수동 + 레지스트리, "같은 owner면 no-op" 3분기 `claimOwner`)를 검증하고 있어서 + 현재 base 설계에 맞춰 다시 씀. A 섹션은 그대로 유효 — 단 `kTagMap`은 + 2026-08-13 다섯 번째 세션에 Handler 계약이 클로저 반환으로 바뀌며 + 삭제됐고, 클로저가 `v`(이 위치의 Tag)를 직접 캡처하므로 이제 + `tagNameMap`(이름 -> 홀더 Tag set) 하나만 남음 — 참조 카운트 로직 + 자체는 안 바뀌어서 A의 검증 내용/기댓값은 그대로 성립. + A) Tag 참조 카운트 — `base/tag-plan.md` "메커니즘 — TagHandler" 절의 - `kTagMap`/`tagNameMap`. 서로 다른 두 위치가 같은 이름을 겹쳐 가질 때 + `tagNameMap`. 서로 다른 두 위치가 같은 이름을 겹쳐 가질 때 (`Frame { Tag("a"), Tag("a","b") }`류) 한쪽이 이름을 잃어도 다른 쪽이 아직 쥐고 있으면 실제 `RemoveTag`가 안 불려야 함(웹 className 합집합 시맨틱). - B) Attribute 소유권 — `base/attribute-plan.md` "이름 소유권" 절의 `rawNew`+ - `owners` 레지스트리. 직접 리터럴 쓰기와 그룹 위임이 같은 이름을 동시에 - 관리하려 하면 즉시 error. 그룹의 "남아있는 이름"은 캐싱된 같은 키 객체를 - 여러 사이클에 걸쳐 재사용해야 함(매번 새 키를 만들면 owners 레지스트리가 - 자기 자신과 충돌하는 오탐이 남). + B) Attribute 이름 소유권 — `base/attribute-plan.md` "이름 소유권"/ + "메커니즘" 절의 최종(세 번째) 설계: 그룹이 이름마다 공개 + `AttributeKey(name)`(이름별 weak 캐시)으로 항상 인덱스 1에 + `Dispatch.process(inst, key, source, 1)`만 부르고(`retractFrom` + 선행 호출 금지 — 부르면 점유 체크가 무력화됨), 철거는 반환 클로저가 + 자기가 등록한 이름 전부에 대해 수행. 소유권 충돌 감지는 별도 + 레지스트리가 아니라 **`Dispatch.process` 자신의 인덱스 점유 체크**가 + 대신함 — 이 스파이크는 그 점유 체크 로직 자체를 흉내 내어 검증. - C) Slot 요소 소유권 — `base/slot-plan.md` "요소 소유권 — `elementOwner`" 절의 - `claimOwner`/`releaseOwner`. 같은 element가 이미 다른 곳에 마운트돼 - 있으면 error, 같은 owner가 다시 클레임하면 조용한 no-op(재귀 재emit - 대응), 해제 후 다른 곳에 새로 붙는 건 정상 허용. top-level(Dispatch)과 - nested(`Add`) 경로가 **같은** 레지스트리를 공유해야 서로의 마운트를 잡음. + **[중요 캐비엇]** `research/dispatch-redispatch-diff-plan.md` 5절이 + 지적하듯, 앞으로 도입될 "하강 diff" 재디스패치 모델(`Dispatch.process`가 + 핸들러를 먼저 비교해 같으면 재사용)에서는 그룹 A/그룹 B가 둘 다 + `StoreBind`로 보여 "같은 핸들러"로 판정되므로 **이 점유 체크 하나만으로는 + 그룹↔그룹 충돌을 못 잡는다**는 게 이미 확인돼 있음(`.claude/question.md` + 0-Z, 최우선 미결 항목 — "이전 결정인 이름별 claimant `Relate`를 다시 + 가져오는 방향"으로 사용자가 기울었으나 심층 분석은 다음 세션으로 이관). + 즉 아래 B 섹션이 검증하는 건 **현재 확정된 모델**(점유 체크가 충돌을 + 잡음, 그룹↔직접 쓰기 케이스는 이 모델에서도 계속 유효)이고, 0-Z가 + 결정되면(특히 그룹↔그룹 케이스) 이 섹션도 다시 손봐야 함. + + C) Slot 요소 소유권 — `base/slot-plan.md` "요소 소유권 — `elementOwner`" + 절의 `claimOwner`(nested `rawAdd` 전용, 엄격 — 같은 owner 재클레임도 + error)와 `claimOwnerAt`(top-level `SlotHandler` 전용 — `(inst,k)`까지 + 봐서 정확히 같은 자리의 spurious 재발행만 `false`, 그 외 중복은 전부 + error) 두 함수로 쪼개진 최종 설계. 원래 하나의 `claimOwner`가 "같은 + owner면 no-op(false)"이라는 3분기를 양쪽에 공유했던 게 감사에서 버그로 + 판정됨(`Frame { slot, slot }`에서 top-level이 `k=2`를 조용히 no-op + 처리하고도 파괴적 클로저를 반환해 이중 파괴로 이어짐, `local a=Slot{}; + Slot{a,a}`에서 nested가 반환값을 안 봐서 이중 클레임을 그냥 통과시킴) — + 아래 C 섹션은 두 함수를 각각 검증. 실행: `luau 19-ownership-refcount-relate-patterns.luau` ]] @@ -82,10 +85,23 @@ local allPass = true -- ===== A. Tag 참조 카운트 — 여러 위치가 같은 이름을 겹쳐 가짐 ===== -print("=== A. Tag kTagMap/tagNameMap — 참조 카운트 ===") +print("=== A. Tag tagNameMap — 참조 카운트 ===") do - local kTagMap = makeRegistry() -- (inst,k) -> Tag(흉내: {names={...}}) + -- [2026-08-13 재작성 시 확인] 옛 `kTagMap`(위치별 마지막 Tag를 별도 + -- 저장하던 맵)은 실제 base 설계에선 없음 — 2026-08-13 다섯 번째 세션에 + -- `process`가 자기 retract 클로저를 반환하는 계약으로 바뀌며, "이 + -- (inst,k) 자리에 지금 뭐가 있었나"는 클로저가 `v`를 upvalue로 직접 + -- 캡처해서 알므로 별도 저장소가 불필요해짐(`tag-plan.md` "메커니즘" 절). + -- `tagNameMap`(이름 -> 그 이름을 걸고 있는 현재 홀더 집합)만 남음 — 이건 + -- 여러 위치를 가로지르는 누적 상태라 여전히 필요. + -- + -- 아래 TagRetract/TagProcess는 실제 base 코드의 함수 분리 방식(하나의 + -- process가 반환하는 클로저)과는 다르게 두 개의 독립 함수로 남아있음 — + -- 이건 원래 스파이크 작성자가 검증 편의를 위해 고른 형태고, 이 세션의 + -- 재작성 범위(B/C만)에 포함되지 않아 그대로 유지함. 참조 카운트 로직 + -- 자체(홀더 집합 갱신 + `Contains` 힌트로 실제 RemoveTag만 skip)는 + -- 실제 base 설계와 동등함. local tagNameMap = makeRegistry() -- (inst,name) -> {[Tag]=true} local removeLog = {} local addLog = {} @@ -112,8 +128,11 @@ do return tag.__names[name] == true end - local function TagRetract(inst, k, newv) - local oldv = kTagMap:get(inst, k) + -- kTagMap 대체: 이 스파이크 안에서만 쓰는 "이 위치가 직전에 걸었던 Tag" + -- 로컬 기억 — 실제 base에선 process가 반환하는 클로저의 upvalue가 이 + -- 역할을 함(위 주석 참고). 여기선 TagRetract/TagProcess가 분리돼 있어 + -- 호출부가 직접 넘겨줌. + local function TagRetract(inst, k, oldv, newv) if not oldv then return end @@ -137,15 +156,14 @@ do holders[v] = true tagNameMap:set(inst, name, holders) end - kTagMap:set(inst, k, v) end -- Frame { Tag("a"), Tag("a", "b") } — 두 위치(k=1, k=2)가 "a"를 겹쳐 가짐 local tagPos1 = makeTag("a") local tagPos2 = makeTag("a", "b") - TagRetract("Frame1", 1, tagPos1) + TagRetract("Frame1", 1, nil, tagPos1) TagProcess("Frame1", 1, tagPos1) - TagRetract("Frame1", 2, tagPos2) + TagRetract("Frame1", 2, nil, tagPos2) TagProcess("Frame1", 2, tagPos2) -- Frame{Tag("a"), Tag("a","b")} 마운트 직후: "a"는 두 위치가 겹쳐 가지므로 @@ -155,131 +173,308 @@ do -- 위치1이 "a"를 잃음(Tag()로 교체, 빈 값) — 위치2가 아직 "a"를 쥐고 있으므로 -- 실제 RemoveTag("a")는 안 불려야 함 local emptyTag = makeTag() - TagRetract("Frame1", 1, emptyTag) + TagRetract("Frame1", 1, tagPos1, emptyTag) TagProcess("Frame1", 1, emptyTag) allPass = report("위치1이 a를 잃어도 위치2가 쥐고 있어 RemoveTag 안 불림", #removeLog == 0, "0", #removeLog) and allPass -- 위치2도 "a"를 잃음 — 이제 진짜로 RemoveTag("a")가 불려야 함(b는 그대로 유지) local tagPos2b = makeTag("b") - TagRetract("Frame1", 2, tagPos2b) + TagRetract("Frame1", 2, tagPos2, tagPos2b) TagProcess("Frame1", 2, tagPos2b) allPass = report("마지막 홀더도 a를 잃으면 RemoveTag(a) 실제로 불림", #removeLog == 1 and removeLog[1] == "a", "{a}", table.concat(removeLog, ",")) and allPass end --- ===== B. Attribute 소유권 — rawNew 전용 키 + owners 레지스트리 ===== +-- ===== B. Attribute 이름 소유권 — Dispatch 인덱스 점유 체크로 판정 ===== print() -print("=== B. Attribute owners 레지스트리 — 소유권 충돌 + 캐시 재사용 ===") +print("=== B. Attribute — Dispatch.process 점유 체크가 이름 소유권 충돌을 잡는가 ===") do - local owners = makeRegistry() -- (inst,name) -> keyObject - local function rawNew(name) - return { __name = name } -- identity가 의미있는 새 키 객체 + -- Dispatch.process/retractFrom을 아주 얇게 흉내냄 — (inst,key) 자리별로 + -- 인덱스별 점유자(occupant)를 기록. 실제 Dispatch는 핸들러 매치/재귀도 + -- 하지만 여기선 "인덱스 1이 이미 점유돼 있으면 error"라는 핵심 계약만 + -- 재현하면 충분(attribute-plan.md "메커니즘" 절이 의존하는 게 정확히 이것). + local occupant = makeRegistry() -- (inst,key) -> claimant(임의의 identity) + + local function DispatchProcess(inst, key, claimant, index) + assert(index == 1, "이 스파이크는 인덱스 1 직접 위임 케이스만 다룸") + local current = occupant:get(inst, key) + if current ~= nil and current ~= claimant then + error(string.format('attribute key "%s"는 이미 다른 claimant가 점유 중', tostring(key.__name))) + end + occupant:set(inst, key, claimant) end - local function AttributeKeyProcess(inst, k, v) - local name = k.__name - local map = owners:get(inst, "map") or {} - local current = map[name] - if current ~= nil and current ~= k then - error(string.format('attribute "%s"는 이미 다른 AttributeKey가 관리 중', name)) + local function DispatchRetractFrom(inst, key, claimant, index) + assert(index == 1) + if occupant:get(inst, key) == claimant then + occupant:set(inst, key, nil) end - if v == nil then - map[name] = nil - else - map[name] = k - end - owners:set(inst, "map", map) end - local directKey = rawNew("Enabled") -- 직접 리터럴 쓰기 흉내(공개 캐시 대신 여기선 그냥 키 하나) - AttributeKeyProcess("Frame1", directKey, true) + -- AttributeKey(name) 이름별 weak 캐시 흉내 — 실제로는 GC weak지만 여기선 + -- 순수 로직만 보므로 strong 캐시로 충분(동등성 보장이 핵심) + local keyCache = {} + local function AttributeKey(name) + local k = keyCache[name] + if not k then + k = { __name = name } + keyCache[name] = k + end + return k + end + -- 직접 리터럴 쓰기: [AttributeKey "Enabled"] = true, claimant는 이 자리 + -- 자체(Modifier 필드 identity 대신 편의상 문자열로 대체) + local directClaimant = "directWrite" + DispatchProcess("Frame1", AttributeKey("Enabled"), directClaimant, 1) + + -- 그룹이 rawNew 대신 공개 캐시로 같은 이름을 잡으려 하면 -> 점유 체크가 error + local groupClaimant = "group1" local ok1 = pcall(function() - local groupKey = rawNew("Enabled") -- 그룹이 rawNew로 만든 별개 키 객체(이름은 같음) - AttributeKeyProcess("Frame1", groupKey, false) + DispatchProcess("Frame1", AttributeKey("Enabled"), groupClaimant, 1) end) - allPass = report("직접 쓰기 + 그룹이 같은 이름을 동시에 관리 -> error", ok1 == false, false, ok1) and allPass + allPass = report("직접 쓰기가 점유한 이름을 그룹이 잡으려 하면 -> error", ok1 == false, false, ok1) and allPass - -- 그룹 자신의 diff 사이클 — 같은 이름이 여러 사이클에 걸쳐 살아남으면 - -- 캐싱된 같은 키 객체를 재사용해야 함(안 그러면 owners가 자기 자신과 충돌) - local groupCache = {} -- {[name] = keyObject} — attribute-plan.md의 "이름 -> 그 이름 전용 키" 맵 - local function groupCycle(names) + -- 그룹 자신의 재귀 diff 사이클 — AttributeGroupHandler.process/retractor 흉내. + -- process: 새 이름 집합 전부 인덱스 1로 위임. 반환 클로저: 자기가 등록한 + -- 이름 전부 retractFrom(공개 캐시라 매번 같은 키 객체 재사용, 별도 + -- 레지스트리 불필요 — attribute-plan.md "메커니즘" 절 그대로). + local function AttributeGroupProcess(inst, groupClaimant, names) + local registered = {} for _, name in names do - local key = groupCache[name] or rawNew(name) - groupCache[name] = key - AttributeKeyProcess("Frame2", key, true) -- 실제로는 Dispatch.retractUnder+process 페어, 여기선 process만 흉내 + DispatchProcess(inst, AttributeKey(name), groupClaimant, 1) + table.insert(registered, name) + end + return function() + for _, name in registered do + DispatchRetractFrom(inst, AttributeKey(name), groupClaimant, 1) + end end end - groupCycle({ "Health", "Mana" }) - local firstHealthKey = groupCache["Health"] - groupCycle({ "Health", "Mana" }) -- 2번째 사이클, 같은 이름들 살아남음 + local retractGroup = AttributeGroupProcess("Frame2", "groupA", { "Health", "Mana" }) + local firstHealthKey = AttributeKey("Health") -- 캐시된 같은 객체인지 재확인용 - local ok2 = groupCache["Health"] == firstHealthKey - allPass = report("남아있는 이름은 사이클 간 같은 키 객체 재사용(재생성 아님)", ok2, true, ok2) and allPass + -- 2번째 사이클: 옛 클로저가 먼저 걷어내고(하강 diff 이전 모델의 관용구대로 + -- retract-then-process), 새 process가 같은 이름들을 다시 등록 — 캐시된 + -- 같은 키 객체를 재사용해야 자기 자신과 충돌하지 않음 + retractGroup() + retractGroup = AttributeGroupProcess("Frame2", "groupA", { "Health", "Mana" }) - local ok3, err3 = pcall(function() - -- 캐시를 무시하고 매번 새 키를 만들면(버그 시뮬레이션) 자기 자신과도 충돌해야 정상 - local staleKey = rawNew("Health") - AttributeKeyProcess("Frame2", staleKey, true) + local ok2 = AttributeKey("Health") == firstHealthKey + allPass = report("이름 캐시는 사이클 간 같은 키 객체 재사용(공개 AttributeKey 캐시)", ok2, true, ok2) and allPass + + -- 같은 그룹이 "Health"를 잃었다가(2번째 사이클에서 Mana만 남김) 다시 + -- 포함하는 경우 -> 자기 자신과 충돌하면 안 됨(자기가 걷어낸 자리를 + -- 자기가 다시 잡는 것이므로) — 0-Z 이전, 현재 모델에서 정상 통과해야 함 + retractGroup() + retractGroup = AttributeGroupProcess("Frame2", "groupA", { "Mana" }) -- Health를 놓음 + local ok3 = pcall(function() + retractGroup() + retractGroup = AttributeGroupProcess("Frame2", "groupA", { "Health", "Mana" }) -- Health를 다시 포함 end) - allPass = report("캐시 우회하고 새 키로 같은 이름 쓰면 자기 자신과도 충돌(캐시 재사용이 왜 필수인지 반증)", ok3 == false, false, ok3) and allPass + allPass = report("같은 그룹이 이름을 놓았다 다시 포함해도 자기 자신과 안 충돌", ok3 == true, true, ok3) and allPass + + -- 음성 대조군: 두 개의 서로 다른 그룹이 같은 이름을 동시에 관리하려 하면 + -- (retractFrom 없이 process만 먼저 부르는 순서로) 반드시 점유 error가 + -- 나야 함 — 이게 "체크포인트/owners 레지스트리 없이 점유 체크 하나로 + -- 충돌을 잡는다"는 이 설계의 핵심 주장 그 자체 + local groupBClaimant = "groupB" + local ok4 = pcall(function() + DispatchProcess("Frame2", AttributeKey("Mana"), groupBClaimant, 1) -- groupA가 아직 Mana를 쥔 채 + end) + allPass = report("[핵심] 그룹A가 점유 중인 이름을 그룹B가 잡으려 하면 -> error(점유 체크가 충돌을 잡음)", ok4 == false, false, ok4) and allPass + + -- 음성 대조군 2: 점유 체크를 흉내내지 않고(=폐기된 옛 모델처럼 아무 + -- 체크 없이 그냥 덮어쓰면) 위 케이스가 조용히 통과해버림을 직접 보여줌 — + -- "이 스파이크가 실제로 뭔가를 검증하고 있다"는 반증 + do + local naiveOccupant = {} + local function naiveProcess(key, claimant) + naiveOccupant[key] = claimant -- 점유 체크 없이 무조건 덮어씀(구 last-write-wins) + end + local key = "Mana" + naiveProcess(key, "groupA") + naiveProcess(key, "groupB") -- 아무 에러도 안 남 — 이게 바로 이 문서가 고쳤다고 주장하는 버그 + allPass = report("(음성 대조군) 점유 체크 없는 naive 모델은 같은 상황에서 조용히 통과함(버그 재현)", naiveOccupant[key] == "groupB", "groupB", naiveOccupant[key]) and allPass + end end --- ===== C. Slot elementOwner — claimOwner/releaseOwner ===== +-- ===== C. Slot elementOwner — claimOwner(nested, 엄격) / claimOwnerAt(top-level) ===== print() -print("=== C. Slot elementOwner — 다중 마운트 금지 + 같은 owner는 no-op ===") +print("=== C. Slot elementOwner — claimOwner(엄격) vs claimOwnerAt(spurious 재발행 허용) ===") do - local elementOwner = makeRegistry() + local elementOwner = makeRegistry() -- element -> {OWNER=ownerKey, OWNER_POS=k} local OWNER = "__owner" + local OWNER_POS = "__ownerPos" + -- nested(rawAdd) 전용 — 엄격: 이미 누가 갖고 있으면 같은 owner여도 error local function claimOwner(element, ownerKey) + if elementOwner:get(element, OWNER) ~= nil then + error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지") + end + elementOwner:set(element, OWNER, ownerKey) + end + + -- top-level(SlotHandler) 전용 — 정확히 같은 (inst,k) 자리의 spurious + -- 재발행만 false(no-op), 그 외 중복은 전부 error. 반환값 = 새로 클레임했는가. + local function claimOwnerAt(element, inst, k) local current = elementOwner:get(element, OWNER) - if current == ownerKey then - return false -- 이미 같은 owner — no-op + if current == inst and elementOwner:get(element, OWNER_POS) == k then + return false -- 정확히 이 자리가 이미 들고 있음 — 재확인만 end if current ~= nil then error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지") end - elementOwner:set(element, OWNER, ownerKey) + elementOwner:set(element, OWNER, inst) + elementOwner:set(element, OWNER_POS, k) return true end local function releaseOwner(element, ownerKey) - if elementOwner:get(element, OWNER) == ownerKey then - elementOwner:set(element, OWNER, nil) + local current = elementOwner:get(element, OWNER) + if current ~= ownerKey then + error("releaseOwner: 이 element는 이 ownerKey가 소유하고 있지 않음 — 호출측 소유권 추적이 깨졌음") end + elementOwner:set(element, OWNER, nil) + elementOwner:set(element, OWNER_POS, nil) end - local slotA = { __name = "slotA" } - local frame1 = { __name = "Frame1" } - local frame2 = { __name = "Frame2" } - local outerSlot = { __name = "outerSlot" } + -- --- C-1. nested claimOwner: 같은 owner 재클레임도 error(Slot{a,a} 방지) --- - local claimed1 = claimOwner(slotA, frame1) -- top-level Dispatch 마운트 - allPass = report("최초 클레임은 true(실제로 붙음)", claimed1 == true, true, claimed1) and allPass + do + local a = { __name = "elementA" } + local outerSlot = { __name = "outerSlot" } - local claimed2 = claimOwner(slotA, frame1) -- 같은 owner가 재emit(재귀 재계산 등) - allPass = report("같은 owner 재클레임은 false(no-op, 재파괴/재생성 없음)", claimed2 == false, false, claimed2) and allPass + claimOwner(a, outerSlot) -- rawAdd(outerSlot, a) — 최초 클레임, 정상 - local ok4 = pcall(function() - claimOwner(slotA, outerSlot) -- nested Add가 같은 slotA를 다른 곳에 붙이려 함 - end) - allPass = report("top-level이 이미 소유 중인데 nested Add가 같은 element를 또 클레임 -> error", ok4 == false, false, ok4) and allPass + local ok1 = pcall(function() + claimOwner(a, outerSlot) -- 같은 outerSlot이 같은 a를 또 rawAdd(Slot{a,a} 시뮬레이션) + end) + allPass = report("C-1: nested Slot{a,a} — 같은 owner 재클레임도 -> error", ok1 == false, false, ok1) and allPass - releaseOwner(slotA, frame1) -- top-level에서 정상 해제(retract) - local claimed3 = claimOwner(slotA, outerSlot) -- 해제 후엔 다른 곳에 정상적으로 다시 붙을 수 있음 - allPass = report("release 이후엔 다른 owner가 정상 클레임 가능", claimed3 == true, true, claimed3) and allPass + -- 음성 대조군: 옛 3분기(같은 owner면 조용히 false)였다면 위 케이스가 + -- 통과해버려 outerSlot._elements = {a, a}가 조용히 만들어짐 + local function oldClaimOwner3way(element, ownerKey, registry) + local current = registry[element] + if current == ownerKey then + return false -- 옛 설계: no-op 취급, error 없음 + end + if current ~= nil then + error("다른 곳에 마운트됨") + end + registry[element] = ownerKey + return true + end + local oldRegistry = {} + local claimed1 = oldClaimOwner3way(a, outerSlot, oldRegistry) + local claimed2 = oldClaimOwner3way(a, outerSlot, oldRegistry) -- rawAdd가 반환값을 안 보므로 그냥 통과 + allPass = report("(음성 대조군) 옛 3분기 claimOwner였다면 Slot{a,a}가 에러 없이 통과함(claimed2=false, no error)", claimed1 == true and claimed2 == false, "true,false", tostring(claimed1) .. "," .. tostring(claimed2)) and allPass - local ok5 = pcall(function() - claimOwner(slotA, frame2) -- outerSlot이 아직 쥐고 있는데 frame2가 가로채려 함 - end) - allPass = report("release 안 된 상태에서 제3자가 가로채려 하면 -> error", ok5 == false, false, ok5) and allPass + releaseOwner(a, outerSlot) + end + + -- --- C-2. 서로 다른 Slot에 같은 element -> error --- + + do + local b = { __name = "elementB" } + local slot1 = { __name = "slot1" } + local slot2 = { __name = "slot2" } + + claimOwner(b, slot1) + local ok2 = pcall(function() + claimOwner(b, slot2) + end) + allPass = report("C-2: 서로 다른 nested Slot에 같은 element -> error", ok2 == false, false, ok2) and allPass + + releaseOwner(b, slot1) + end + + -- --- C-3. top-level claimOwnerAt: 같은 (inst,k) 자리에서 같은 slot 재발행은 no-op --- + + do + local slotA = { __name = "slotA" } + local frame1 = { __name = "Frame1" } + + local claimed1 = claimOwnerAt(slotA, frame1, 1) -- 최초 마운트 + allPass = report("C-3a: 최초 클레임은 true(실제로 붙음)", claimed1 == true, true, claimed1) and allPass + + local claimed2 = claimOwnerAt(slotA, frame1, 1) -- 같은 (inst,k)에서 store 재발행 + allPass = report("C-3b: 같은 (inst,k) 재발행은 false(no-op, 재파괴/재생성 없음)", claimed2 == false, false, claimed2) and allPass + + releaseOwner(slotA, frame1) + end + + -- --- C-4. top-level Frame{slot, slot}: 같은 inst, 다른 k -> error --- + + do + local slotB = { __name = "slotB" } + local frame2 = { __name = "Frame2" } + + claimOwnerAt(slotB, frame2, 1) -- k=1에 마운트 + + local ok3 = pcall(function() + claimOwnerAt(slotB, frame2, 2) -- 같은 inst, 다른 위치(k=2)에 같은 slotB + end) + allPass = report("C-4: [핵심] top-level Frame{slot,slot}(같은 inst, 다른 k) -> error", ok3 == false, false, ok3) and allPass + + -- 음성 대조군: owner만 보고 위치(k)를 안 보는 옛 claimOwner였다면 + -- current==inst라 조용히 false를 반환해 버림 -> 이후 파괴적 클로저가 + -- 두 자리 모두에서 반환되어 이중 파괴로 이어지는 게 실제 발견된 버그 + local function oldClaimOwnerNoPos(element, inst, registry) + local current = registry[element] + if current == inst then + return false -- 위치를 안 봐서 k=2도 그냥 no-op 취급 — 버그 + end + if current ~= nil then + error("다른 곳에 마운트됨") + end + registry[element] = inst + return true + end + local oldRegistry = {} + local oc1 = oldClaimOwnerNoPos(slotB, frame2, oldRegistry) + local oc2 = oldClaimOwnerNoPos(slotB, frame2, oldRegistry) -- k=2에서도 에러 없이 false — 버그 재현 + allPass = report("(음성 대조군) 위치를 안 보는 옛 claimOwner였다면 Frame{slot,slot}이 에러 없이 통과함", oc1 == true and oc2 == false, "true,false", tostring(oc1) .. "," .. tostring(oc2)) and allPass + + releaseOwner(slotB, frame2) + end + + -- --- C-5. release 후 재클레임은 정상 통과 --- + + do + local slotC = { __name = "slotC" } + local frame3 = { __name = "Frame3" } + local frame4 = { __name = "Frame4" } + + claimOwnerAt(slotC, frame3, 1) + releaseOwner(slotC, frame3) + local claimed = claimOwnerAt(slotC, frame4, 1) -- 해제 후 완전히 다른 곳에 재마운트 + allPass = report("C-5: release 이후엔 다른 owner가 정상 클레임 가능", claimed == true, true, claimed) and allPass + + releaseOwner(slotC, frame4) + end + + -- --- C-6. 불일치 release -> error --- + + do + local slotD = { __name = "slotD" } + local frame5 = { __name = "Frame5" } + local frame6 = { __name = "Frame6" } + + claimOwnerAt(slotD, frame5, 1) + local ok4 = pcall(function() + releaseOwner(slotD, frame6) -- frame5가 쥐고 있는데 frame6이 release 시도 + end) + allPass = report("C-6: 불일치 release -> error(호출측 소유권 추적 붕괴 즉시 감지)", ok4 == false, false, ok4) and allPass + + releaseOwner(slotD, frame5) -- 정리 + end end print() @@ -289,14 +484,16 @@ print(allPass and "=== 전체 PASS ===" or "=== 하나 이상 FAIL — 위 로 확인 포인트: 1. A: "위치1이 a를 잃어도 위치2가 쥐고 있어 RemoveTag 안 불림" — 이게 FAIL이면 웹 className류 합집합 시맨틱이 실제로 안 되는 것이므로 tag-plan.md의 - `kTagMap`/`tagNameMap` 알고리즘 자체를 재검토해야 함(최우선 보고). - 2. B: "캐시 우회하고 새 키로 같은 이름 쓰면 자기 자신과도 충돌" 케이스가 - FAIL(즉 에러가 안 남)이면 오히려 좋은 신호가 아니라, attribute-plan.md가 - "캐시가 그룹 값 교체를 넘어 영속돼야 한다"고 요구하는 이유 자체가 - 이 스파이크에서 재현이 안 된 것 — 이 경우 이 검증 케이스 설계를 - 재검토할 것(알고리즘이 아니라 테스트 설계 문제일 수 있음). - 3. C: 세 가지 분기(같은 owner=no-op, 다른 owner=error, release 후 재클레임= - 정상)가 전부 정확히 갈리는가 — 이 셋 중 하나라도 안 갈리면 Slot의 - "재귀 재emit마다 서브트리가 파괴됐다 재생성되는" 파괴적 버그 - (slot-plan.md 참고)로 직결되므로 FAIL이면 최우선 보고. + `tagNameMap` 알고리즘 자체를 재검토해야 함(최우선 보고). + 2. B: "[핵심] 그룹A가 점유 중인 이름을 그룹B가 잡으려 하면 -> error" 케이스가 + FAIL이면, `Dispatch.process`의 인덱스 점유 체크만으로 그룹↔그룹 이름 + 충돌을 잡는다는 attribute-plan.md의 현재 모델 자체가 성립 안 하는 것 — + 단, 이건 재디스패치 "하강 diff" 모델 도입 후에는 어차피 다시 뚫리는 + 케이스로 이미 알려져 있음(`.claude/question.md` 0-Z) — 그 결정이 나면 + 이 섹션 전체를 그 결정에 맞춰 다시 써야 함. + 3. C: C-1(nested 재클레임 error)/C-4(top-level 다른 위치 error)가 이 + 스파이크의 핵심 — 둘 다 "옛 3분기/위치 미검사 설계였다면 조용히 + 통과했을 것"을 음성 대조군으로 같이 보여줌. 이 중 하나라도 FAIL이면 + Slot의 "재귀 재emit마다 서브트리가 파괴됐다 재생성되는" 또는 "이중 + 파괴" 버그로 직결되므로 최우선 보고. ]] diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 53211c3..20cd196 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -167,6 +167,29 @@ error)를 추가하고, `04`의 3~4단계를 "가드가 실제로 걸리는지 + 정상 복구되는지" 확인으로 재작성. `operator-sugar-plan.md`엔 별개로 nil 대체 콤비네이터 `Alternative` 후보를 신설(카탈로그에 이전엔 없었음). +**11차 (2026-08-13, 여섯 번째 세션) — 처음으로 실제 실행함.** +`luau`/`luau-analyze` 바이너리가 사용 가능해져, 2026-08-09 열두 번째 세션에 +스파이크를 만들기 시작한 이래 **처음으로 돌려본 라운드**. 결과 전문은 +`.claude/audit/luau-test-first-run-2026-08-13.md`. 요지: + +- **런타임 8개 통과**(01/02/03/04/05/06/18/20) — 설계를 흔드는 결과 없음. +- **`04`가 이번 세션 감사에서 찾은 버그를 음성 대조군으로 재현** — 체인 + 깊이가 3 대신 1로 무너지고, 죽은 store가 나중에 UI를 덮어쓰는 것까지 + 실측됨. 감사→수정 사이클이 실측으로 닫힘. +- **`07`은 보강해야 실제 검증이 됐음** — 3번 섹션이 sanity check만 하고 + 있었고 헤더의 핵심 주장(연쇄 GC)은 미검증이었음. 파일이 스스로 적어둔 + "weak table 엔트리를 셀 방법이 없다"는 전제가 틀렸음(outer가 + `__mode="k"`라 GC 후 `pairs`에서 그냥 사라짐) — `_countEntries()` + + weak-value canary로 4번 섹션 신설, **GC-native 아키텍처의 핵심 전제가 + 실측 확인됨**. +- **`18`이 `relate-plan.md`의 상호 순환 경고를 실증** — 추측이 아니라 + 실제로 GC가 안 됨. `Slot`의 두-`Relate` 수정이 필수 조치였음이 입증. +- **`17` 크래시**(44행, 아무것도 검증 못 함), **`11`의 "다른 Modifier" + 케이스가 엉뚱한 이유로 통과** — 둘 다 스파이크 코드 결함, 수정 대상. +- 타입 스파이크(08/09/12/13/14/15/16)는 음성 대조군이 섞여 있어 헤더 의도와 + 대조해야 판정 가능 — 별도 진행. `15`는 `SyntaxError`로 파싱 자체가 + 안 되는 상태(= 아무것도 검증 못 함)인 것만 먼저 확정. + ## 결과 확인 후 할 일 각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면 diff --git a/.claude/question.md b/.claude/question.md index d65ea73..fbd1b23 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -10,6 +10,41 @@ ## 지금 열려있는 것 (우선순위순) +### 0-Y. ⭐ **최우선(신설) — `:Compute(fn)`의 lazy 핸들 계약을 유지할 것인가** (2026-08-13 여섯 번째 세션, 첫 실측에서 발견) + +**실측 결과**: 콜백이 lazy `State` 핸들을 받는 quad의 커링 계약이 +**Luau 양방향 타입 추론과 충돌**함. 가장 흔한 관용구가 그대로 실패: + +```lua +state:Compute(function(s) return s:Get() * 2 end) -- ❌ TypeError 2건 +``` + +최소 재현으로 원인을 좁힘(`audit/luau-test-first-run-2026-08-13.md`): + +| 형태 | 결과 | +|---|---| +| lazy 핸들 + 무주석 인라인 람다 | ❌ | +| 위 + `read Get` 선언 | ❌ | +| 위 + `Get: (self: State) -> T` | ❌ | +| 콜백 파라미터에 타입 주석 | ✅ | +| **콜백이 raw 값 `T`를 받으면** | ✅ **완전 클린** | + +즉 표기 조정으로는 안 풀리고, **계약 자체가 원인**. `:Compute`만이 +아니라 `Effect`/`Observer`/`Animate`/`Operator` 등 같은 계약을 공유하는 +API 전부에 걸리며, 2026-08-07 일곱 번째 세션(커링 스타일 확정)과 +2026-08-12 네 번째 세션(`:Get()` 누락 버그 전역 감사)이 전부 이 계약 +위에 서 있음. + +**선택지**: +1. **계약 유지 + 파라미터 주석 필수** — 설계 변경 없음, 대신 가장 흔한 + 자리에 매번 `function(s: State)`를 써야 하는 인체공학 손해. +2. **콜백이 raw 값을 받도록 전환** — 타입 추론은 완벽해지지만 lazy + 평가/trailing deps/`previous` 설계 전반과 충돌. **구조 변경 규모가 큼** + (사용자가 이번 라운드에서 미리 잡고 싶다고 한 바로 그 종류). +3. **혼합** — 기본은 raw, lazy가 실제로 필요한 자리만 별도 API. + +**M0 착수 전에 정하는 게 맞음** — 2번을 고르면 base 문서 다수가 영향받음. + ### 0-Z. ⭐ **최우선 — Attribute 이름 소유권을 무엇으로 판정할 것인가** (2026-08-13 여섯 번째 세션, 사용자가 다음 세션 심층 분석으로 이관) **이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의 diff --git a/CLAUDE.md b/CLAUDE.md index 55991f3..3700164 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,19 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 ## 지금 할 일 (우선순위순) -0. **⭐ 최우선 — `.claude/question.md` 0-Z(Attribute 이름 소유권) 결정.** +0. **⭐ 최우선 — `.claude/question.md` 0-Y / 0-Z 두 결정.** + + **0-Y(신설, 2026-08-13 첫 실측에서 발견): `:Compute(fn)`의 lazy 핸들 + 계약을 유지할 것인가.** 콜백이 lazy `State` 핸들을 받는 커링 계약이 + **Luau 양방향 추론과 충돌**해 가장 흔한 관용구 + (`state:Compute(function(s) return s:Get() * 2 end)`)가 타입 에러를 냄. + `read`/`self` 표기 조정으로는 안 풀리고, 콜백이 **raw 값을 받으면 완전 + 클린**(최소 재현으로 확인, `audit/luau-test-first-run-2026-08-13.md`). + `Effect`/`Observer`/`Animate`/`Operator` 등 같은 계약을 공유하는 API + 전부에 걸림 — 선택지는 (1) 계약 유지 + 파라미터 주석 필수, (2) raw 값 + 전환(**구조 변경 규모 큼**), (3) 혼합. **M0 착수 전에 정할 것.** + + **0-Z: Attribute 이름 소유권 결정.** 2026-08-13 여섯 번째 세션에 `Dispatch` 재디스패치 모델이 "하강 diff"로 다시 정리되면서(`research/dispatch-redispatch-diff-plan.md`), **그 모델에서 유일하게 안 풀린 것이 Attribute 그룹의 이름 소유권 충돌 @@ -916,3 +928,23 @@ Slot 소유권은 nested=엄격 `claimOwner`/top-level=`claimOwnerAt(inst,k)`로 `StoreBind`라 "같은 핸들러"로 판정돼 조용히 갈아탐. 사용자가 "이전 결정(claimant `Relate`)을 다시 가져오는 게 맞아 보이나 다음 세션에 직접 스케치하며 심층 분석"으로 이관 — `question.md` **0-Z(최우선)**. + +**이어진 첫 실측 라운드(같은 세션)** — `luau`/`luau-analyze` 바이너리가 +사용 가능해져 **스파이크를 만들기 시작한 2026-08-09 이래 처음으로 실제로 +돌림**(CLAUDE.md가 "M0 착수 전 남은 유일한 게이트"로 꼽아온 항목). 결과 +전문은 `audit/luau-test-first-run-2026-08-13.md`. +- **런타임 12개 전원 통과**(01~07/11/17~20). **`04`가 이번 세션 감사의 + 버그를 음성 대조군으로 재현** — 체인 깊이가 3 대신 1로 무너지고 죽은 + store가 나중에 UI를 덮어쓰는 것까지 실측돼 감사→수정 사이클이 닫힘. + **`07`은 보강해야 실제 검증이 됐음**(연쇄 GC 미검증 상태였고, 파일이 + 스스로 적어둔 "weak 엔트리를 셀 방법 없음" 전제가 틀렸음) — GC-native + 아키텍처의 핵심 전제가 이걸로 실측 확정. **`18`이 `Relate` 상호 순환 + 경고를 실증**(추측이 아니라 실제로 GC 안 됨). +- **타입 쪽에서 진짜 이슈 하나 발견 → `question.md` 0-Y 신설**(위 참고). +- `modifier-plan.md`의 "데이터를 테이블에 직접 두고"가 두 갈래로 읽히는 + **문서 결함**이 드러남 — self 최상위 리터럴 키에 두면 `__index`가 + `rawget` 성공 시 안 불려 같은 필드 재호출이 죽음(문서 3·4절 대표 용례가 + 바로 그 패턴). 경고 문단 추가 + `17` 재작성으로 해소. +- 스파이크 결함 수정: `17`(크래시), `11`(엉뚱한 이유로 통과), `19` B/C + (폐기 설계 검증 → 현행 설계로 재작성, 음성 대조군 포함), `07`(보강). + 남은 것은 `13`/`15`/`16`의 스파이크 격리·API 재확인(설계 문제 아님).