4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설(205문항). 범위를 셋으로 좁힘 — (1) 4라운드에 문항이 아예 없던 영역(project-setup / quad-types, 그리고 문서가 아니라 실제 커밋된 M1 코드), (2) 그 이후 확정된 것 (Detach/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의 심화. 회신을 4차에 걸쳐 받아 전량 반영했고, 커밋 전 감사를 각도를 바꿔 2라운드 돌렸다. 주요 확정/역전: - slot._detached lazy화, KeyGone엔 새 값 반환도 error, Owned=false에서 Detach는 _detached에 안 들어감(rawUnmount) - Slot:Replace 신설 + rawReplace/rawAdd 의사코드 신설(문서에 정의가 없었음) - raw* 인자를 index로 전부 통일 — 오래 열려 있던 캐비엇 종결. 래핑은 raw* 바깥에서만(공개 표면 + settle), raw*는 물리 요소만 다룸 - 물리 조작을 주입 op로(mountInst/unmountInst/disposeInst, 이름 가칭) — base는 Parent를 모른다는 지적. mountInst는 0-based 절대 offset을 받음 - Dispatch.setLength에 anchor(생략 시 ownerKey) — 부기 키와 생명주기 앵커 분리, 4라운드 D-56 역전(archive로) - Dispatch.getOffsetAt 신설(pull) + 접두합 캐시(offsetDirtyFrom), setOffsetSource(None)은 얼리 리턴, None의 뜻을 "발행 채널 없음"으로 정정 - recompute가 owner 베이스에서 시작(중첩 offset이 부모 베이스를 못 받던 결함), _baseObserver로 깊은 전파, Offset Source identity 재사용(포탈), bk.N or 0(빈 Slot 크래시) - Effect(fn, ...deps) 확정 — Ref도 의존성(옛 "trailing args sugar 안 만듦" 역전), Tween:Mapped, groupClaimKeys 키 = (inst, groupValue) → k - 게이팅 먼저(M2로 앞당김) — 다만 대상이 Blocker가 아니라 공용 Gate 노드로 바뀌었고, 설계는 사용자 지시로 다음 세션(M2 착수를 막는 유일한 항목) 새 research 둘: gate-primitive.md(다음 세션이 이어받을 재료), state-epoch-validation.md(전파 중 Get이 섞인 값을 캐시하는 glitch — 정확성 결정이라 M3 전 결론 필요). 감사가 잡은 것 중 큰 것: 확정한 Owned가 Slot:List 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, effect-plan.md의 역전 배너 없는 자기모순, 그리고 손대지 않은 문서(ROADMAP 백로그·debounce-throttle-plan)가 "Gate는 M3에서"로 남아 있던 사각지대. doc-check.py ERROR 0. 상세는 qa-request/pre-implementation-qa-round5-followup.md (A~K절, 마지막이 최신). Co-authored-by: qwreey <me@qwreey.moe>
14 KiB
2026-08-21-02 — 구현 전 QA 5라운드(문항지 → 회신 → 1차 처리), Gate/에포크 리서치 신설
요약: 사용자 요청으로 QA 5라운드 문항지(205문항) 를 만들고, 같은
세션에 회신을 받아 1차 처리까지 끝냈다. 즉시 반영 14건, 되물은 것 7건,
그리고 새 research 문서 둘(gate-primitive.md,
state-epoch-validation.md)이 나왔다. 처리 결과의 소스는
qa-request/pre-implementation-qa-round5-followup.md.
1. 5라운드가 왜 생겼나 — 4라운드 처리 때의 판단을 뒤집음
4라운드 종결 시점엔 사용자 지시("이후 stale 만 잡는것으로 끝낼 수
있어보임")로 5라운드를 안 만들기로 했고 그 문장이 세 곳
(todos.md 00번, README.md qa-request 행, 4라운드 followup H-7)에
적혀 있었다. 같은 날 사용자가 5라운드를 요청 — "4차에서 예로 넘어갔던건
스킵하고, 새로운 부분들이나 다른 깊은 부분을 예가 나와야 정상인 질문들을
쌓아보자." → 세 곳 전부 정정하고 문항지를 신설했다.
범위를 셋으로 좁힌 게 이번 라운드의 설계: (1) 4라운드에 문항이 아예
없던 영역(project-setup-plan.md/quad-types-plan.md, 그리고 문서가
아니라 실제 커밋된 M1 코드), (2) 4라운드 회신 이후 확정된 것
(Detach/_detached/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의
심화(예: debounce-throttle-plan.md는 1100줄인데 4라운드 문항이 9개뿐이었음).
2. 문항지 작성 중 이미 잡힌 것
문항을 쓰는 과정 자체가 감사가 됐다:
EF-3— 4라운드 followup이 "반영 완료"로 적은E-10(dedup 대칭)이effect-plan.md에 실제로는 안 들어가 있었다. → 일반 교훈으로CR-1("followup 표를 신뢰 소스로 쓰면 안 된다, 소스는base/본문")을 문항화했고, 사용자가 "예"로 확인해 이번에 실제로 반영했다.DE-9—KeyGone소멸 루프가 새 값을 반환받으면settle의 교체 분기로 들어가rawAdd(self, result, 0)(범위 밖 인덱스)로 터진다.IM-1—architecture.md는 "지금은New()미노출 싱글톤"이라는데 실제 코드는module.New = New+return New().CR-2/CR-9—Tween:MapvsMapped이름과isBoundAlive의 세 번째 분기가 결정이 필요한데 어느 추적 목록에도 없었다.DC-9— Blocker 게이팅이recompute를 O(N²)→O(N)으로 줄였는데setOffsetSource의 즉시 계산이 그 자체로 O(N²)라 상쇄된다.
3. 회신으로 확정된 것 (즉시 반영)
slot._detached는 lazy — 모든 Slot이 빈 테이블을 미리 갖지 않는다 (사용자: "테이블 생성 비용을 모든 slot 이 가져야하나는 의문 … if 확인으로 nil 이면 스킵이 훨씬 싸게 먹히지 않는가?").getDetached(slot)getOrCreate.prev없이Detach를 반환해도 nop — 사용자가 prev 유무를 추적할 의무 없음.KeyGone엔nil/None/Detach만 — prev도 새 값도 error("KeyGone 을 받은 요소는 오직 … 캐싱 이외의 새로운 마운트나 생성을 거부한다").- ⭐
Owned = false에서Detach는_detached에 안 들어간다 — 남의 것에 소유권을 유지하는 건 모순이라rawUnmount로 처리하고 다음prev는nil. 부수로 unowned에선Detach와nil이 같은 동작이 되고_detachCleanup의_owned분기가 도달 불가가 되어 삭제됐다. - 조상이 죽으면 unowned 요소도 엔진 재귀 파괴로 같이 죽는다 —
Owned=false가 약속하는 건 "quad가 안 죽인다"뿐. 이 계약이 코퍼스에 없어서 신설. groupClaimKeys키 =(inst, groupValue) → k,nameClaims보다 위치 claim이 먼저.Frame { a, a }갭이 이걸로 닫힘.Tween<T>:Mapped확정(-ed관례).- ⭐ "게이팅 먼저" — M2가 M3의
Blocker에 의존하던 순서 문제를 로드맵 순서 유지가 아니라 앞당기는 쪽으로 결정. 단 앞당기는 대상이Blocker가 아니라 그 아래 공용Gate노드로 바뀌었다(아래 4절).
4. 새로 열린 것 — research 문서 둘
research/gate-primitive.md—DT-4에서 사용자가 정리: 시간 기반 게이트를 공개BlockerAPI 위에 못 얹는 이유가 순서 보존이다 ("이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제"). 그래서 emit을 가로채는 공용 노드가 필요하고, 공개 API로 낸다. 사용자 스케치는Gate(function(emit) return function() ... end end). 남은 건 이름(Gater가 어색하다는 지적 — 에이전트 권고는Gate그대로),:Apply팩토리 여부,Blocker의 얹힘 방식, M2 범위.research/state-epoch-validation.md—SS-2/SS-3에서 사용자가 제기한 glitch: DFS 전파 중 Observer가Get()을 부르면 아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어간다. 제안은 각 State가 상류 루트 Source들의 에포크(count) 를 들고Get()때 비교하는 것. 에이전트 분석: 진단·방향 타당(MobX/Adapton류 선례), 정확성 결정이지 최적화가 아님, 다만 중복 통지는 안 고쳐지고 "선언 안 된 의존성"을 UB로 명문화 해야 함. M3(State 구현) 전에 결론 필요.
5. 2차 회신 — C절 전량 확정 (같은 세션)
되물은 6건이 그 자리에서 전부 닫혔다(처리 전량은 followup F절):
Slot:Replace신설(B-5) — 사용자가 "교체는 제거+삽입이 아니라 replace가 나아 보인다"고 제시.:List의 교체가spliceArraysDown+spliceArraysUp쌍(시프트 2회·recompute2회)에서rawReplace한 번(시프트 0) 으로 바뀌었고, 그 부수로C-7(:Single교체 시 Length가 잠깐 줄었다 느는 것)이 같이 사라졌다.- 에포크 안에서 중복 통지도 접기로(
B-7) — emit이(source, count)를 실어오므로 판정이 O(1). 2026-08-14에 폐기된 옛 dedup과 다른 장치임을 문서에 못 박았다(그건invalid플래그 기반이라 Observer 영구 침묵 모드가 있었고, 에포크는 매Set마다 새 값이라 그 모드가 없다). 구현 요구로seen/computedAt두 카운트 분리가 추가됐다. 에이전트가 붙였던 "선언 안 된 의존성을 UB로 명문화" 조건은 사용자 기각("이전과 다른게 없다"). - 물리 조작은 주입 op(
C-1) — "slot 의 해당 동작은 base 이므로 parent 를 모른다". 의사코드 전체의element.Parent = .../element:Destroy()를mountInst/unmountInst/disposeInst로 정정(9곳)하고 경계 절을 신설.rawAdd의사코드도 이때 처음으로 문서에 들어갔다. rawAdd의Length:Set제거(C-2) —Length는recompute만 쓴다.- 래핑/언래핑을 Slot 전체 연산으로(
C-3) —wrapElement/unwrapElement한 쌍 + 래퍼의_wrapped역참조. 사용자가 예상한 대로wrappers[key]/mounted[key]분리가 필요 없어졌다. setLength에anchor인자 신설(C-4) — 부기 키와 생명주기 앵커 분리. 4라운드D-56이 역전돼archive/bindlifetime-slot-owner-reversed.md로 갔고, 형태 미정으로 열려 있던isBoundAlive세 번째 분기 항목이 같이 닫혔다.Effect(fn, ...deps)확정(C-6) —Ref도 의존성이 될 수 있고, 최소 1회 실행(useEffect 동일), trailing lazy 위치 인자,Ref는Set될 때만 발화.Gate만 다음 세션으로 — "고칠것이 많으므로 … 지금 세션 상 지식만 이전될 수 있게 두세요." 그래서research/gate-primitive.md는 재료만 모아둔 상태로 두고question.md/todos.md에 M2를 막는 유일한 항목으로 남겼다.
6. (1차 시점 기록) 되물었던 것
파급 큰 순서로: rawAdd의 Length:Set 제거(지금 서술이 "기여도 합"
정의와 충돌하고 recompute와 이중 기록), updateFn이 State를 반환할 때의
래핑/prev identity(래핑이 공개 Slot:Add에만 있어 reconcile 경로엔
없다는 실제 갭), setLength의 Observer 앵커를 물리 target으로 되돌리기
(사용자 문제 제기 — "우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지",
되돌리면 4라운드 D-56의 백엔드 요구사항과 isBoundAlive 세 번째 분기가
통째로 불필요해짐), rawAdd 의사코드 초안 승인(문서에 정의가 아예 없었음),
Gate 이름·표면, Effect의 다중 의존성(Ref 포함) 안.
6-1. 3·4차 — mountInst의 삽입 위치에서 시작해 offset 모델이 정리됨
2차까지 끝난 뒤 사용자가 mountInst/unmountInst가 index를 안 받는 걸 문제
삼았다("웹에서는 어떻게 되냐가 모호함. 어디 둘지 어떻게 아느냐는것"). 이
질문이 결국 offset 부기 모델 전체를 정리하게 만들었다:
None이 두 뜻을 겸하고 있었다 — 정의는 "실제 마운트를 하지 않는 위치"인데 plain 요소가None+setLength(1)로 등록되고 있었다. 그래서 그 자리의 offset 숫자가 계산조차 안 됐고, DOM류 백엔드가 삽입 위치를 알 방법이 없었다. →None의 뜻을 **"발행 채널 없음"**으로 좁히고(참여 여부는lengthList가 답), 숫자가 필요한 쪽은Dispatch.getOffsetAt(ownerKey, i)로 pull한다.- ⭐⭐ 그 자리를 파다 별개 결함 발견 — 중첩 offset이 부모 베이스를 못 받았다.
recompute가sum = 0으로 시작하고ownerKey.Offset을 읽는 자리가 없어서, depth ≥ 2에서 자식 offset이 부모 베이스만큼 통째로 밀려 있었다(depth 1만 쓰던 동안 로컬==절대라 안 드러남). →base를 시드하고, 중첩 Slot이 자기Offset을 관측해 자식 offset을 다시 미는 구독을 추가. - ⭐ 반영하다 하나 더 — 재마운트가
OffsetSource를 새로 만들고 있었다. 언마운트가slot.Offset을 일부러 보존하는 이유(SL-75/DC-6: 이미 렌더된 요소들이 그 Source를 구독한 채 딸려 나감)가 재마운트에서 무너지고 있었음 →slot.Offset or Source(0)으로 identity 재사용. - 에이전트 제안(
bk.offsetListpush)은 사용자안(pull +getOffsetAt)에 밀려 폐기됐고, 이어서bk.base필드도 사용자 지적으로 걷어냈다 ("이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데" — 같은 값을 두 곳에 두는 중복 상태).isSlot분기는 남는데, 그건 타입 분기가 아니라 duck-typing이 금지돼 있어서(Roblox userdata의 미정의 키 인덱싱 에러,brand-plan.md) 브랜드 검사가 유일한 안전 경로이기 때문.
이 라운드의 패턴: 세 결함 전부 "사용자가 표면적인 질문 하나를 던졌더니 그
아래에서 나왔다" — mountInst의 인자 하나가 offset 모델의 두 결함을 끌어냈다.
6-2. 커밋 전 감사 2라운드 — 실제 결함 다수
핸드오버 준비로 quad-doc-auditor를 각도를 바꿔 두 번 돌렸다(1: diff와 그걸
인용하는 base / 2: 인덱스 레이어·히스토리). 둘 다 실질적인 걸 잡았다.
- ⭐
Owned가 코드에 도달하지 못하고 있었다 —settle/destroySlotTree/releaseElement가self._owned를 9곳 넘게 읽는데Slot:List/Slot:Single의사코드가opts인자를 안 받고 있었다. 확정한 옵션이 문서 안에서 배선이 끊긴 채 있었던 셈. - ⭐
effect-plan.md가 자기 자신과 모순 — 최상단 시그니처와 "trailing args sugar는 의도적으로 안 만듦" 확정 문단이 그대로인 채Effect(fn, ...deps)절이 추가돼 역전 배너 없이 정반대 두 서술이 공존하고 있었다. - ⭐ 손대지 않은 문서의 사각지대 —
ROADMAP.md백로그 문단과debounce-throttle-plan.md가 "Gate 추출은 M3에서"라고 여전히 말하고 있었다 (이번에 M2로 앞당겨졌는데 diff에 안 잡히는 파일이라 그대로 남음). 이건conventions.md가 경고하는 "변경한 세션 자신은 자기가 뭘 안 건드렸는지 모른다"의 교과서적 사례. - ⭐ 인덱스에 옛 결론이 남음 — State-에포크의 "중복 통지는 안 고쳐진다 /
UB 명문화 필요"가 같은 날 정반대로 확정됐는데
question.md/README.md만 안 따라왔다. - 그 외:
indexOfRaw미정의,mountSlotTree의 전제 미명시,Replace의destroyOld미서술,Gate이름이 용어 대기열에 없음, 주입 op 목록 미갱신, M2 체크박스 문장 깨짐 등.
그리고 감사 회신 자리에서 결정 셋이 더 확정됐다 — raw*를 index로 통일
(오래 열려 있던 캐비엇 종결), 래핑은 raw* 바깥, 그리고 getOffsetAt의
접두합 캐시(offsetDirtyFrom). offset 물리 재배치 질문도 "DOM은 insert가
알아서 밀어낸다"로 닫혔다.
7. 남긴 파일
qa-request/pre-implementation-qa-round5.md— 문항지(205문항)qa-request/pre-implementation-qa-round5-response.md— 회신 원문qa-request/pre-implementation-qa-round5-followup.md— 처리 결과의 소스 (A~E절 1차, F절이 최신)archive/bindlifetime-slot-owner-reversed.md— 역전된D-56원문research/gate-primitive.md,research/state-epoch-validation.md