test(luau-test): 첫 실측 라운드 — 런타임 12개 전원 통과, 타입 이슈 1건 발견

luau/luau-analyze 바이너리가 사용 가능해져, 2026-08-09에 스파이크를 만들기
시작한 이래 처음으로 실제로 돌림(CLAUDE.md가 "M0 착수 전 남은 유일한
게이트"로 꼽아온 항목). 전문: .claude/audit/luau-test-first-run-2026-08-13.md

## 설계 검증 결과 — 런타임은 전부 성립

- 04가 직전 커밋의 감사에서 찾은 버그를 음성 대조군으로 재현: chains:SetStrong을
  process 뒤에 두면 체인 깊이가 3 대신 1로 무너지고, 죽은 store가 나중에 UI를
  덮어씀(STALE). 감사→수정 사이클이 실측으로 닫힘.
- 07은 보강해야 실제 검증이 됐음. 3번 섹션이 sanity check만 하고 있었고 헤더가
  내세운 연쇄 GC 주장은 미검증이었음. 파일이 스스로 적어둔 "weak table 엔트리를
  셀 표준 API가 없다"는 전제도 틀렸음(outer가 __mode="k"라 GC 후 pairs에서
  사라짐) — _countEntries + weak-value canary로 4번 섹션 신설, GC-native
  아키텍처의 핵심 전제(연쇄 GC)가 실측 확정됨.
- 18이 relate-plan.md의 상호 순환 경고를 실증 — 추측이 아니라 실제로 GC 안 됨.
- 01/02/03/05/06/20도 전부 통과.

## 타입 — 진짜 설계 이슈 1건 (question.md 0-Y 신설)

:Compute(fn)의 lazy 핸들 계약이 Luau 양방향 추론과 충돌.
`state:Compute(function(s) return s:Get() * 2 end)`가 타입 에러를 냄.
최소 재현으로 원인 확정: read/self 표기 조정으로는 안 풀리고, 콜백이 raw 값을
받으면 완전 클린. Effect/Observer/Animate/Operator 등 같은 계약을 공유하는
API 전부에 걸림 — M0 착수 전 결정 필요.

## 문서 결함 발견 — modifier-plan.md

"데이터를 테이블에 직접 두고"가 "self 최상위 리터럴 키"로 읽힐 여지가 있었는데,
그렇게 하면 __index가 rawget 성공 시 안 불려 같은 필드 재호출이 죽음. 그 재호출
패턴이 문서 3·4절의 대표 용례라 실사용에서 즉시 터지는 경로였음 — 경고 문단 추가.

## 스파이크 수정

- 17: self 최상위 키 저장 → 내부 저장소 구조로 재작성(크래시 해소)
- 11: 브랜드 판별 크래시로 "다른 Modifier" 케이스가 엉뚱하게 통과하던 것 수정
- 19: B/C를 폐기 설계(rawNew+owners, 3분기 claimOwner)에서 현행 설계로 재작성,
      음성 대조군으로 옛 로직이 Slot{a,a}/Frame{slot,slot}을 통과시킴을 재현
- 07: 위 참고

남은 것은 13/15/16의 스파이크 격리·API 재확인(설계 문제 아님).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
qwreey 2026-08-13 16:19:00 +09:00
parent 8c784288b0
commit 124b706e2c
Signed by: qwreey
GPG key ID: D28DB79297A214BD
9 changed files with 742 additions and 144 deletions

View file

@ -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<T>`를 `State<T>` 자리에 그대로 넘기기)은 클린 통과 — 구조적 서브타이핑 성립. 별개로 `State<T>`가 **자기 자신**을 다른 타입 인자로 재귀 참조하면 `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<T>``Ref<T>` 자리에 대입 가능 — 진단 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<T>`(lazy 핸들)를 받음, **무주석 인라인 람다** | ❌ 2건 |
| 위 + `Get``read`로 선언 | ❌ 여전 |
| 위 + `Get: (self: State<T>) -> T` 형태로 변경 | ❌ 여전 |
| 콜백 **파라미터에 타입 주석**을 달면 | ✅ 0건 |
| 콜백이 **raw 값 `T`** 를 받으면(`fn(v)`) | ✅ **0건** |
즉 **원인은 "콜백이 lazy `State<T>` 핸들을 받는다"는 quad의 커링 계약
그 자체**임. `read`/`self` 표기 조정으로는 안 풀리고, raw 값을 넘기면
완벽히 추론됨.
```lua
-- 지금 확정된 관용구 — 타입 추론 실패
state:Compute(function(s) return s:Get() * 2 end)
-- 우회책 1: 파라미터 주석(가장 흔한 자리에 매번 타입을 써야 함)
state:Compute(function(s: State<number>) 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 구현 중에 터졌을 건이라, 실측 라운드
자체의 값어치를 보여준 사례.

View file

@ -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 타입을 생성기로

View file

@ -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 아키텍처
전체의 전제가 흔들리는 것**이므로 최우선으로 파고들 것.
]]

View file

@ -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")

View file

@ -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")

View file

@ -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마다 서브트리가 파괴됐다 재생성되는" 또는 "이중
파괴" 버그로 직결되므로 최우선 보고.
]]

View file

@ -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`로 파싱 자체가
안 되는 상태(= 아무것도 검증 못 함)인 것만 먼저 확정.
## 결과 확인 후 할 일
각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면

View file

@ -10,6 +10,41 @@
## 지금 열려있는 것 (우선순위순)
### 0-Y. ⭐ **최우선(신설) — `:Compute(fn)`의 lazy 핸들 계약을 유지할 것인가** (2026-08-13 여섯 번째 세션, 첫 실측에서 발견)
**실측 결과**: 콜백이 lazy `State<T>` 핸들을 받는 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>) -> T` | ❌ |
| 콜백 파라미터에 타입 주석 | ✅ |
| **콜백이 raw 값 `T`를 받으면** | ✅ **완전 클린** |
즉 표기 조정으로는 안 풀리고, **계약 자체가 원인**. `:Compute`만이
아니라 `Effect`/`Observer`/`Animate`/`Operator` 등 같은 계약을 공유하는
API 전부에 걸리며, 2026-08-07 일곱 번째 세션(커링 스타일 확정)과
2026-08-12 네 번째 세션(`:Get()` 누락 버그 전역 감사)이 전부 이 계약
위에 서 있음.
**선택지**:
1. **계약 유지 + 파라미터 주석 필수** — 설계 변경 없음, 대신 가장 흔한
자리에 매번 `function(s: State<number>)`를 써야 하는 인체공학 손해.
2. **콜백이 raw 값을 받도록 전환** — 타입 추론은 완벽해지지만 lazy
평가/trailing deps/`previous` 설계 전반과 충돌. **구조 변경 규모가 큼**
(사용자가 이번 라운드에서 미리 잡고 싶다고 한 바로 그 종류).
3. **혼합** — 기본은 raw, lazy가 실제로 필요한 자리만 별도 API.
**M0 착수 전에 정하는 게 맞음** — 2번을 고르면 base 문서 다수가 영향받음.
### 0-Z. ⭐ **최우선 — Attribute 이름 소유권을 무엇으로 판정할 것인가** (2026-08-13 여섯 번째 세션, 사용자가 다음 세션 심층 분석으로 이관)
**이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의

View file

@ -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<T>` 핸들을 받는 커링 계약이
**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 재확인(설계 문제 아님).