8라운드가 써둔 지시서(-round9-brief.md)대로 커밋 9dd8213의 델타를 재트레이싱해 발견 18건(H-124~H-141)을 냈고(qa-request/pre-implementation-handtrace-round9.md), §4 문항 중 Q1~Q3를 사용자와 확정해 base/·ROADMAP에 반영했다. 결정의 소스는 -round9-followup.md(진행 표가 상태의 소스). 🔴 둘 다 luau 실측 재현. Q1 H-124 — recompute가 lengthList[i]를 되감기 판정보다 먼저 읽어, offset:Set 안의 사용자 코드가 커서 뒤 자리 수를 줄이면 sum += nil로 죽고 recomputeBlocker가 영구 On. 되감기 판정을 앞으로(continue), 읽기·누적은 되감지 않을 때만. Q2 H-125 — 재마운트 시 setOffsetSource가 slot.Offset을 바꾸는 순간 _baseObserver가 unbind 상태라 두 필드가 0으로 안 내려가 옛 베이스의 offsetCache[1]을 씀. 사용자 확정: Offset·_baseObserver를 Slot 생성자로(첫 마운트/재마운트 분기 소멸), materializeSlotTree는 blocker:On → bindLifetime → setOffsetSource, 파괴는 _destroyed 플래그 하나(핸들은 unbind만, mutate CRUD·:List·마운트 진입 error, Owned=false는 안 섬, 이중 dispose no-op). Q3 H-126/H-137/H-141 — element→index 맵이 Slot 층(slot._elemIndex)과 Dispatch 층(bk.tokens/indexOfToken)에 두 벌 있었고, 후자의 token은 사용자가 정한 적 없는 것(2026-08-25 /code-review가 발명해 사용자 인용문 옆에 앉아 있던 것). bk.indexOfElement 하나로 통일, setLength 5번째 인자 element, splice가 비운 자리는 세 배열 전부 처리. H-137 소멸. 부수: H-140(ROADMAP의 폐기된 "해제 시 slot.Offset = nil" 잔존) 정정, H-125 피해 범위를 "중첩 Slot의 Offset"으로 축소(유저 체인은 요소 인스턴스에 바인드돼 전파됨 — 실측), G각도로 "for d in seen do는 유효한 Luau가 아니다"가 거짓임을 확인, keyof<{}> 빈 Store 실측 클린. conventions.md 신설: "/code-review(그리고 메인 세션)가 내놓는 새 필드·인자· 이름·메커니즘은 발견이지 결정이 아니다" — 이 세션에서 메인 세션도 같은 실수를 세 번 했다(subject 인자 / Observer 위치 필드 = 기각된 Effect userdata 재개방 / 조회 클로저). 검증: quad-doc-auditor 6라운드(확실 1→1→3→1→1→0, base/ 본문 결함은 1라운드 이후 0건 — 나머지는 인덱스 레이어·인용처), doc-check ERROR 0. /code-review high는 Q4~Q10 반영 뒤 한 번에(같은 파일을 또 건드려 diff가 섞이므로 지금 체크포인트). Claude-Session: https://claude.ai/code/session_01F9zgJ4c4kDitAoQMm9qxKn Co-authored-by: qwreey <me@qwreey.moe>
12 KiB
7라운드 손 트레이싱 — 참조 구현과 스파이크 (2026-08-25 실측)
무엇인가: .claude/qa-request/pre-implementation-handtrace-round7.md의
4·5·6차 패스가 발견을 재현하는 데 쓴 코드 전량. audit/의 다른 폴더와
같은 성격이고(계획이 아니라 실측 결과 기록), type-recursion-issue/처럼
스크립트를 같이 두는 구성이다 — 판정이 "문서대로 짠 것을 돌려본 결과"라
개별 파일을 직접 돌려야 재현된다.
⚠️⚠️ 이 코드는 문서가 아니라 문서의 전사물이다. spikes/core.luau ·
spikes/dispatch.luau · spikes/chain.luau는 base/의 확정 의사코드를
손으로 옮긴 것이고, 옮기는 과정에서 틀렸을 수 있다. 그래서 여기서 나온
발견을 검증할 때 재실행은 검증이 아니다 — 재실행은 "전사물이 그렇게
동작한다"만 말해준다. 반드시 아래 "대조표"를 따라 원문 의사코드와 줄 단위로
대조할 것. 대조에서 전사 오류가 나오면 그 발견은 quad의 결함이 아니라 이
폴더의 결함이다.
환경: luau / luau-analyze
(~/.local/share/mise/installs/luau/latest). 저장소 루트에서
cd .claude/audit/handtrace-round7-reference-impl/spikes && luau <파일>.
런타임 스파이크는 luau, ty*는 luau-analyze로 돌린다.
실행 결과 스냅샷: RUN-runtime.txt(런타임 18개), RUN-typecheck.txt
(타입 11개). 2026-08-25 실행분 그대로이고, 재실행이 이와 달라지면 그 자체가
조사 대상이다.
⚠️⚠️ [2026-08-26] 이 전사물은 8라운드 이전 계약이다
8라운드(qa-request/pre-implementation-handtrace-round8.md)가 바로 이
전사물이 돌지 않은 경로들에서 결함을 찾아냈고, 그 결과 여기 옮겨진
계약 몇 개가 바뀌었다(결정의 소스는 qa-request/pre-implementation-handtrace-round8-followup.md). 이 폴더는
2026-08-25 실측의 기록이라 본문을 소급 수정하지 않는다 — 대신 재실행할 때
아래를 알고 볼 것. 바뀐 계약을 이 코드로 "재확인"하지 말 것.
| 여기 있는 것 | 지금 확정된 것 | 근거 |
|---|---|---|
core.luau의 Observer:_receive가 self.fn(self, from) — 2-인자. t9_gate_chain.luau 등 Observer를 쓰는 스파이크 전부가 이 위에서 돈다 |
3-인자 fn(targetState, self, emitFrom), 루프는 sub.fn(sub._state, sub, from). observer._state 강참조 신설 |
H-109/H-110 |
core.luau의 State가 rawInvalid: boolean |
캐시 카운터 쌍(cacheTargetCount/cacheCurrCount) — 7라운드 H-85가 이미 교체했는데 전사물이 옛 필드로 남았다. t5_recompute_race/t5b_fix는 의도적으로 이 필드의 버그·기각안을 보여주는 것이라 예외 |
H-85(7라운드) |
core.luau/dispatch.luau의 부기가 단일 bk.invalidAfter |
두 필드로 갈라졌다 — bk.offsetCacheValidUpTo(캐시)와 bk.offsetSetUpTo(:Set 완료). 옛 단일 필드가 두 뜻을 겸한 게 되감기 신호가 지워지는 원인이었다 |
/code-review high 4차 |
d7_splice_fix.luau:14의 splice 무효화가 math.min(bk.invalidAfter, index) |
splice는 index - 1(커서 위치 splice가 "변경 없음"과 구분이 안 된다). dispatch.luau:84의 setLength용 math.min(…, i)는 정정 대상이 아니다 — 그쪽 공식은 원래 i다 |
H-113 |
Ref를 쓰는 자리의 콜백 호출 |
fn(value, ref) — 두 번째 인자가 Ref 자신(= Epoch). :Set 순서는 값 → Revision → 콜백, 순회는 .Callbacks + .WeakCallbacks |
H-107/H-108 |
[2026-08-27 추가, 9라운드] dispatch.luau의 recompute가 lengthList[i]를 읽고 누적한 뒤 되감기를 판정 |
되감기 판정이 먼저(continue), 읽기·누적은 되감지 않을 때만 — 옛 순서는 커서 뒤 자리 수가 줄면 sum += nil |
H-124/Q1 |
[2026-08-27 추가] dispatch.luau의 setLength가 box.pos(가변 박스)로 위치를 캡처, 8라운드 전사물엔 bk.tokens/indexOfToken |
토큰 폐기 — 5번째 인자 element를 캡처해 bk.indexOfElement[element] 조회. slot._elemIndex도 이 맵으로 통합 |
H-141/Q3 |
[2026-08-27 추가] newSlot이 Offset을 만들고 materializeSlotTree 상당이 setOffsetSource 뒤에 관측자를 붙임 |
Offset·_baseObserver는 생성자에서, 순서는 blocker:On() → bindLifetime → setOffsetSource |
H-125/Q2 |
d7_splice_fix.luau의 결론(H-102가 역참조 조회로 닫힌다)은 영향받지
않는다 — 그 테스트는 recompute가 끝난 뒤 splice하는 시나리오라 H-113이
문제 삼은 "루프 커서 위치에서의 splice" 경계를 애초에 안 밟는다. 공식만 낡았다.
([2026-08-27 정정] 여기 한때 *"토큰 역참조로 닫힌다"*였는데, 토큰은 9라운드
H-141/Q3로 폐기됐다 — 지금은 요소 캡처 + bk.indexOfElement 조회다. 결론
("캡처 말고 조회")은 그대로다. 9라운드 전사물(요소 키)은 세션 스크래치패드
ref9/에 있고 근거는 qa-request/pre-implementation-handtrace-round9.md에
인라인 전사돼 있다.)
참조 구현 3개 — 원문 대조표
각 파일이 어느 절을 옮긴 것인지. 검증 패스는 이 표의 왼쪽(전사물)과 오른쪽(원문)을 대조해야 한다.
| 파일 | 옮긴 대상 |
|---|---|
spikes/core.luau |
base/state-epoch-plan.md §2(Epoch/리비전 갱신) · §3(EpochMap의 Update/Refresh/Sync/TrackFrom) · §4(수신 규칙 1~3, 재계산 판정, 재계산 후 처리) · §5(emit 페이로드) / base/source-state-plan.md의 전파 모델과 :With/:Compute 노드 / base/gate-plan.md 4·8번(withheld, flush 시 스왑, 빈 배치 무통지, 게이트-게이트 unfold) / base/blocker-plan.md(On/Off/OffWithoutEmit/IsOn/Policy) / ROADMAP.md M2의 H-23(구독자 스냅샷) |
spikes/dispatch.luau |
base/dispatch-core-plan.md의 Dispatch.getOffsetAt(접두합 캐시) · recompute · Dispatch.setLength · Dispatch.setOffsetSource · 배치 게이팅(getBlocker) |
spikes/chain.luau |
base/dispatch-core-plan.md의 Dispatch.process(하강 diff (A)/(B) 분기) · Dispatch.retractFrom |
문서와 의도적으로 다른 곳 (전사 오류가 아님)
대조할 때 이 셋은 차이로 세지 말 것. 그 외의 모든 차이는 전사 오류 후보다.
core.luau의EpochMap:PeekDiffers— 문서에 없는 연산이다.GateNode가 §4의 규칙 1~3을 돌려면emitEpochMap을 갱신하지 않고 비교만 해야 하는데 그 연산이 표면에 없다는 게H-72그 자체라, 게이트를 돌려보려면 임시로 하나 둘 수밖에 없었다. 이 함수를 쓰는 자리마다 주석으로 표시해뒀다.dispatch.luau의local box = { pos = i }— 확정 의사코드는gatedRecompute가i를 직접 캡처한다. 여기선box.pos를 읽는데,box.pos를 아무도 안 고치면 동작이 완전히 동일하고(초기값이i),d7_splice_fix.luau가 A/B 대조를 하려고 그 필드를 고친다. 즉 기본 동작은 문서 그대로고, 이 우회가 없으면H-102의 대조군을 만들 수 없다.- 주입 op·
bindLifetime/canExecute가 없다 — 순수luau엔 엔진이 없으므로 생명주기 게이팅을 뺐다(그 공백 자체가H-97이다). 그래서 이 전사물은 "구독자가 살아있는가" 판정을 하지 않는다 — 그 판정이 결과를 바꿀 수 있는 발견(H-98등)은 그 점을 감안해 읽을 것.
스파이크 → 발견 대조표
"기대"는 그 발견이 주장하는 결과다. 재실행 결과가 이와 다르면 발견 쪽을 의심할 것.
4차 패스 — 반응형 코어와 예외 경로
| 파일 | 발견 | 무엇을 보여주는가 |
|---|---|---|
t1_diamond.luau |
(대조군) | 다이아몬드에서 Observer가 1회만 울고 섞인 값이 안 나온다 — 전사물이 §1의 glitch 해소를 재현한다는 증거 |
t8_reentrant.luau |
(대조군) | 전파 도중 동기 :Set() 재진입이 안전하다(220,330) |
t9_gate_chain.luau |
(대조군) | 게이트 2겹이 어느 순서로 풀려도 1회 발화·배치 2개 |
t5_recompute_race.luau |
H-85 |
재계산 도중 도착한 무효화를 꼬리의 rawInvalid = false가 지운다(2가 영구히 남음) |
t5b_fix.luau |
H-85 갈래 (a) |
rawInvalid를 fn 앞으로 옮기면 198로 고쳐진다 |
t4_throttle.luau |
H-86 |
정책이 보류분을 못 읽으면 leading이 사라지고(t=4.00) 타이머가 안 끝난다. localpending 변형은 1-1절 그림과 일치 |
t7_poisoned.luau |
H-87 |
배치 중 error → Blocker가 영구 On → 이후 recompute 0회 |
t2_error.luau |
H-88 |
전파 중 error → 뒤 구독자 영구 침묵(값만 자가치유) |
t3_gate_error.luau |
H-89 |
flush 중 error → 배치 소멸, 재도착도 규칙 3으로 삼켜짐 / Off() 순회 중 error → 뒤 게이트가 안 풀림 |
t6_effect_gate.luau |
H-90 |
공용 EpochMap이 루트로 접어 게이팅이 무력화(막혀 있는데 이미 돌았다) |
t10_subscribe_gc.luau |
H-98 |
중간 State가 수거되면 :Subscribe()한 Observer가 살아있는 채로 영영 안 울린다 |
5차 패스 — 타입 (luau-analyze)
| 파일 | 발견 | 무엇을 보여주는가 |
|---|---|---|
ty1_apply_call.luau / ty1b.luau |
H-94 |
__call 테이블이 (State<T>) -> U 자리에 안 들어간다(제네릭·비제네릭 양쪽). 직접 호출은 통과 |
ty4_effect_ret.luau |
H-95 |
Effect의 fn이 아무것도 안 돌려주면 에러. 가변 반환 팩·유니온은 통과 |
ty5_updatefn.luau / ty6_multiret.luau |
H-95 |
선언된 다중 반환보다 적게 반환하면 에러(값 하나만/nil만/없음 전부) |
ty7_fix.luau |
H-95 갈래 |
함수 타입 유니온이 네 모양을 다 받고 엉뚱한 반환은 여전히 잡는다 |
ty9_mixed_deps.luau / ty10_contrast.luau |
H-96 |
deps 0개는 무주석 통과, deps가 붙으면 무주석이 깨진다. dep 주석 시 Source/State 혼합도 정확히 좁혀진다 |
ty3_epoch_effectfn.luau |
H-100 · (대조군) |
Source가 Epoch를 구조적으로 만족한다(성립). {[Source<T>]: true}는 {[Epoch]: true} 자리에 안 들어간다 |
ty2_effect_deps.luau |
(대조군) | `State |
ty8_gate_apply.luau |
(대조군) | state:Gate(function(emit) return b:Policy(emit) end)가 성립하고, gate-plan.md가 정정한 오답 b.Policy는 타입이 잡는다 |
6차 패스 — Length/Offset 부기와 error 계약
| 파일 | 발견 | 무엇을 보여주는가 |
|---|---|---|
d1_basic.luau / d2_slots.luau |
(대조군) | 접두합 캐시와 형제 offset 전파가 정확하다(0,1,2 / 1→4→1). 배치 게이팅으로 recompute 1회 |
d4_reentrant.luau |
H-101 |
recompute 재진입 → 꼬리가 Length를 낡은 합계로 덮어씀(2 vs 실제 3) |
d7_splice_fix.luau |
H-102 |
splice 후 캡처 인덱스가 낡아 뒤 형제 offset이 1로 남음. 위치를 재조정하면 5 |
d5_chain_error.luau |
H-103 |
process가 던지면 NOOP 슬롯이 남아 claim이 영구히 잠기고 명시적 철거로도 회수 안 됨 |
e1_errlevel.luau |
H-104 |
error(msg)와 error(msg, 2)가 가리키는 줄이 다르다 |
d9_hole2.luau |
H-106 |
부기 구멍이 C-6의 메시지가 아니라 getOffsetAt의 익명 산술 에러로 먼저 터진다 |
여기 없는 것
- 1~3차 패스의 재현 코드는 없다. 1차는 문서 대 문서라 코드가 없고,
2·3차는 최소 재현을 그때그때 짜서 돌린 것이라 그 문서 본문에 인라인으로
들어 있다(
H-71의Relate누수,H-77,H-73H-76,H-78H-83). 그쪽을 재검증하려면 그 항목 안의 코드 블록을 쓰면 된다. luau-test/로 승격한 것은 없다. 여기 있는 건 "설계가 이렇게 동작하는지" 본 것이지 "M0/M2가 통과해야 할 스파이크"가 아니다. 발견이 확정 처리되면 그중 일부가luau-test/로 갈 수 있고, 그건 그때 판단할 일이다.