From d2d67c7aebca4fa2abe6b5b21af350c91f4e34fd Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 28 Aug 2026 01:03:05 +0900 Subject: [PATCH] =?UTF-8?q?qa:=2010=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EA=B4=91?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=ED=83=90=EC=82=AC=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=E2=80=94=20=EB=B0=9C=EA=B2=AC=20H-150~H-157=20(=F0=9F=94=B4=20?= =?UTF-8?q?0=20/=20=F0=9F=9F=A1=205=20/=20=F0=9F=9F=A2=203),=20=C2=A74=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EB=AC=B8=ED=95=AD=207=EA=B1=B4=20?= =?UTF-8?q?=ED=9A=8C=EC=8B=A0=20=EB=8C=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신선한 탐사자(fable) 단일 컨텍스트, 지시서 -round10-brief.md §2 레인 A·C 완료, B 부분, D ALL PASS - audit/handtrace-round10-reference-impl/: round7 참조 구현을 현재 계약으로 갱신·재실행 (부수: round7/ref9 _recompute 첫 인자 오류 발견·정정) - base/·인덱스 레이어는 미변경 — 결정은 사용자 배치 회신 뒤 -round10-followup.md로 Co-authored-by: qwreey Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb --- .claude/README.md | 4 +- .../README.md | 98 +++ .../spikes/chain.luau | 46 ++ .../spikes/core.luau | 209 +++++++ .../spikes/core10.luau | 476 ++++++++++++++ .../spikes/core9.luau | 307 +++++++++ .../spikes/d10_remove_at_tail.luau | 26 + .../spikes/d10_remove_at_tail_r10.luau | 26 + .../spikes/d11_two_ops.luau | 26 + .../spikes/d11_two_ops_r10.luau | 26 + .../spikes/d12_remount_stale.luau | 24 + .../spikes/d13_splice_at_cursor.luau | 18 + .../spikes/d13_splice_at_cursor_r10.luau | 18 + .../spikes/d14_baseline.luau | 13 + .../spikes/d14_baseline_r10.luau | 13 + .../spikes/d15_spliceup_vacate.luau | 30 + .../spikes/d16_remount_variants.luau | 40 ++ .../spikes/d17_ctor_cost.luau | 13 + .../spikes/d18_add_middle_elemkey.luau | 12 + .../spikes/d1_basic.luau | 12 + .../spikes/d20_indexOfElement_weak.luau | 40 ++ .../spikes/d21_reconcile_batch.luau | 50 ++ .../spikes/d22_detach_keygone.luau | 34 + .../spikes/d23_instancechild_spurious.luau | 42 ++ .../spikes/d2_slots.luau | 23 + .../spikes/d4_reentrant.luau | 31 + .../spikes/d5_chain_error.luau | 49 ++ .../spikes/d7_splice_fix.luau | 39 ++ .../spikes/d9_hole2.luau | 11 + .../spikes/dispatch.luau | 115 ++++ .../spikes/dispatch10.luau | 587 ++++++++++++++++++ .../spikes/dispatch9.luau | 289 +++++++++ .../spikes/e1_errlevel.luau | 14 + .../spikes/g1_keyof_empty.luau | 28 + .../spikes/g1b_keyof_empty_clean.luau | 17 + .../spikes/g2_arity.luau | 9 + .../spikes/g3_for_in_table.luau | 4 + .../spikes/g3b_for_in_strict.luau | 4 + .../spikes/t10_subscribe_gc.luau | 25 + .../spikes/t11_effect_deps.luau | 42 ++ .../spikes/t12_subscribe_failfast.luau | 26 + .../spikes/t13_ref_dedup.luau | 20 + .../spikes/t14_effect_state_matrix.luau | 99 +++ .../spikes/t15_rerun_dead_handle.luau | 53 ++ .../spikes/t16_gate_resub_refresh.luau | 100 +++ .../spikes/t17_hold_gc.luau | 69 ++ .../t18_blocker_vestigial_and_set_same.luau | 37 ++ .../spikes/t19_observer_level2.luau | 24 + .../spikes/t1_diamond.luau | 15 + .../spikes/t22_fn_destroys_inst.luau | 17 + .../spikes/t23_store_reserved_runtime.luau | 49 ++ .../spikes/t24_gate_brand_missing.luau | 22 + .../spikes/t2_error.luau | 26 + .../spikes/t3_gate_error.luau | 47 ++ .../spikes/t4_throttle.luau | 84 +++ .../spikes/t5_recompute_race.luau | 21 + .../spikes/t5b_fix.luau | 20 + .../spikes/t6_effect_gate.luau | 28 + .../spikes/t7_poisoned.luau | 40 ++ .../spikes/t8_reentrant.luau | 17 + .../spikes/t9_gate_chain.luau | 27 + .../spikes/ty10_contrast.luau | 20 + .../spikes/ty11_store_final.luau | 57 ++ .../spikes/ty1_apply_call.luau | 28 + .../spikes/ty1b.luau | 23 + .../spikes/ty2_effect_deps.luau | 27 + .../spikes/ty3_epoch_effectfn.luau | 46 ++ .../spikes/ty4_effect_ret.luau | 23 + .../spikes/ty5_updatefn.luau | 31 + .../spikes/ty6_multiret.luau | 16 + .../spikes/ty7_fix.luau | 12 + .../spikes/ty8_gate_apply.luau | 41 ++ .../spikes/ty9_mixed_deps.luau | 28 + .../pre-implementation-handtrace-round10.md | 399 +++++++++++- 74 files changed, 4473 insertions(+), 9 deletions(-) create mode 100644 .claude/audit/handtrace-round10-reference-impl/README.md create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/chain.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/core.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/core10.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/core9.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d10_remove_at_tail.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d10_remove_at_tail_r10.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d11_two_ops.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d11_two_ops_r10.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d12_remount_stale.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d13_splice_at_cursor.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d13_splice_at_cursor_r10.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d14_baseline.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d14_baseline_r10.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d15_spliceup_vacate.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d16_remount_variants.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d17_ctor_cost.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d18_add_middle_elemkey.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d1_basic.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d20_indexOfElement_weak.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d21_reconcile_batch.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d22_detach_keygone.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d23_instancechild_spurious.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d2_slots.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d4_reentrant.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d5_chain_error.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d7_splice_fix.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/d9_hole2.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/dispatch.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/dispatch10.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/dispatch9.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/e1_errlevel.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/g1_keyof_empty.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/g1b_keyof_empty_clean.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/g2_arity.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/g3_for_in_table.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/g3b_for_in_strict.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t10_subscribe_gc.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t11_effect_deps.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t12_subscribe_failfast.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t13_ref_dedup.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t14_effect_state_matrix.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t15_rerun_dead_handle.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t16_gate_resub_refresh.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t17_hold_gc.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t18_blocker_vestigial_and_set_same.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t19_observer_level2.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t1_diamond.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t22_fn_destroys_inst.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t23_store_reserved_runtime.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t24_gate_brand_missing.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t2_error.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t3_gate_error.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t4_throttle.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t5_recompute_race.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t5b_fix.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t6_effect_gate.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t7_poisoned.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t8_reentrant.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/t9_gate_chain.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty10_contrast.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty11_store_final.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty1_apply_call.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty1b.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty2_effect_deps.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty3_epoch_effectfn.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty4_effect_ret.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty5_updatefn.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty6_multiret.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty7_fix.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty8_gate_apply.luau create mode 100644 .claude/audit/handtrace-round10-reference-impl/spikes/ty9_mixed_deps.luau diff --git a/.claude/README.md b/.claude/README.md index 6ce83a1..0a6d7a3 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,11 +24,11 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음. **[2026-08-21 확장] 확정된 결정의 "왜 그렇게 정했나" 근거 기록도 여기 둔다** — `research/`(아직 상의 필요)도 `archive/`(뒤집혔거나 기각됨)도 아니고, `base/`가 근거로 인용하는 온디맨드 자료라는 이 폴더의 기준에 정확히 맞기 때문(`slot-attach-decomposition.md`/`epoch-brand-composition.md`가 그렇게 들어옴) | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 최우선 절과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M3가 M2의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영 — **[2026-08-24]** 그때 열어뒀던 마일스톤 재편은 M2/M3 순서 교체로 닫혔다). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 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`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M2~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것. **⭐ [2026-08-24] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round6-followup.md`(**[2026-08-24 신설] 6라운드의 결정과 근거가 여기 소스다** — `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정한 기록이고, 반영 후 `/code-review high`가 잡은 7건(그중 셋이 이번 반영이 만든 회귀)도 D절에 있다. 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`). `pre-implementation-handtrace-round7.md`(**[2026-08-25 신설, 회신 대기]** 7라운드: 6라운드와 같은 손 트레이싱이되 범위가 **M2(반응형 코어)와 M2→M3 경계**다. 패스 6개가 각기 다른 각도를 쓴다 — 1차는 프리미티브 사이의 *호출 순서*를 시간축으로 겹쳐 보기, 2차는 문서가 "확인했다"고 적은 런타임/타입 주장을 실제로 `luau`/`luau-analyze`에 걸어보기, 3차는 **커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려보기** + 1·2차가 뺐던 "M2를 *소비하는* 문서", 4차는 **M2 코어를 문서 그대로 옮긴 참조 구현을 돌려보기** + 아무 문서도 안 정한 **예외 경로**, 5차는 **확정된 M2 표면을 실제 Luau 타입으로 선언해 `luau-analyze`에 걸어보기**(확정 시그니처가 확정 관용구를 통과시키는가), 6차는 **그 참조 구현을 M2→M3 경계(Length/Offset 부기·Dispatch 체인)까지 이어 붙여 돌려보기** + **quad 자신이 던지기로 확정한 error 42곳의 계약 감사**. 발견 번호는 6라운드에서 이어서 `H-55`부터, 패스를 가로질러 연속으로 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "문제가 없던 것" 부록은 다시 파지 않아도 되는 자리를 적어둔 것. **아직 아무것도 `base/`에 반영하지 않았다** — 판정은 사용자가 하고, 결정이 나면 6라운드처럼 `-followup.md`를 새로 만든다), `pre-implementation-handtrace-round7-verification.md`(**[2026-08-25 신설]** 그 52건이 **정말 유효한지만** 판정한 검증 패스 — 새 발견은 없다. ⚠️ **재실행이 아니라 대조로 판정했다**: 4·5·6차 패스가 쓴 전사물(`audit/handtrace-round7-reference-impl/spikes/`), `pre-implementation-handtrace-round7-followup.md`(**[2026-08-25 신설] 그 52건에 대한 사용자 결정과 `base/` 반영 결과 — 결정 단위 12묶음(🅐~🅜) 순서로 대화형 처리. **이 파일이 결정의 소스**이고 발견 원문은 앞의 두 파일. 무효/기각 7건(`H-73`~`H-77` 계열: `<>`가 값 호출부에서 동작함이 실측으로 드러나 Store 재설계로 이어짐, `RunInit` 누수는 성립 안 하는 사용법), 나머지는 전량 반영. **부수로 `question.md` 최우선 두 항목이 같이 닫혀 M2 착수 게이트가 0이 됐다**)과 `base/` 확정 의사코드를 줄 단위로 맞춰봤고(전사 오류로 생긴 발견은 **없었다**, 미신고 차이 3개는 전부 발견을 만들지 않는 방향), Luau 언어 동작·저장소 상태를 주장하는 것만 직접 재실행했다. 판정 분포와 항목별 근거는 그 문서 자신이 소스 — 여기서 세지 않는다. **결정 전에 반드시 읽을 것**: 부정 주장·개수 주장 몇 건이 정정됐고, 특히 **이미 사용자가 판단한 항목을 다시 묻게 되는 자리**가 하나 있다. 마지막 절의 **batch용 요약**이 유효 항목을 "한 결정으로 닫히는 묶음"으로 재편성해뒀으니 회신은 번호순이 아니라 그 묶음 단위로 하는 게 싸다), `pre-implementation-handtrace-round8.md`(**[2026-08-26 신설, 회신 대기]** 8라운드: 7라운드 반영으로 새로 쓰인 `base/` 서술을 **서로 겹쳐서** 재트레이싱한 것 — 개별 함수가 아니라 "반영된 결정들이 조합될 때 성립하는가"가 주제. 3패스에 걸쳐 `base/` 전 문서를 완독했고, 발견 번호는 `H-107`부터 이어 매김(발견 수·심각도 분포는 그 문서 자신이 소스), 실측 스파이크는 발견 항목에 인라인 전사. 사용자 결정 문항 Q1~Q10이 §4에 배치 회신용으로 정리돼 있다. 커밋 전 `/code-review`가 이 문서 자체의 결함 7건을 잡아 반영됐다(각 항목의 `[code-review 정정/추가]` 표시). **⭐ [2026-08-26] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round8-followup.md`(**[2026-08-26 신설] 8라운드의 결정과 근거가 여기 소스다** — Q1~Q10을 사용자와 대화형으로 처리한 기록. **역전은 없고** 전부 "7라운드 확정이 `base/`에 내려앉을 때 생긴 누락·충돌"을 닫은 것. ⭐ 사용자가 **문항의 전제 자체를 정정한 것이 둘** 있다(Ref 콜백 ↔ Observer 콜백은 애초에 통합 대상이 아니다 / `H-118`은 소유권 문제가 아니라 문장이 틀린 것) — 결정만 읽지 말고 그 두 절을 볼 것), `pre-implementation-handtrace-round9-brief.md`(**[2026-08-26 신설]** 9라운드를 돌릴 감사자에게 주는 **지시서** — 발견 보고가 아니라 그 앞단이다. 이런 지시서를 저장하는 건 이번이 처음으로, 7·8라운드 것은 대화에만 있었고 저장되지 않았다(8라운드 본문이 인용하는 "감사 지시서 §2"가 코퍼스 어디에도 없는 이유). 스코프를 **커밋 `9dd8213` 하나의 델타**로 정의한 게 핵심 — 8라운드 결정 반영과 그 뒤 `/code-review high` 7패스 수정이 전부 그 커밋에 들어 있고 아무도 트레이싱한 적이 없다. 레인 셋(A: 그 델타의 상호 간섭 / C: `audit/handtrace-round7-reference-impl/`을 지금 계약으로 갱신해 실행 / B: 8라운드 §6이 남긴 M5+ 값 단위 트레이싱)과 우선순위 근거, `-round8-followup.md`가 적어둔 **반복 실패 모드 7개**를 사냥 목록으로 옮겨 실었다. 각도 문자(A~H)는 8라운드와 같은 걸 유지해 상호참조가 되게 했다), `pre-implementation-handtrace-round9.md`(**[2026-08-26]** 9라운드 발견 보고 — 레인 A(`9dd8213` 델타 상호 간섭)·C(참조 구현을 현재 계약으로 재전사해 `luau` 실행)·G(Luau 사실 재확인)·D(ROADMAP M2 시뮬레이션)·B(M5+ 값 단위, 첫 시도) — 발견 `H-124`~`H-141`, 🔴 둘(`recompute`의 `lengthList[i]` nil 읽기 / 재마운트 시 `_baseObserver` 미바인드 캐시 stale) 다 실측 재현. §4 배치 문항 Q1~Q10 + §5 이상 없음(⭐ `keyof<{}>` 빈 Store 실측으로 클린) + §6), `pre-implementation-handtrace-round9-followup.md`(**[2026-08-27] 9라운드 결정의 소스 — 전량 처리 완료** — Q1(`recompute` 되감기 판정을 읽기 앞으로) / Q2(`Offset`·`_baseObserver`를 Slot 생성자로, `materializeSlotTree` 순서, `_destroyed` 플래그) / Q3(`element → index`는 `bk.indexOfElement` 하나 — 사용자가 정한 적 없는 `token` 폐기, `H-137` 소멸, `H-141` 신설) / Q4(`EffectHandle` 네 진입점 의사코드) / Q5(M2에 `Ref` 최소형) / Q6(`WeakUnsubscribe` 관대) / Q7(폐기 블록 `archive/`) / Q8(`InstanceChildHandler` 부기) / **Q9(문항 전제가 틀림 — Tween 절 스케치 한 줄 복사 오류)** / Q10(`reconcile` 배치 Blocker) + `H-138`(숏핸드 우선순위) / `H-139`(`New`/`drive` 파이프라인 의사코드) / **`H-142`(props에 `Parent` 금지 — 순서 문제 소멸)** / **`H-143`~`H-146`**(`/code-review`가 낸 새 메커니즘 넷 — 전부 권고 (a), `H-144`는 사용자 요청으로 재구독 뒤 epoch·Blocker 상호작용을 재트레이싱한 기록 포함). 진행 표가 상태의 소스, 사용자 회신 원문 셋 인용; **[2026-08-28]** `H-143`~`H-146` 반영분의 감사 8라운드 표와 `/code-review high` 기록도 여기), `pre-implementation-handtrace-round10-brief.md`(**[2026-08-28 신설]** 10라운드 지시서 — 델타가 아니라 **광범위** 스코프(M2~M8, 레인 A 반응형 코어 전체 / C 참조 구현 갱신·실행 / B 디스패치·Slot / D 타입·M1 코드), 신선한 탐사자가 한 번에 문항지를 만들어 사용자가 배치로 결정하게 하는 것이 목적), `pre-implementation-handtrace-round10.md`(**[2026-08-28 신설, 회신 대기]** 10라운드 발견 보고 + §4 배치 문항지 — 씨앗은 `/code-review`가 낸 `H-147`~`H-149`(죽은 핸들에서 `Rerun` / `Parent` 거부 전용 문구는 새 메커니즘 / Observer `Subscribe` 위임과 `level 2`), 탐사자 발견은 `H-150`부터. 레인 C 실행 기록은 `audit/handtrace-round10-reference-impl/`). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것(지시서도 같이 남길 것 — `-roundN-brief.md`) | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 최우선 절과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M3가 M2의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영 — **[2026-08-24]** 그때 열어뒀던 마일스톤 재편은 M2/M3 순서 교체로 닫혔다). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 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`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). `pre-implementation-handtrace-round6.md`(**[2026-08-22 신설]** 6라운드: 문항지가 아니라 **손 트레이싱**이다(2·3라운드와 같은 성격) — 사용자가 지목한 최근 확정 5개 영역(`Effect(fn, ...deps)`/`Gate`·`Blocker`/State 전파(`rawInvalid`·emit 지연)/Slot의 `native*`·offset·length·mount/`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려본 결과. 발견 번호는 `H-n`. **[2026-08-23] 2차 패스** — 1차가 안 본 영역(디스패치 코어 전체/라이프타임 유틸/Ref·Tag·Attribute·UI 숏핸드 핸들러/Slot의 `raw*` 계층). **[2026-08-23] 3차 패스** — 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어, Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트, 타입 계약과 실제 커밋된 M1 코드) + 통합 시나리오. **[2026-08-24] 4차 패스** — 문서 단위가 아니라 **축을 바꿔서**(핸들러 레지스트리 전수/두 대형 핸들러 문서 심층/`luau-test` 스파이크 실제 재실행/프리미티브 조합 매트릭스/`reference`·`archive`·로드맵 M2~M9/엔진·언어 사실 주장 전수 검증). 3·4차는 추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`와 공식 문서로 **직접 재현·교차검증**했고, 그 부수로 기존 `H-2`의 크래시 주장이 틀렸음도 드러났다(3차 패스 머리의 정정 절). 발견 번호는 패스를 가로질러 이어서 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "확인만 하고 문제 없었던 것" 절은 다시 트레이싱할 필요 없는 자리를 적어둔 것. **⭐ [2026-08-24] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round6-followup.md`(**[2026-08-24 신설] 6라운드의 결정과 근거가 여기 소스다** — `H-1`~`H-54`를 사용자와 대화형으로 하나씩 결정한 기록이고, 반영 후 `/code-review high`가 잡은 7건(그중 셋이 이번 반영이 만든 회귀)도 D절에 있다. 진행 경위와 사용자 발언 원문은 `session/2026-08-24-01-handtrace-round6-resolution.md`). `pre-implementation-handtrace-round7.md`(**[2026-08-25 신설, 회신 대기]** 7라운드: 6라운드와 같은 손 트레이싱이되 범위가 **M2(반응형 코어)와 M2→M3 경계**다. 패스 6개가 각기 다른 각도를 쓴다 — 1차는 프리미티브 사이의 *호출 순서*를 시간축으로 겹쳐 보기, 2차는 문서가 "확인했다"고 적은 런타임/타입 주장을 실제로 `luau`/`luau-analyze`에 걸어보기, 3차는 **커밋된 M1 코드·툴체인을 이 저장소에서 그대로 돌려보기** + 1·2차가 뺐던 "M2를 *소비하는* 문서", 4차는 **M2 코어를 문서 그대로 옮긴 참조 구현을 돌려보기** + 아무 문서도 안 정한 **예외 경로**, 5차는 **확정된 M2 표면을 실제 Luau 타입으로 선언해 `luau-analyze`에 걸어보기**(확정 시그니처가 확정 관용구를 통과시키는가), 6차는 **그 참조 구현을 M2→M3 경계(Length/Offset 부기·Dispatch 체인)까지 이어 붙여 돌려보기** + **quad 자신이 던지기로 확정한 error 42곳의 계약 감사**. 발견 번호는 6라운드에서 이어서 `H-55`부터, 패스를 가로질러 연속으로 매긴다. 발견 수/심각도 분포는 그 문서 자신이 소스. 각 패스의 "문제가 없던 것" 부록은 다시 파지 않아도 되는 자리를 적어둔 것. **아직 아무것도 `base/`에 반영하지 않았다** — 판정은 사용자가 하고, 결정이 나면 6라운드처럼 `-followup.md`를 새로 만든다), `pre-implementation-handtrace-round7-verification.md`(**[2026-08-25 신설]** 그 52건이 **정말 유효한지만** 판정한 검증 패스 — 새 발견은 없다. ⚠️ **재실행이 아니라 대조로 판정했다**: 4·5·6차 패스가 쓴 전사물(`audit/handtrace-round7-reference-impl/spikes/`), `pre-implementation-handtrace-round7-followup.md`(**[2026-08-25 신설] 그 52건에 대한 사용자 결정과 `base/` 반영 결과 — 결정 단위 12묶음(🅐~🅜) 순서로 대화형 처리. **이 파일이 결정의 소스**이고 발견 원문은 앞의 두 파일. 무효/기각 7건(`H-73`~`H-77` 계열: `<>`가 값 호출부에서 동작함이 실측으로 드러나 Store 재설계로 이어짐, `RunInit` 누수는 성립 안 하는 사용법), 나머지는 전량 반영. **부수로 `question.md` 최우선 두 항목이 같이 닫혀 M2 착수 게이트가 0이 됐다**)과 `base/` 확정 의사코드를 줄 단위로 맞춰봤고(전사 오류로 생긴 발견은 **없었다**, 미신고 차이 3개는 전부 발견을 만들지 않는 방향), Luau 언어 동작·저장소 상태를 주장하는 것만 직접 재실행했다. 판정 분포와 항목별 근거는 그 문서 자신이 소스 — 여기서 세지 않는다. **결정 전에 반드시 읽을 것**: 부정 주장·개수 주장 몇 건이 정정됐고, 특히 **이미 사용자가 판단한 항목을 다시 묻게 되는 자리**가 하나 있다. 마지막 절의 **batch용 요약**이 유효 항목을 "한 결정으로 닫히는 묶음"으로 재편성해뒀으니 회신은 번호순이 아니라 그 묶음 단위로 하는 게 싸다), `pre-implementation-handtrace-round8.md`(**[2026-08-26 신설, 회신 대기]** 8라운드: 7라운드 반영으로 새로 쓰인 `base/` 서술을 **서로 겹쳐서** 재트레이싱한 것 — 개별 함수가 아니라 "반영된 결정들이 조합될 때 성립하는가"가 주제. 3패스에 걸쳐 `base/` 전 문서를 완독했고, 발견 번호는 `H-107`부터 이어 매김(발견 수·심각도 분포는 그 문서 자신이 소스), 실측 스파이크는 발견 항목에 인라인 전사. 사용자 결정 문항 Q1~Q10이 §4에 배치 회신용으로 정리돼 있다. 커밋 전 `/code-review`가 이 문서 자체의 결함 7건을 잡아 반영됐다(각 항목의 `[code-review 정정/추가]` 표시). **⭐ [2026-08-26] 전량 처리·반영 완료** — 그 문서는 이제 **발견 당시의 기록**이라 각 항목의 "갈래"는 선택 전 목록이니 그대로 믿지 말 것), `pre-implementation-handtrace-round8-followup.md`(**[2026-08-26 신설] 8라운드의 결정과 근거가 여기 소스다** — Q1~Q10을 사용자와 대화형으로 처리한 기록. **역전은 없고** 전부 "7라운드 확정이 `base/`에 내려앉을 때 생긴 누락·충돌"을 닫은 것. ⭐ 사용자가 **문항의 전제 자체를 정정한 것이 둘** 있다(Ref 콜백 ↔ Observer 콜백은 애초에 통합 대상이 아니다 / `H-118`은 소유권 문제가 아니라 문장이 틀린 것) — 결정만 읽지 말고 그 두 절을 볼 것), `pre-implementation-handtrace-round9-brief.md`(**[2026-08-26 신설]** 9라운드를 돌릴 감사자에게 주는 **지시서** — 발견 보고가 아니라 그 앞단이다. 이런 지시서를 저장하는 건 이번이 처음으로, 7·8라운드 것은 대화에만 있었고 저장되지 않았다(8라운드 본문이 인용하는 "감사 지시서 §2"가 코퍼스 어디에도 없는 이유). 스코프를 **커밋 `9dd8213` 하나의 델타**로 정의한 게 핵심 — 8라운드 결정 반영과 그 뒤 `/code-review high` 7패스 수정이 전부 그 커밋에 들어 있고 아무도 트레이싱한 적이 없다. 레인 셋(A: 그 델타의 상호 간섭 / C: `audit/handtrace-round7-reference-impl/`을 지금 계약으로 갱신해 실행 / B: 8라운드 §6이 남긴 M5+ 값 단위 트레이싱)과 우선순위 근거, `-round8-followup.md`가 적어둔 **반복 실패 모드 7개**를 사냥 목록으로 옮겨 실었다. 각도 문자(A~H)는 8라운드와 같은 걸 유지해 상호참조가 되게 했다), `pre-implementation-handtrace-round9.md`(**[2026-08-26]** 9라운드 발견 보고 — 레인 A(`9dd8213` 델타 상호 간섭)·C(참조 구현을 현재 계약으로 재전사해 `luau` 실행)·G(Luau 사실 재확인)·D(ROADMAP M2 시뮬레이션)·B(M5+ 값 단위, 첫 시도) — 발견 `H-124`~`H-141`, 🔴 둘(`recompute`의 `lengthList[i]` nil 읽기 / 재마운트 시 `_baseObserver` 미바인드 캐시 stale) 다 실측 재현. §4 배치 문항 Q1~Q10 + §5 이상 없음(⭐ `keyof<{}>` 빈 Store 실측으로 클린) + §6), `pre-implementation-handtrace-round9-followup.md`(**[2026-08-27] 9라운드 결정의 소스 — 전량 처리 완료** — Q1(`recompute` 되감기 판정을 읽기 앞으로) / Q2(`Offset`·`_baseObserver`를 Slot 생성자로, `materializeSlotTree` 순서, `_destroyed` 플래그) / Q3(`element → index`는 `bk.indexOfElement` 하나 — 사용자가 정한 적 없는 `token` 폐기, `H-137` 소멸, `H-141` 신설) / Q4(`EffectHandle` 네 진입점 의사코드) / Q5(M2에 `Ref` 최소형) / Q6(`WeakUnsubscribe` 관대) / Q7(폐기 블록 `archive/`) / Q8(`InstanceChildHandler` 부기) / **Q9(문항 전제가 틀림 — Tween 절 스케치 한 줄 복사 오류)** / Q10(`reconcile` 배치 Blocker) + `H-138`(숏핸드 우선순위) / `H-139`(`New`/`drive` 파이프라인 의사코드) / **`H-142`(props에 `Parent` 금지 — 순서 문제 소멸)** / **`H-143`~`H-146`**(`/code-review`가 낸 새 메커니즘 넷 — 전부 권고 (a), `H-144`는 사용자 요청으로 재구독 뒤 epoch·Blocker 상호작용을 재트레이싱한 기록 포함). 진행 표가 상태의 소스, 사용자 회신 원문 셋 인용; **[2026-08-28]** `H-143`~`H-146` 반영분의 감사 8라운드 표와 `/code-review high` 기록도 여기), `pre-implementation-handtrace-round10-brief.md`(**[2026-08-28 신설]** 10라운드 지시서 — 델타가 아니라 **광범위** 스코프(M2~M8, 레인 A 반응형 코어 전체 / C 참조 구현 갱신·실행 / B 디스패치·Slot / D 타입·M1 코드), 신선한 탐사자가 한 번에 문항지를 만들어 사용자가 배치로 결정하게 하는 것이 목적), `pre-implementation-handtrace-round10.md`(**[2026-08-28 신설, 회신 대기]** 10라운드 발견 보고 + §4 배치 문항지 — 씨앗은 `/code-review`가 낸 `H-147`~`H-149`(죽은 핸들에서 `Rerun` / `Parent` 거부 전용 문구는 새 메커니즘 / Observer `Subscribe` 위임과 `level 2`), 탐사자 발견은 `H-150`부터. 레인 C 실행 기록은 `audit/handtrace-round10-reference-impl/`. **[2026-08-28 탐사 완료]** 🔴 0 / 🟡 5 / 🟢 3, §4 문항 7건(기존 3 + `H-150` `Effect._blocker` 죽은 부품 / `H-151` 게이트 우회 계약 / `H-153` Store 예약 이름 런타임 가드 / `H-154` `InstanceChildHandler` dedup) — 전부 권고 (a). 레인 A·C 완료, B 부분, D `./scripts/test.sh` ALL PASS). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것(지시서도 같이 남길 것 — `-roundN-brief.md`) | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-19 기준] 폴더 자체가 아직 없음**(M0/M1 스캐폴딩만으론 안 생기고 실제로 렌더링해보고 쓰는 단계부터, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | -| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **개수는 폴더가 소스**(여기서 세지 않음): `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션, 열한 번째 세션에 `canBound` 재도입 반영해 재갱신]** 실측된 사실 자체는 그대로 유효하고 `value` 단독 1-인자 재정정으로 오히려 더 중요해졌음 — 이중 바인딩 게이트(`canBound`)/emit 게이팅(`canExecute`)/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/`(개수는 폴더가 소스). 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md`의 `Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`), **`type-recursive-issue-with-typeof/`**(**[2026-08-15 신설]** 사용자가 발견한 `typeof(named fn)` 간접참조가 0-Y(재귀 제네릭 반환 leak)를 실제로 우회하는지 실측 — `REPORT.md` + `spikes/`. 결론: 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 LHS 명시 없이도 다운스트림이 안전해짐(체이닝 50단·타입 변경·중첩 self 호출까지 확인), `typing-limits.md` §1 ③으로 승격. 부수적으로 `setmetatable` 확장 시도에서 quad와 무관한 Luau 0.733 솔버 버그(모순 진단 두 개 동시 발생) 발견, 채택 안 함. `luau-test/16`(type function으로 `Store` 레코드 필드 합성) 복구도 이 조사 중 완료 — API 버전 드리프트였을 뿐 설계 문제 아니었음, `typing-limits.md` §5 승격), **`type-recursive-issue-try-callback/`**(**[2026-08-15 신설]** 콜백 파라미터 무주석 추론을 뚫을 방법이 정말 없는지 type function/메타테이블/오버로드/제네릭 디폴트 등 전방위로 재시도 — `REPORT.md` + `spikes/`(개수는 폴더가 소스 — 최초 라운드 + `/code-review high`가 이중 꺾쇠 명시적 제네릭 인스턴스화를 안 시도했음을 지적해 추가된 후속 조사 라운드로 구성). 결론: quad의 `state:Compute(fn)` 단일 호출 모양을 유지한 채로는 여전히 안 됨. 발견 셋 — (1) 근본 원인이 재귀 자기참조가 아니라 "제네릭 콜백 인자 전반에 컨텍스트 타입 전파가 안 됨"이라는 게 더 정확함(재귀 없는 최소 사례로도 재현), (2) `T`를 명시 중간 변수로 먼저 고정하거나 재사용 가능한 monomorphize 헬퍼를 거치면 실제로 추론이 살아나지만 둘 다 단일 콜론 호출을 2단계 체인으로 바꿔야만 해서 §0 대전제로 채택 안 함, (3) 이중 꺾쇠 명시 인스턴스화(`Compute<>(fn)`)는 leaf 호출에선 sound하게 성립하지만(spurious 진단 원인도 규명 — read-only/read-write 가변성 불일치) 매 호출 T/U 전부 명시 필요 + 중첩 self 호출 여전히 실패라 순손해로 채택 안 함), **`handtrace-round7-reference-impl/`**(**[2026-08-25 신설]** 7라운드 손 트레이싱 4·5·6차 패스가 발견을 재현하는 데 쓴 코드 전량 — `README.md` + `spikes/`(개수는 폴더가 소스) + 실행 결과 스냅샷 둘(`RUN-runtime.txt`/`RUN-typecheck.txt`). `type-recursion-issue/`와 같은 이유로 **스크립트를 같이 둠**. **⚠️ 여기 든 `core.luau`/`dispatch.luau`/`chain.luau`는 `base/`의 확정 의사코드를 손으로 옮긴 *전사물*이라 그 자체가 틀렸을 수 있다** — 그래서 그 README는 "재실행은 검증이 아니다"라고 못박고 **원문 대조표**와 **의도적으로 다른 곳 3개**를 같이 싣는다. 이 폴더에서 나온 발견을 판정하려면 재실행이 아니라 원문 의사코드와 줄 단위 대조가 필요하다) **[2026-08-25 신설] `type-store-index-keyof/`** — Store 타이핑을 `WrapStore` 합성에서 `index<>`/`keyof<>` + 팬텀 필드로 다시 짠 실측(사용자가 직접 돌린 탐색 원본 포함). `type-recursion-issue/`와 같은 **스크립트 동봉** 구성(판정이 여러 formulation 대조라 개별 파일을 직접 돌려야 재현됨). 싱글톤 보존 규칙, 실패한 formulation 6종, 예약 키 충돌이 조용히 타입 검사를 끄는 것, `f<>()`가 값 호출부에서 동작함(`H-73` 반증)까지 | +| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **개수는 폴더가 소스**(여기서 세지 않음): `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션, 열한 번째 세션에 `canBound` 재도입 반영해 재갱신]** 실측된 사실 자체는 그대로 유효하고 `value` 단독 1-인자 재정정으로 오히려 더 중요해졌음 — 이중 바인딩 게이트(`canBound`)/emit 게이팅(`canExecute`)/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/`(개수는 폴더가 소스). 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md`의 `Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`), **`type-recursive-issue-with-typeof/`**(**[2026-08-15 신설]** 사용자가 발견한 `typeof(named fn)` 간접참조가 0-Y(재귀 제네릭 반환 leak)를 실제로 우회하는지 실측 — `REPORT.md` + `spikes/`. 결론: 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 LHS 명시 없이도 다운스트림이 안전해짐(체이닝 50단·타입 변경·중첩 self 호출까지 확인), `typing-limits.md` §1 ③으로 승격. 부수적으로 `setmetatable` 확장 시도에서 quad와 무관한 Luau 0.733 솔버 버그(모순 진단 두 개 동시 발생) 발견, 채택 안 함. `luau-test/16`(type function으로 `Store` 레코드 필드 합성) 복구도 이 조사 중 완료 — API 버전 드리프트였을 뿐 설계 문제 아니었음, `typing-limits.md` §5 승격), **`type-recursive-issue-try-callback/`**(**[2026-08-15 신설]** 콜백 파라미터 무주석 추론을 뚫을 방법이 정말 없는지 type function/메타테이블/오버로드/제네릭 디폴트 등 전방위로 재시도 — `REPORT.md` + `spikes/`(개수는 폴더가 소스 — 최초 라운드 + `/code-review high`가 이중 꺾쇠 명시적 제네릭 인스턴스화를 안 시도했음을 지적해 추가된 후속 조사 라운드로 구성). 결론: quad의 `state:Compute(fn)` 단일 호출 모양을 유지한 채로는 여전히 안 됨. 발견 셋 — (1) 근본 원인이 재귀 자기참조가 아니라 "제네릭 콜백 인자 전반에 컨텍스트 타입 전파가 안 됨"이라는 게 더 정확함(재귀 없는 최소 사례로도 재현), (2) `T`를 명시 중간 변수로 먼저 고정하거나 재사용 가능한 monomorphize 헬퍼를 거치면 실제로 추론이 살아나지만 둘 다 단일 콜론 호출을 2단계 체인으로 바꿔야만 해서 §0 대전제로 채택 안 함, (3) 이중 꺾쇠 명시 인스턴스화(`Compute<>(fn)`)는 leaf 호출에선 sound하게 성립하지만(spurious 진단 원인도 규명 — read-only/read-write 가변성 불일치) 매 호출 T/U 전부 명시 필요 + 중첩 self 호출 여전히 실패라 순손해로 채택 안 함), **`handtrace-round10-reference-impl/`**(**[2026-08-28 신설]** 10라운드 레인 C — round7 스파이크 전량을 **복사**해(원본 불변) `H-107`~`H-149`의 현재 계약으로 갱신하고 `luau`로 재실행한 것: `README.md`(무엇을 어떻게 갱신했나 + round7 참고 구현의 `_recompute` 첫 인자 오류 정정 기록) + `spikes/`(신규 `core10.luau`/`dispatch10.luau` + 이 라운드 스파이크, 개수는 폴더가 소스). 같은 전사물 경고가 적용된다), **`handtrace-round7-reference-impl/`**(**[2026-08-25 신설]** 7라운드 손 트레이싱 4·5·6차 패스가 발견을 재현하는 데 쓴 코드 전량 — `README.md` + `spikes/`(개수는 폴더가 소스) + 실행 결과 스냅샷 둘(`RUN-runtime.txt`/`RUN-typecheck.txt`). `type-recursion-issue/`와 같은 이유로 **스크립트를 같이 둠**. **⚠️ 여기 든 `core.luau`/`dispatch.luau`/`chain.luau`는 `base/`의 확정 의사코드를 손으로 옮긴 *전사물*이라 그 자체가 틀렸을 수 있다** — 그래서 그 README는 "재실행은 검증이 아니다"라고 못박고 **원문 대조표**와 **의도적으로 다른 곳 3개**를 같이 싣는다. 이 폴더에서 나온 발견을 판정하려면 재실행이 아니라 원문 의사코드와 줄 단위 대조가 필요하다) **[2026-08-25 신설] `type-store-index-keyof/`** — Store 타이핑을 `WrapStore` 합성에서 `index<>`/`keyof<>` + 팬텀 필드로 다시 짠 실측(사용자가 직접 돌린 탐색 원본 포함). `type-recursion-issue/`와 같은 **스크립트 동봉** 구성(판정이 여러 formulation 대조라 개별 파일을 직접 돌려야 재현됨). 싱글톤 보존 규칙, 실패한 formulation 6종, 예약 키 충돌이 조용히 타입 검사를 끄는 것, `f<>()`가 값 호출부에서 동작함(`H-73` 반증)까지 | | `tools/` | **[2026-08-13 신설, `session/2026-08-13-09-structure-and-guardrails.md`]** 코퍼스 기계 점검 — `doc-check.py`가 깨진 파일/절 참조, README 색인 누락, 날짜 없는 시한부 주장("아직 안 돌려봄" 등), 미반영 ⚠️ 배너를 한 번에 훑음. **[2026-08-16]** 절 참조는 WARN이 아니라 **ERROR** — 판정 규칙은 `conventions.md`의 "절 인용 규약"이 소스. **중대 변경 후 커밋 전에 돌릴 것**(`python3 .claude/tools/doc-check.py`) — 수동 감사에서 나온 발견의 대부분이 이 종류였고, 실제로 문서를 쪼개다 잘못 옮긴 참조를 이게 잡아냄. ERROR는 고치고 WARN은 판단 대상 | | `agents/` | **[2026-08-16 신설]** 프로젝트 서브에이전트 정의(`.claude/agents/*.md`, Claude Code 표준 위치). 현재 `quad-doc-auditor.md` 하나 — `doc-check.py`가 못 잡는 의미론적 stale/모순(본문 문장이 뒤집힌 결정을 여전히 서술, 개수/목록 이중 소스 드리프트 등)을 신선한 맥락에서 찾는 읽기 전용 감사자. 중대 변경 커밋 전에 위임하는 게 기본 — **[2026-08-18 재설계] 한 턴에 하나씩만 돌리고(병렬 금지) 발견이 0건인 라운드가 나올 때까지 턴을 늘리는 루프**, 수정은 메인 세션이 일괄로 함(라운드 수·범위 좁히기 규칙은 여기 안 적음 — 소스는 conventions.md)이며 절차는 `.claude/conventions.md` "작업 방식" 절이 소스(**[2026-08-16]** 이 루프를 담던 `workflows/quad-handover-audit.js`는 토큰 과다·픽스 에이전트발 부정확 서술·사용자 질의 불가 때문에 폐기됨). `tools/`의 기계 점검과 짝을 이루는 의미론적 점검 계층 | | `agent-memory/` | **[2026-08-16 신설]** 서브에이전트가 라운드를 넘겨 유지하는 영속 메모리(`agent-memory/<에이전트 이름>/MEMORY.md`가 색인). 지금은 `quad-doc-auditor/` 하나 — 코퍼스 구조, 반복되는 실패 패턴 등을 기억해 감사 라운드마다 처음부터 파악하지 않게 함. **사람이 손으로 채우는 문서가 아니라 에이전트가 스스로 쓰는 것**이지만, `.gitignore` 대상이 아니라 커밋하면 코퍼스 일부가 되고 `doc-check.py` 검사 대상에도 들어감(감사 대상이기도 하다는 뜻 — 여기 적힌 주장도 stale해질 수 있음). **[2026-08-16 확정] 커밋해서 추적함**(사용자 결정). 사용자 논거: "실 기록이고 디펜던시도 아니고, 어차피 `SAFETY.md`에 따라 구현 시점에는 컨테이너에서 개발되며 다른 프라이빗 git에 올라가고 검토 후 머징되는거라, 문제되는 메모리 있으면(환경 노출 등) 사람이 감사처리 마지막으로 함. 결국 프로젝트 사이드 기록이고 같이 올려지는게 맞는게, 개발 환경이 다수라서 필요해보임" — 즉 **개발 환경이 여러 개라 메모리가 따라다녀야 하고**, 노출 위험은 머지 전 사람 검토가 최종 방어선. 커밋하는 쪽이 정해졌으니 여기 내용도 감사 대상이다(에이전트가 자기 메모리에 stale한 결론을 남기는 일이 실제로 있었음 — 2026-08-16에 폐기된 워크플로를 살아있는 것처럼 서술한 2건이 감사로 잡힘) | diff --git a/.claude/audit/handtrace-round10-reference-impl/README.md b/.claude/audit/handtrace-round10-reference-impl/README.md new file mode 100644 index 0000000..d010edf --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/README.md @@ -0,0 +1,98 @@ +# 10라운드 참고 구현 — `H-107`~`H-149` 계약으로 갱신한 스파이크 모음 + +**[2026-08-28 신설]** `audit/handtrace-round7-reference-impl/`(7라운드)와 9라운드 +스크래치(`core9.luau`/`dispatch9.luau`, 지금은 여기 같이 둠)를 **커밋 `0ec22fb` +시점의 `base/`**(8·9라운드 반영 + `H-143`~`H-146`)로 옮긴 것. +발견 보고는 `qa-request/pre-implementation-handtrace-round10.md`(§5/§7이 이 폴더를 +가리킨다). 이 README는 **무엇을 갱신했고 어디서 결과가 나왔는지**만 적는다 — +결정의 소스가 아니다. + +실행: `cd spikes && luau <파일>.luau` (타입 스파이크 `ty*`는 `luau-analyze`). +`luau`는 `~/.local/share/mise/installs/luau/latest`(mise). 저장소 코드는 전혀 +`require`하지 않는다 — 참고 구현은 `base/`의 의사코드를 손으로 옮긴 것이다. + +## 폴더 구성 + +| 파일 | 무엇 | +|---|---| +| `spikes/core10.luau` | **반응형 코어 참고 구현(이번 라운드)** — 아래 "계약 갱신" 표 | +| `spikes/dispatch10.luau` | **디스패치 부기 + Slot 참고 구현(이번 라운드)** | +| `spikes/core.luau` / `dispatch.luau` / `chain.luau` | 7라운드 원본(그대로 복사, `cp -n`). `chain.luau`(하강 diff 체인)는 `d23`이 그대로 재사용 | +| `spikes/core9.luau` / `dispatch9.luau` | 9라운드 스크래치 원본(그대로) | +| `spikes/t14`~`t24`, `d20`~`d23`, `ty11` | **이번 라운드 신규** — 아래 결과 표 | +| `spikes/d10`/`d11`/`d13`/`d14` `_r10` | 9라운드 `H-124` 스파이크를 `sed`로 `dispatch10`/`core10`에 물린 사본 | +| 그 외 `t1`~`t13`, `d1`~`d18`, `g*`, `e1`, `ty1`~`ty10` | 7·9라운드 원본 그대로(옛 `core`/`dispatch`/`core9`를 `require`함 — 이번에 다시 돌리지 않았고 결과도 그쪽 README/파일이 소스) | + +## 계약 갱신 — round7 `core.luau`/ref9 `core9.luau` 대비 `core10.luau`가 바꾼 것 + +| 항목 | 옛 참고 구현 | `core10.luau` (근거) | +|---|---|---| +| `EpochMap` | `Update`/`Refresh`/`Sync`/`TrackFrom` | + **`Peek`**(`GateNode:_receive`의 emit 판정, `H-124` 이전 7라운드 확정) | +| Observer `fn` | 2-인자 | **3-인자 `fn(targetState, self, emitFrom)`** + `observer._state` 강참조(`H-109`/`H-110`) | +| Observer 진입점 | `Subscribe`/`Unsubscribe` | 넷 — `WeakSubscribe`(프리미티브, `.Subscribed = true`, `H-111`)/`Subscribe`(위임 — `H-149` 그대로 재현하려고 **일부러** 안 고침)/`WeakUnsubscribe`(관대, `H-133`)/`Unsubscribe`(엄격) | +| `Ref` | `Callbacks` 배열 | 해시맵 셋 + **`WeakCallbacks`**(weak-키) + `Uncallback`, `:Set` 순서 값 → 리비전 → 콜백(스냅샷, 교차 dedup), 콜백 `k(value, self)`(`H-107`/`H-108`) | +| `Effect` 생성자 | dep마다 바인드 시 등록 | **생성자에서 한 번**, `_blocker:On()` … `OffWithoutEmit()`, dep 종류별 클로저 둘(`onRefFire(_, ref)` / `onStateFire(_, _, from)`), 공통 `fire`: `canExecute` → `_blocker:IsOn` → `_epochs:Update` → `Rerun`. `M.effectTrace` 훅으로 가드 판정을 기록할 수 있다(`t18`) | +| `_bindDestroying` | 없음/부분 | `_unbindDestroying()` 선행 → `onDestroying` 연결 → **`Refresh()` 먼저** → `not _installed or depsChanged → Rerun` | +| `Rerun` | 단순 | 루프 머리 `_consumeCleanup` → `fn` → 꼬리 `wasAlive and not canExecute` 즉시 소진 + `_pending` 드롭(`H-143`) | +| `Effect` 진입점 | 없음 | 네 개 + `resubscribeTail`(`H-144` (b)) | +| `GateNode` | `_receive` `Update`만 | `Update` + `Peek`, 배치 **unfold**해서 `_withheld`에, `_flush(commit) -> boolean`, 빈 배치 무시 | +| `Blocker` | 강한 핸들 목록 | weak 핸들 집합(`H-63`), 강한 주인은 `onUpstreamEmit` 클로저(`Policy(emit)`가 돌려준 핸들을 업밸류로) | +| `state:Block` | 별도 노드 | `Gate(self, function(emit) return blocker:Policy(emit) end)` | +| 생명주기 mock | `bindLifetime`만 | `isEffect(value)`면 `_bindDestroying(inst)`/`_unbindDestroying()`, `onDestroying(inst, fn) -> {Connected, Disconnect}`, `destroyInst`는 **Immediate** 모드(콜백 동기 실행 후 `conn.Connected = false`) | + +`dispatch10.luau`가 `dispatch9.luau` 대비 바꾼 것: `bk.indexOfElement`를 **weak-키**로 +(`H-145`), `recompute`의 되감기 판정을 `lengthList[i]` 읽기 **앞**으로(`H-124`, +`continue`), Slot 생성자에서 `Length`/`Offset`/`_baseObserver` 생성 + `_destroyed` +(9라운드 Q2), `materializeSlotTree` 순서(blocker On → `_baseObserver` bind → +`setOffsetSource`), `reconcile`의 `ownsGate` 배치(`H-136`), `mountTop`의 retractor가 +`setLength(inst, k, 0)`을 **요소 없이** 부름(`H-145`의 해제 경로), `rawMove`는 +`H-29` 규약(배열 permute, `N` 불변, `minPos - 1` 무효화, 이동 범위 `setLength` +재등록)을 탐사자가 옮긴 것(base에 의사코드 없음 — 검증 대상이 아니라 도구). + +## 옮기며 고친 것 (참고 구현 자체의 오류 — 발견 번호 없음) + +1. **`_recompute`의 첫 인자.** round7 `core.luau`/ref9 `core9.luau`는 + `fn(resultNode, cache, deps…)`처럼 **결과 노드**를 첫 인자로 넘겼다. base + (`source-state-plan.md`)는 `fn(self, previous?, ...deps)`의 `self`가 **리시버의 + lazy 핸들**이다. 옛 스파이크들은 첫 인자를 안 써서 무해했으나, `t16`/`t17`처럼 + `fn` 안에서 `s:Get()`을 부르면 결과 노드 자신을 다시 계산해 **스택 오버플로**. + `core10.luau`는 `self.fn(self._hold[1], self.cache, table.unpack(self._hold, 2))`. +2. **`elementOwner`의 값 모드.** 처음 옮긴 `dispatch10`은 값을 강하게 잡아 + weak-키 → 값 → 키 순환(Luau엔 ephemeron이 없다, `relate-plan.md`)으로 옛 + Slot이 GC되지 않았다(`d20` 첫 실행). `slot-plan.md`가 요소 소유권 절에서 전부 `SetWeak`이라 못박은 대로 + `__mode = "kv"`로 고쳤다. +3. **`isState`에 `GateNode` 포함.** `core10.luau`의 `isState`는 `Source`/`State`/ + `GateNode` 메타테이블을 직접 나열한다 — base엔 그 등록이 없다. 그게 + `H-152`다(`t24`가 빼면 어떻게 되는지 재현). +4. 브랜드는 `Brand` 대신 **메타테이블 identity**로 판별한다(스파이크 단순화) — + `t24`는 이 단순화를 이용해 "브랜드 누락"을 흉내낸다. + +## 결과 (2026-08-28, 전부 종료 코드 0) + +| 스파이크 | 확인 대상 | 결과 → 발견 | +|---|---|---| +| `t14_effect_state_matrix` | 네 진입점 × 상태 전이표 | 문서 표와 일치 | +| `t15_rerun_dead_handle` | `H-147` 케이스 1·2·3 | 재현. 케이스 3만 `BindData` 죽은 gcconn으로 `wasAlive` 참 | +| `t16_gate_resub_refresh` | 게이트 × 재구독/재바인드 × `Refresh`/`Peek` 7건 | 1·2(캐치업 우회)·4(형제 dep) → `H-151`; 나머지 확정대로 | +| `t17_hold_gc` | `_hold` 불변식 | 양성/음성 확정대로 | +| `t18_blocker_vestigial_and_set_same` | `Effect._blocker` / 같은 값 `Set` | `drop:canExecute` 3, `drop:blocker` **0** → `H-150` | +| `t19_observer_level2` | `H-149` | `Subscribe`만 내부 줄 blame — 재현 | +| `t22_fn_destroys_inst` | `H-143` 동기 자기 파괴 | cleanup 즉시 1회 | +| `t23_store_reserved_runtime` | `Of("Of")`/`defaults.Of` × 구현 (I)/(II) | 둘 다 깨짐 → `H-153` | +| `t24_gate_brand_missing` | 브랜드 누락 | 발화 2 → 0, `Get` 정상 → `H-152` | +| `ty11_store_final`(analyze) | 최종형 Store 타입 + 빈 Store | 진단: 56행 예약 키(H), 43/45/46/52행 음성; 나머지 0 → `H-157` | +| `d10`/`d11`/`d13`/`d14` `_r10` | `H-124` | 통과 | +| `d20_indexOfElement_weak` | `H-145` | 키 수거 확인(테이블 키 한정 — `Instance`는 CLI 밖) | +| `d21_reconcile_batch` | `H-136` + 중첩 Slot | 사이클당 `recompute(L)` 1, 물리 op 최소 | +| `d22_detach_keygone` | `Detach`/`KeyGone` | 확정대로 | +| `d23_instancechild_spurious` | `InstanceChildHandler` 같은 값 | `Parent` nil→inst 2회, `recompute` 2 → `H-154` | + +## 한계 + +- 물리 층은 전부 mock(`D.native` 로그) — Roblox `Instance`의 weak 키 거동, + `SignalBehavior = Deferred`, `ChildAdded`류 이벤트 횟수는 여기서 볼 수 없다 + (발견 보고 §6). +- `Debounce`/`Throttle`은 옮기지 않았다(7절이 무효화 배너 아래라 확정 의사코드가 + 없음). 게이트 정책은 `Blocker:Policy`까지만. +- `Dispatch.drive`/`New` 파이프라인(`H-139`)은 옮기지 않았다 — `d23`은 round7 + `chain.luau`의 체인만 쓴다. diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/chain.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/chain.luau new file mode 100644 index 0000000..9251dd2 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/chain.luau @@ -0,0 +1,46 @@ +--!nocheck +-- base/dispatch-core-plan.md "Dispatch 체인" 절의 확정 의사코드 그대로 +local C = {} +local chains = {} +local NOOP = function() end +local handlers = {} +function C.addHandler(h) table.insert(handlers, h) end +local function getHandler(inst, k, v) + for _, h in handlers do if h.isHandlable(inst, k, v) then return h end end + error("Dispatch: 매치되는 핸들러 없음") +end +function C.process(inst, k, v, index) + local list = chains[inst] and chains[inst][k] + if not list then + list = {} + chains[inst] = chains[inst] or {} + chains[inst][k] = list + end + local slot = list[index] + local h = getHandler(inst, k, v) + if slot ~= nil and slot.handler == h then + slot.retractor(v) + slot.retractor = NOOP + local retractor = h.process(inst, k, v, index) + if retractor == nil then error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가") end + slot.retractor = retractor + else + C.retractFrom(inst, k, index) + list[index] = { handler = h, retractor = NOOP } + local retractor = h.process(inst, k, v, index) + if retractor == nil then error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가") end + list[index] = { handler = h, retractor = retractor } + end +end +function C.retractFrom(inst, k, index) + local list = chains[inst] and chains[inst][k] + if not list then return end + for i = #list, index, -1 do + local slot = list[i] + if slot == nil then error("Dispatch: 인덱스 " .. i .. "에 슬롯이 없음 — 배열에 구멍이 뚫렸음") end + slot.retractor(nil) + list[i] = nil + end +end +C.dump = function(inst, k) return chains[inst] and chains[inst][k] end +return C diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/core.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/core.luau new file mode 100644 index 0000000..451c5c3 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/core.luau @@ -0,0 +1,209 @@ +--!nocheck +-- 문서(base/state-epoch-plan.md §2~§5, base/source-state-plan.md 전파 모델, +-- base/gate-plan.md 4/8번, base/blocker-plan.md)를 그대로 옮긴 참조 구현. +-- 방어 코드(pcall 등)는 문서에 없으므로 넣지 않는다. + +local M = {} + +local function newWeakK() return setmetatable({}, {__mode = "k"}) end + +-- ── EpochMap ──────────────────────────────────────────────────────────── +local EpochMap = {} +EpochMap.__index = EpochMap +function M.EpochMap() + return setmetatable({map = newWeakK()}, EpochMap) +end +local function unfold(from) + -- Epoch 하나면 {from}, EpochSet이면 그 키들 + if from.Revision ~= nil then return {from} end + local t = {} + for e in from do t[#t+1] = e end + return t +end +M.unfold = unfold +function EpochMap:Update(from) + local changed = false + for _, e in unfold(from) do + if self.map[e] ~= e.Revision then + changed = true + self.map[e] = e.Revision + end + end + return changed +end +function EpochMap:Sync(from) + for _, e in unfold(from) do self.map[e] = e.Revision end +end +function EpochMap:Refresh() + local changed = false + for e, rev in self.map do + if e.Revision ~= rev then changed = true; self.map[e] = e.Revision end + end + return changed +end +function EpochMap:TrackFrom(other) + for e in other.map do self.map[e] = e.Revision end +end +-- 문서에 없는 연산(H-72). 게이트 판정용으로만 쓰고, 쓸 때마다 표시한다. +function EpochMap:PeekDiffers(from) + for _, e in unfold(from) do + if self.map[e] ~= e.Revision then return true end + end + return false +end + +-- ── 전파 ──────────────────────────────────────────────────────────────── +local function emitDown(node, from) + -- H-23 확정: 구독자 집합을 배열로 스냅샷한 뒤 돈다 + local snapshot = {} + for sub in node.subs do snapshot[#snapshot+1] = sub end + for _, sub in snapshot do + sub:_receive(from) + end +end +M.emitDown = emitDown + +-- ── Source ────────────────────────────────────────────────────────────── +local Source = {} +Source.__index = Source +function M.Source(v) + return setmetatable({value = v, Revision = 0, subs = newWeakK()}, Source) +end +function Source:Get() return self.value end +function Source:Set(v) + self.value = v + self.Revision = bit32.bnot(-self.Revision) + emitDown(self, self) +end +function Source:_track(map) map:Sync(self) end +function Source:_addSub(s) self.subs[s] = true end + +-- ── State(Compute/With) ───────────────────────────────────────────────── +local State = {} +State.__index = State +function M.State(fn, deps, name) + local self = setmetatable({ + fn = fn, deps = deps, name = name, + valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(), + rawInvalid = true, cache = nil, subs = newWeakK(), + recomputes = 0, + }, State) + for _, d in deps do + d:_addSub(self) + d:_track(self.valueEpochMap) -- Source면 Sync, State면 TrackFrom + end + return self +end +function State:_track(map) map:TrackFrom(self.valueEpochMap) end +function State:_addSub(s) self.subs[s] = true end +function State:_receive(from) + local valueChanged = self.valueEpochMap:Update(from) + local emitChanged = self.emitEpochMap:Update(from) + if valueChanged then self.rawInvalid = true end + if valueChanged or emitChanged then emitDown(self, from) end +end +function State:_recompute() + self.recomputes += 1 + self.cache = self.fn(self, self.cache, table.unpack(self.deps)) + self.rawInvalid = false + for _, d in self.deps do d:_track(self.valueEpochMap) end +end +function State:Get() + if self.rawInvalid then + self:_recompute() + elseif self.valueEpochMap:Refresh() then + self.rawInvalid = true + self:_recompute() + end + return self.cache +end + +-- ── Observer(leaf) ────────────────────────────────────────────────────── +local Observer = {} +Observer.__index = Observer +function M.Observer(state, fn, name) + local self = setmetatable({fn = fn, name = name, fires = 0}, Observer) + state:_addSub(self) + self.owner = state + return self +end +function Observer:_receive(from) + self.fires += 1 + self.fn(self, from) +end + +-- ── GateNode ──────────────────────────────────────────────────────────── +local GateNode = {} +GateNode.__index = GateNode +function M.Gate(upstream, setup, name) + local self = setmetatable({ + up = upstream, name = name, + valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(), + rawInvalid = true, cache = nil, subs = newWeakK(), + withheld = newWeakK(), flushes = 0, + }, GateNode) + upstream:_addSub(self) + upstream:_track(self.valueEpochMap) + local function emit() self:_flush() end + self.onUpstreamEmit = setup(emit) + return self +end +function GateNode:_track(map) map:TrackFrom(self.valueEpochMap) end +function GateNode:_addSub(s) self.subs[s] = true end +function GateNode:_flush() + if next(self.withheld) == nil then return end -- 8번: 빈 배치는 아무것도 안 함 + local batch = self.withheld + self.withheld = newWeakK() -- 진입 시 스왑(H-9) + self.flushes += 1 + self.emitEpochMap:Sync(batch) -- 전파할 때 갱신(§4 예외) + emitDown(self, batch) +end +function GateNode:_discard() -- OffWithoutEmit 경로 + self.withheld = newWeakK() +end +function GateNode:_receive(from) + -- 규칙 1~3을 먼저. 게이트는 emitEpochMap을 수신 시점에 갱신하지 않으므로 + -- 비교만 하는 연산이 필요하다(H-72) — PeekDiffers로 대신한다. + local valueChanged = self.valueEpochMap:Update(from) + local emitDiffers = self.emitEpochMap:PeekDiffers(from) + if valueChanged then self.rawInvalid = true end + if not (valueChanged or emitDiffers) then return end -- 규칙 3: 삼킴 + for _, e in unfold(from) do self.withheld[e] = true end + self.onUpstreamEmit() +end +function GateNode:Get() + if self.rawInvalid then + self.cache = self.up:Get() + self.rawInvalid = false + self.up:_track(self.valueEpochMap) + elseif self.valueEpochMap:Refresh() then + self.cache = self.up:Get() + self.up:_track(self.valueEpochMap) + end + return self.cache +end + +-- ── Blocker ───────────────────────────────────────────────────────────── +local Blocker = {} +Blocker.__index = Blocker +function M.Blocker() + return setmetatable({IsBlocked = false, handles = {}}, Blocker) +end +function Blocker:IsOn() return self.IsBlocked end +function Blocker:On() self.IsBlocked = true; return self end +function Blocker:_run(emit) + for _, h in self.handles do h(emit) end +end +function Blocker:Off() self.IsBlocked = false; self:_run(true); return self end +function Blocker:OffWithoutEmit() self.IsBlocked = false; self:_run(false); return self end +-- blocker:Policy(emit) -> onUpstreamEmit +function Blocker:Policy(emit, discard) + table.insert(self.handles, function(doEmit) + if doEmit then emit() else (discard or function() end)() end + end) + return function() + if not self.IsBlocked then emit() end + end +end + +return M diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/core10.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/core10.luau new file mode 100644 index 0000000..7866391 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/core10.luau @@ -0,0 +1,476 @@ +--!nocheck +-- 10라운드 참조 구현 — HEAD `0ec22fb`(2026-08-28) 시점 `base/`를 옮긴 전사물. +-- 출발점은 9라운드 전사물 `core9.luau`(같은 폴더에 원본 보존)이고, 거기에 +-- `H-143`~`H-149` 시점 계약을 얹었다: +-- · effect-plan.md — `Rerun` 꼬리 `wasAlive and not canExecute` + `_pending` 버림(H-143), +-- 네 진입점 `EffectHandle` 자기 것 + `resubscribeTail`(H-144 (b)), +-- 생성자 `_blocker`, `fire` 가드 순서, `_bindDestroying` 캐치업(Refresh 먼저) +-- · lifecycle-pattern.md (2) — Observer 네 진입점(`Subscribe`→`WeakSubscribe` 콜론 위임 **그대로**, +-- H-149가 판단 대기라 원문대로 둔다), `WeakUnsubscribe` 관대(H-133) +-- · gate-plan.md 4번/GateNode 조립 — `_receive`(valueEpochMap:Update + emitEpochMap:**Peek**), +-- `_flush(commit)` 네 단계 + 반환값, `newWeakK` 스왑(H-9), unfold 합류 +-- · blocker-plan.md/H-63 — onunblock 핸들 weak-키 셋, 강한 주인은 `onUpstreamEmit` 클로저, +-- `Off`/`OffWithoutEmit` 스냅샷 순회, `Policy(emit)` +-- · state-epoch-plan.md §3/§4 — EpochMap(`Update`/`Peek`/`Refresh`/`Sync`/`TrackFrom`), +-- 캐시 카운터 쌍, `_hold`(하류→상류 강함), 구독자 weak-키 +-- · source-state-plan.md — `_emitDown` 스냅샷, Observer 3-인자 + `_state`, `:With` pass-through, +-- `Source:Set` 동일값도 emit(H-68) +-- · ref-plan.md — `:Set` 값→리비전→콜백, 두 테이블 스냅샷·dedup, `WeakCallback`/`Callback`/`Uncallback` +-- 엔진 부재: mock inst = `{conn = {Connected = bool}, destroying = {}}` (H-97 mock 4종), +-- `onDestroying`은 `inst.destroying` 배열에 콜백을 넣는 것으로 흉내낸다. +-- ⚠️ 이 파일은 문서가 아니라 전사물이다 — 여기서 나온 결과는 원문과 줄 단위 대조 뒤에만 발견이다. +local M = {} +local function weakK() return setmetatable({}, {__mode = "k"}) end +M.weakK = weakK + +-- ── EpochMap (state-epoch-plan.md §3) ─────────────────────────────────── +local EpochMap = {}; EpochMap.__index = EpochMap +function M.EpochMap() return setmetatable({map = weakK()}, EpochMap) end +local function isEpochLike(x) return type(x) == "table" and x.Revision ~= nil end +local function unfold(from) + if isEpochLike(from) then return {from} end + local t = {}; for e in pairs(from) do t[#t+1] = e end; return t +end +M.unfold = unfold +function EpochMap:Update(from) + local changed = false + for _, e in ipairs(unfold(from)) do + if self.map[e] ~= e.Revision then changed = true; self.map[e] = e.Revision end + end + return changed +end +function EpochMap:Peek(from) -- H-72: 비교만, 덮지 않음 + for _, e in ipairs(unfold(from)) do if self.map[e] ~= e.Revision then return true end end + return false +end +function EpochMap:Sync(from) for _, e in ipairs(unfold(from)) do self.map[e] = e.Revision end end +function EpochMap:Refresh() + local changed = false + for e, rev in pairs(self.map) do + if e.Revision ~= rev then changed = true; self.map[e] = e.Revision end + end + return changed +end +function EpochMap:TrackFrom(other) for e in pairs(other.map) do self.map[e] = e.Revision end end +function EpochMap:_keys() local t = {}; for e in pairs(self.map) do t[#t+1] = e end; return t end + +-- ── 판별자 (brand-plan.md의 isX — 여기선 메타테이블 항등으로 흉내) ────── +local Source, State, GateNode, Observer, Ref, EffectHandle = {}, {}, {}, {}, {}, {} +Source.__index, State.__index, GateNode.__index, Observer.__index, Ref.__index, EffectHandle.__index = + Source, State, GateNode, Observer, Ref, EffectHandle +local function isSource(x) return getmetatable(x) == Source end +local function isState(x) local mt = getmetatable(x); return mt == Source or mt == State or mt == GateNode end +local function isObserver(x) return getmetatable(x) == Observer end +local function isRef(x) return getmetatable(x) == Ref end +local function isEffect(x) return getmetatable(x) == EffectHandle end +local function isEpoch(x) return isSource(x) or isRef(x) end +M.isSource, M.isState, M.isObserver, M.isRef, M.isEffect, M.isEpoch = + isSource, isState, isObserver, isRef, isEffect, isEpoch + +-- ── 생명주기 (lifecycle-pattern.md (1)) — mock inst ──────────────────── +local BindData = weakK() -- value -> {gchold=, gcconn=} +M.BindData = BindData +local function isBoundAlive(value) + local bd = BindData[value] + if bd and bd.gcconn ~= nil and bd.gcconn.Connected then return true end + if isObserver(value) or isEffect(value) then return value.Subscribed == true end + return false +end +local function canBound(value) return not isBoundAlive(value) end +local function canExecute(value) return isBoundAlive(value) end +M.canBound, M.canExecute = canBound, canExecute +function M.bindLifetime(inst, value) + if not canBound(value) then + local isGlobal = (isObserver(value) or isEffect(value)) and value.Subscribed == true + error(if isGlobal then "already subscribed" else "already bound to another instance", 2) + end + inst.gchold = inst.gchold or {} + inst.gchold[value] = true + BindData[value] = {gchold = inst.gchold, gcconn = inst.conn} + if isEffect(value) then value:_bindDestroying(inst) end +end +function M.unbindLifetime(value) + if isEffect(value) then value:_unbindDestroying() end + local bd = BindData[value] + if bd and bd.gchold then bd.gchold[value] = nil end + BindData[value] = nil +end +-- mock 엔진: Instance + Destroying 훅(주입 op `onDestroying`) +function M.newInst(name) return {name = name, conn = {Connected = true}, destroying = {}} end +function M.onDestroying(inst, fn) + local conn = {Connected = true} + conn.Disconnect = function(self) self.Connected = false end + inst.destroying[#inst.destroying + 1] = {conn = conn, fn = fn} + return conn +end +-- Immediate 모드 흉내: Destroying 콜백을 동기로 돌린 뒤 커넥션을 끊는다(Roblox 순서). +function M.destroyInst(inst) + local snap = {} + for _, e in ipairs(inst.destroying) do snap[#snap+1] = e end + for _, e in ipairs(snap) do if e.conn.Connected then e.fn() end end + inst.conn.Connected = false + for _, e in ipairs(inst.destroying) do e.conn.Connected = false end + inst.destroying = {} +end + +-- ── 전파 루프 (source-state-plan.md "전파 루프 — 확정 의사코드") ────── +local function emitDown(self, from) + local snap = {} + for sub in pairs(self._subs) do snap[#snap+1] = sub end -- H-23 스냅샷 + for _, sub in ipairs(snap) do + if isState(sub) then + sub:_receive(from) + elseif canExecute(sub) then + sub.fn(sub._state, sub, from) -- (리시버 State, Observer 자신, 출처) H-109 + end + end +end +M.emitDown = emitDown + +-- ── Source ───────────────────────────────────────────────────────────── +function M.Source(v) + return setmetatable({_value = v, Revision = 0, _subs = weakK()}, Source) +end +function Source:Get() return self._value end +function Source:Set(v) -- H-68: 동일값도 항상 리비전 갱신 + emit + self._value = v + self.Revision = bit32.bnot(-self.Revision) + emitDown(self, self) + return self +end +function Source:Emit() + self.Revision = bit32.bnot(-self.Revision) + emitDown(self, self) + return self +end +function Source:_track(map) map:Sync(self) end +function Source:_addSub(s) self._subs[s] = true end + +-- ── State (Compute / With) — 캐시 카운터 쌍 (state-epoch-plan.md §4) ── +local function seedValueMap(self, deps) + for _, d in ipairs(deps) do d:_addSub(self); d:_track(self.valueEpochMap) end +end +local function newNode(deps, fn) + local self = setmetatable({ + fn = fn, _hold = deps, _subs = weakK(), + valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(), + cacheTargetCount = 0, cacheCurrCount = nil, cache = nil, recomputes = 0, + }, State) + seedValueMap(self, deps) + return self +end +function M.Compute(deps, fn) return newNode(deps, fn) end +function State:_track(map) map:TrackFrom(self.valueEpochMap) end +function State:_addSub(s) self._subs[s] = true end +function State:_invalidate() self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount) end +function State:_receive(from) + local valueChanged = self.valueEpochMap:Update(from) + local emitChanged = self.emitEpochMap:Update(from) + if valueChanged then self:_invalidate() end + if valueChanged or emitChanged then emitDown(self, from) end +end +function State:_recompute() + local target = self.cacheTargetCount + self.recomputes += 1 + -- ⚠️ [전사 정정] `fn(self, previous?, ...deps)`의 `self`는 **리시버 State의 lazy 핸들**이지 결과 노드가 + -- 아니다(source-state-plan.md "self 인자도 lazy 핸들로 통일"). 7·9라운드 전사물은 결과 노드를 넘겨 + -- `s:Get()`이 자기 자신을 재귀하게 돼 있었다(그쪽 테스트는 첫 인자를 안 써서 안 드러났다). + self.cache = self.fn(self._hold[1], self.cache, table.unpack(self._hold, 2)) + self.cacheCurrCount = target + for _, d in ipairs(self._hold) do d:_track(self.valueEpochMap) end +end +function State:Get() + if self.cacheCurrCount ~= self.cacheTargetCount then + self:_recompute() + elseif self.valueEpochMap:Refresh() then -- 순회: 값만 앞당기고 통지는 안 한다 + self:_invalidate() + self:_recompute() + end + return self.cache +end +-- 파생 메소드(콜론) — Source/State/GateNode 공용 +local function Compute(self, fn, ...) + local deps = {self} + for i = 1, select("#", ...) do deps[#deps+1] = (select(i, ...)) end + return newNode(deps, fn) +end +local function With(self, ...) + local deps = {self} + for i = 1, select("#", ...) do deps[#deps+1] = (select(i, ...)) end + return newNode(deps, function(s) return s:Get() end) -- pass-through(값은 self 그대로) +end + +-- ── Observer + 네 진입점 레지스트리 (lifecycle-pattern.md (2)) ────────── +local Subscribed = {} +local WeakSubscribed = weakK() +M.Subscribed, M.WeakSubscribed = Subscribed, WeakSubscribed +local function newObserver(state, fn) + fn = fn or function(targetState) targetState:Get() end -- H-61 + local self = setmetatable({fn = fn, _state = state}, Observer) -- H-110 강참조 + state:_addSub(self) + fn(state, self, nil) -- 등록 즉시 1회(설치 발화, emitFrom = nil) + return self +end +function Observer:WeakSubscribe() + if not canBound(self) then + error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2) + end + self.Subscribed = true -- H-111 + WeakSubscribed[self] = true + return self +end +function Observer:WeakUnsubscribe() + if Subscribed[self] ~= nil then error("subscribed strongly; use :Unsubscribe()", 2) end + WeakSubscribed[self] = nil -- 관대(H-133): 없어도 그냥 지나간다 + self.Subscribed = false + return self +end +function Observer:Subscribe() + self:WeakSubscribe() -- ⚠️ 콜론 위임 그대로(H-149 판단 대기) — level 2가 이 줄을 가리킨다 + Subscribed[self] = true + return self +end +function Observer:Unsubscribe() + if Subscribed[self] == nil then error("not subscribed strongly; use :WeakUnsubscribe()", 2) end + Subscribed[self] = nil + return self:WeakUnsubscribe() +end + +-- ── GateNode (gate-plan.md "GateNode 조립") ───────────────────────────── +function GateNode:_track(map) map:TrackFrom(self.valueEpochMap) end +function GateNode:_addSub(s) self._subs[s] = true end +function GateNode:_invalidate() self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount) end +function GateNode:_flush(commit) + if next(self._withheld) == nil then return false end -- (1) 빈 배치 얼리리턴(8번) + local batch = self._withheld + self._withheld = weakK() -- (2) 새 weak 테이블(H-9) + self.flushes += 1 + if commit == false then return true end -- 버리기(H-55) + self.emitEpochMap:Sync(batch) -- (3) 전파 앞 + emitDown(self, batch) -- (4) + return true +end +function GateNode:_receive(from) + local valueChanged = self.valueEpochMap:Update(from) + local emitChanged = self.emitEpochMap:Peek(from) -- Update가 아니라 Peek + if valueChanged then self:_invalidate() end + if not (valueChanged or emitChanged) then return end -- 규칙 3 + for _, e in ipairs(unfold(from)) do self._withheld[e] = true end + self.onUpstreamEmit() +end +function GateNode:Get() + if self.cacheCurrCount ~= self.cacheTargetCount then + local target = self.cacheTargetCount + self.cache = self._hold[1]:Get(); self.cacheCurrCount = target + self._hold[1]:_track(self.valueEpochMap) + elseif self.valueEpochMap:Refresh() then + self:_invalidate(); local target = self.cacheTargetCount + self.cache = self._hold[1]:Get(); self.cacheCurrCount = target + self._hold[1]:_track(self.valueEpochMap) + end + return self.cache +end +local function Gate(self, setup) + local node = setmetatable({ + _hold = {self}, _subs = weakK(), + valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(), + cacheTargetCount = 0, cacheCurrCount = nil, cache = nil, + _withheld = weakK(), flushes = 0, + }, GateNode) + self:_addSub(node); self:_track(node.valueEpochMap) + node.onUpstreamEmit = setup(function(commit) return node:_flush(commit) end) + return node +end + +-- ── Blocker (blocker-plan.md + H-63) ──────────────────────────────────── +local Blocker = {}; Blocker.__index = Blocker +function M.Blocker() return setmetatable({IsBlocked = false, handles = weakK()}, Blocker) end +function Blocker:IsOn() return self.IsBlocked end +function Blocker:On() self.IsBlocked = true; return self end +function Blocker:_run(emit) + local snap = {}; for h in pairs(self.handles) do snap[#snap+1] = h end -- H-63 (3) 스냅샷 + for _, h in ipairs(snap) do h(emit) end +end +function Blocker:Off() self.IsBlocked = false; self:_run(true); return self end +function Blocker:OffWithoutEmit() self.IsBlocked = false; self:_run(false); return self end +function Blocker:Policy(emit) -- blocker:Policy(emit) -> onUpstreamEmit + local function handle(doEmit) + if doEmit then emit() else emit(false) end -- OffWithoutEmit = 흡수 집합 버리기(H-55) + end + self.handles[handle] = true -- H-63 (1) weak-키 셋 + return function() -- H-63 (2) 강한 주인 = 이 클로저(upvalue `handle`) + if handle == nil then return end + if not self.IsBlocked then emit() end + end +end +local function Block(self, blocker) + return Gate(self, function(emit) return blocker:Policy(emit) end) +end +for _, T in ipairs({Source, State, GateNode}) do + T.Compute, T.With, T.Gate, T.Block, T.Observer = Compute, With, Gate, Block, newObserver +end +M.Gate = function(up, setup) return Gate(up, setup) end + +-- ── Ref (ref-plan.md `:Set` 순서 절 + WeakCallback 항목) ────────────── +function M.Ref(default) + return setmetatable({Value = default, Revision = 0, Callbacks = {}, WeakCallbacks = weakK()}, Ref) +end +function Ref:WeakCallback(fn) + self.WeakCallbacks[fn] = true + fn(self.Value, self) -- 등록 즉시 1회, nil이어도 그대로(H-120) + return self +end +function Ref:Callback(fn) + self:WeakCallback(fn) + self.Callbacks[fn] = true -- GC 킵 한 겹 + return self +end +function Ref:Uncallback(fn) + self.Callbacks[fn] = nil; self.WeakCallbacks[fn] = nil + return self +end +function Ref:Set(value) + self.Value = value -- (1) + self.Revision = bit32.bnot(-self.Revision) -- (2) 콜백보다 앞 + local snapshot = {} -- (3) + for k in pairs(self.Callbacks) do table.insert(snapshot, k) end + for k in pairs(self.WeakCallbacks) do + if self.Callbacks[k] == nil then table.insert(snapshot, k) end + end + for _, k in ipairs(snapshot) do + if self.Callbacks[k] == nil and self.WeakCallbacks[k] == nil then continue end + if type(k) == "thread" then + self.Callbacks[k] = nil + coroutine.resume(k, self) + else + k(value, self) -- 값 + Ref 자신(= Epoch) + end + end + return self +end + +-- ── Effect (effect-plan.md 생성자 / 훅 / Rerun / 네 진입점) ─────────── +M.effectTrace = nil -- 스파이크가 함수를 넣으면 fire 가드 판정을 기록한다 +function M.Effect(fn, ...) + local self = setmetatable({fn = fn, _deps = {}, _epochs = M.EpochMap()}, EffectHandle) + self._blocker = M.Blocker() + local seen = {} + for i = 1, select("#", ...) do + local d = select(i, ...) + if d == nil then error("Effect: dep #" .. i .. " is nil", 2) end + if not (isState(d) or isSource(d) or isRef(d)) then + error("Effect: dep #" .. i .. " is not a State/Source/Ref", 2) + end + if not seen[d] then seen[d] = true end + end + self._blocker:On() + local function fire(from) -- 공통 본문 + if not canExecute(self) then + if M.effectTrace then M.effectTrace(self, "drop:canExecute", from) end + return + end + if self._blocker:IsOn() then + if M.effectTrace then M.effectTrace(self, "drop:blocker", from) end + return + end + if self._epochs:Update(from) then self:Rerun() end + end + local function onRefFire(_, ref) fire(ref) end + local function onStateFire(_, _, from) fire(from) end + for d in pairs(seen) do + if isRef(d) then + self._deps[d] = onRefFire + d:WeakCallback(onRefFire) + else + local o = d:Observer(onStateFire) + self._deps[d] = o + o:WeakSubscribe() + end + if isEpoch(d) then self._epochs:Sync(d) else self._epochs:TrackFrom(d.valueEpochMap) end + end + self._blocker:OffWithoutEmit() + self:Rerun() -- (2) 설치 — 생성 즉시 1회 + return self +end +function EffectHandle:_bindDestroying(inst) + self:_unbindDestroying() + self._destroyConn = M.onDestroying(inst, function() + self:_unbindDestroying() + self:_consumeCleanup() + end) + local depsChanged = self._epochs:Refresh() -- Refresh **먼저** + if not self._installed or depsChanged then self:Rerun() end +end +function EffectHandle:_unbindDestroying() + if self._destroyConn then self._destroyConn:Disconnect(); self._destroyConn = nil end +end +function EffectHandle:_consumeCleanup() + local c = self._cleanup; self._cleanup = nil; self._installed = false + if c then c() end +end +function EffectHandle:Rerun() + if self._running then self._pending = true; return end + self._running = true + repeat + self._pending = false + self:_consumeCleanup() + local wasAlive = canExecute(self) + local c = self.fn(self) + if wasAlive and not canExecute(self) then -- H-143: 이 실행 중에 죽었다 + if c then c() end + self._pending = false + else + self._cleanup = c + self._installed = true + end + until not self._pending + self._running = false +end +-- 네 진입점 — EffectHandle 자기 것(H-144 (b)). 공유는 레지스트리 둘 + canBound뿐. +local function resubscribeTail(self) + local depsChanged = self._epochs:Refresh() + if not self._installed or depsChanged then self:Rerun() end +end +function EffectHandle:WeakSubscribe() + if not canBound(self) then + error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2) + end + self.Subscribed = true + WeakSubscribed[self] = true + resubscribeTail(self) + return self +end +function EffectHandle:Subscribe() + if not canBound(self) then + error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2) + end + self.Subscribed = true + WeakSubscribed[self] = true + Subscribed[self] = true -- 강한 킵이 선 **뒤에** 꼬리 + resubscribeTail(self) + return self +end +function EffectHandle:WeakUnsubscribe() -- 관대(H-133) — cleanup 안 건드림 + if Subscribed[self] ~= nil then error("subscribed strongly; use :Unsubscribe()", 2) end + WeakSubscribed[self] = nil + self.Subscribed = false + return self +end +function EffectHandle:Unsubscribe() + if Subscribed[self] == nil then error("not subscribed strongly; use :WeakUnsubscribe()", 2) end + Subscribed[self] = nil + WeakSubscribed[self] = nil + self.Subscribed = false + self:_consumeCleanup() + return self +end + +-- 상태 스냅샷(스파이크용) +function M.effectState(e) + return ("installed=%s cleanup=%s Subscribed=%s reg[S]=%s reg[W]=%s canExecute=%s running=%s pending=%s"):format( + tostring(e._installed == true), tostring(e._cleanup ~= nil), tostring(e.Subscribed == true), + tostring(Subscribed[e] ~= nil), tostring(WeakSubscribed[e] ~= nil), tostring(canExecute(e)), + tostring(e._running == true), tostring(e._pending == true)) +end + +return M diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/core9.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/core9.luau new file mode 100644 index 0000000..88d85ca --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/core9.luau @@ -0,0 +1,307 @@ +--!nocheck +-- 9라운드 참조 구현 — 2026-08-26 `9dd8213` 이후 계약을 base/에서 옮긴 전사물. +-- 옮긴 원문: source-state-plan.md(전파 루프 `_emitDown`, Observer 3-인자, `_state`), +-- lifecycle-pattern.md((1) isBoundAlive/bindLifetime/unbindLifetime, (2) Weak/Subscribe 4진입점), +-- ref-plan.md(`:Set` 순서 값→리비전→콜백, Callbacks/WeakCallbacks 스냅샷·dedup, Uncallback), +-- effect-plan.md(생성자 fire/onRefFire/onStateFire, _bindDestroying, Rerun, Subscribe/Unsubscribe), +-- state-epoch-plan.md(EpochMap, 캐시 카운터 쌍), blocker-plan.md(On/Off/OffWithoutEmit/IsOn). +-- 엔진 부재: bindLifetime은 mock inst `{conn = {Connected = bool}}`의 gcconn을 복사한다(H-97 mock 4종). +local M = {} +local function weakK() return setmetatable({}, {__mode = "k"}) end + +-- ── EpochMap ─────────────────────────────────────────────────────────── +local EpochMap = {}; EpochMap.__index = EpochMap +function M.EpochMap() return setmetatable({map = weakK()}, EpochMap) end +local function unfold(from) + if from.Revision ~= nil then return {from} end + local t = {}; for e in pairs(from) do t[#t+1] = e end; return t +end +function EpochMap:Update(from) + local changed = false + for _, e in ipairs(unfold(from)) do + if self.map[e] ~= e.Revision then changed = true; self.map[e] = e.Revision end + end + return changed +end +function EpochMap:Peek(from) + for _, e in ipairs(unfold(from)) do if self.map[e] ~= e.Revision then return true end end + return false +end +function EpochMap:Sync(from) for _, e in ipairs(unfold(from)) do self.map[e] = e.Revision end end +function EpochMap:Refresh() + local changed = false + for e, rev in pairs(self.map) do + if e.Revision ~= rev then changed = true; self.map[e] = e.Revision end + end + return changed +end +function EpochMap:TrackFrom(other) for e in pairs(other.map) do self.map[e] = e.Revision end end + +-- ── 판별자 ───────────────────────────────────────────────────────────── +local Source, State, Observer, Ref, EffectHandle = {}, {}, {}, {}, {} +Source.__index, State.__index, Observer.__index, Ref.__index, EffectHandle.__index = + Source, State, Observer, Ref, EffectHandle +local function isSource(x) return getmetatable(x) == Source end +local function isState(x) return getmetatable(x) == Source or getmetatable(x) == State end +local function isObserver(x) return getmetatable(x) == Observer end +local function isRef(x) return getmetatable(x) == Ref end +local function isEffect(x) return getmetatable(x) == EffectHandle end +local function isEpoch(x) return isSource(x) or isRef(x) end +M.isSource, M.isState, M.isObserver, M.isRef, M.isEffect, M.isEpoch = + isSource, isState, isObserver, isRef, isEffect, isEpoch + +-- ── 생명주기 (lifecycle-pattern.md (1)) — mock inst = {conn = {Connected = true}} ── +local BindData = weakK() -- value -> {gchold=, gcconn=} +local function isBoundAlive(value) + local bd = BindData[value] + if bd and bd.gcconn ~= nil and bd.gcconn.Connected then return true end + if isObserver(value) or isEffect(value) then return value.Subscribed == true end + return false +end +local function canBound(value) return not isBoundAlive(value) end +local function canExecute(value) return isBoundAlive(value) end +M.canBound, M.canExecute = canBound, canExecute +function M.bindLifetime(inst, value) + if not canBound(value) then + local isGlobal = (isObserver(value) or isEffect(value)) and value.Subscribed == true + error(if isGlobal then "already subscribed" else "already bound to another instance", 2) + end + inst.gchold = inst.gchold or {} + inst.gchold[value] = true + BindData[value] = {gchold = inst.gchold, gcconn = inst.conn} + if isEffect(value) then value:_bindDestroying(inst) end +end +function M.unbindLifetime(value) + if isEffect(value) then value:_unbindDestroying() end + local bd = BindData[value] + if bd and bd.gchold then bd.gchold[value] = nil end + BindData[value] = nil +end +function M.newInst(name) return {name = name, conn = {Connected = true}, destroying = {}} end +function M.destroyInst(inst) + inst.conn.Connected = false + for _, fn in ipairs(inst.destroying) do fn() end +end + +-- ── 전파 루프 (source-state-plan.md "전파 루프 — 확정 의사코드") ────── +local function emitDown(self, from) + local snap = {} + for sub in pairs(self._subs) do snap[#snap+1] = sub end -- H-23 스냅샷 + for _, sub in ipairs(snap) do + if isState(sub) then + sub:_receive(from) + elseif canExecute(sub) then + sub.fn(sub._state, sub, from) -- ⭐ (리시버 State, Observer 자신, 출처) H-109 + end + end +end + +-- ── Source ───────────────────────────────────────────────────────────── +function M.Source(v) + return setmetatable({_value = v, Revision = 0, _subs = weakK()}, Source) +end +function Source:Get() return self._value end +function Source:Set(v) + self._value = v + self.Revision = bit32.bnot(-self.Revision) + emitDown(self, self) + return self +end +function Source:_track(map) map:Sync(self) end +function Source:_addSub(s) self._subs[s] = true end + +-- ── State (Compute) — 캐시 카운터 쌍 (state-epoch-plan.md §4, H-85) ──── +function M.Compute(deps, fn) + local self = setmetatable({ + fn = fn, _hold = deps, _subs = weakK(), + valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(), + cacheTargetCount = 0, cacheCurrCount = nil, cache = nil, + }, State) + for _, d in ipairs(deps) do d:_addSub(self); d:_track(self.valueEpochMap) end + return self +end +function State:_track(map) map:TrackFrom(self.valueEpochMap) end +function State:_addSub(s) self._subs[s] = true end +function State:_receive(from) + local valueChanged = self.valueEpochMap:Update(from) + local emitChanged = self.emitEpochMap:Update(from) + if valueChanged then self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount) end + if valueChanged or emitChanged then emitDown(self, from) end +end +function State:Get() + if self.cacheCurrCount ~= self.cacheTargetCount or self.valueEpochMap:Refresh() then + if self.cacheCurrCount == self.cacheTargetCount then + self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount) + end + local target = self.cacheTargetCount + self.cache = self.fn(self, self.cache) + self.cacheCurrCount = target + for _, d in ipairs(self._hold) do d:_track(self.valueEpochMap) end + end + return self.cache +end +Source.Compute = function(self, fn) return M.Compute({self}, fn) end +State.Compute = Source.Compute + +-- ── Observer + 4진입점 레지스트리 (lifecycle-pattern.md (2)) ─────────── +local Subscribed = {} +local WeakSubscribed = weakK() +local function newObserver(state, fn) + fn = fn or function(targetState) targetState:Get() end -- H-61 + local self = setmetatable({fn = fn, _state = state}, Observer) -- H-110 강참조 + state:_addSub(self) + fn(state, self, nil) -- 등록 즉시 1회(설치 발화, emitFrom = nil) + return self +end +Source.Observer, State.Observer = newObserver, newObserver +function Observer:WeakSubscribe() + if not canBound(self) then + error(if self.Subscribed then "already subscribed" else "already bound to an instance", 2) + end + self.Subscribed = true -- H-111 + WeakSubscribed[self] = true + return self +end +function Observer:WeakUnsubscribe() + if Subscribed[self] ~= nil then error("subscribed strongly; use :Unsubscribe()", 2) end + WeakSubscribed[self] = nil + self.Subscribed = false + return self +end +function Observer:Subscribe() + self:WeakSubscribe() + Subscribed[self] = true + return self +end +function Observer:Unsubscribe() + if Subscribed[self] == nil then error("not subscribed strongly; use :WeakUnsubscribe()", 2) end + Subscribed[self] = nil + return self:WeakUnsubscribe() +end + +-- ── Ref (ref-plan.md `:Set` 순서 절 + WeakCallback 항목) ────────────── +function M.Ref(default) + return setmetatable({Value = default, Revision = 0, Callbacks = {}, WeakCallbacks = weakK()}, Ref) +end +function Ref:WeakCallback(fn) + self.WeakCallbacks[fn] = true + fn(self.Value, self) -- 등록 즉시 1회, nil이어도 그대로(H-120) + return self +end +function Ref:Callback(fn) + self:WeakCallback(fn) + self.Callbacks[fn] = true -- GC 킵 한 겹 + return self +end +function Ref:Uncallback(fn) + self.Callbacks[fn] = nil; self.WeakCallbacks[fn] = nil + return self +end +function Ref:Set(value) + self.Value = value -- (1) + self.Revision = bit32.bnot(-self.Revision) -- (2) 콜백보다 앞 + local snapshot = {} -- (3) + for k in pairs(self.Callbacks) do table.insert(snapshot, k) end + for k in pairs(self.WeakCallbacks) do + if self.Callbacks[k] == nil then table.insert(snapshot, k) end + end + for _, k in ipairs(snapshot) do + if self.Callbacks[k] == nil and self.WeakCallbacks[k] == nil then continue end + if type(k) == "thread" then + self.Callbacks[k] = nil + coroutine.resume(k, self) + else + k(value, self) -- ⭐ 값 + Ref 자신(= Epoch) + end + end + return self +end + +-- ── Effect (effect-plan.md 생성자/훅/Rerun/Subscribe) ───────────────── +function M.Effect(fn, ...) + local self = setmetatable({fn = fn, _deps = {}, _epochs = M.EpochMap()}, EffectHandle) + self._blocker = M.Blocker() + local seen = {} + for i = 1, select("#", ...) do + local d = select(i, ...) + if d == nil then error("Effect: dep #" .. i .. " is nil", 2) end + if not (isState(d) or isSource(d) or isRef(d)) then + error("Effect: dep #" .. i .. " is not a State/Source/Ref", 2) + end + if not seen[d] then seen[d] = true end + end + self._blocker:On() + local function fire(from) + if not canExecute(self) then return end + if self._blocker:IsOn() then return end + if self._epochs:Update(from) then self:Rerun() end + end + local function onRefFire(_, ref) fire(ref) end + local function onStateFire(_, _, from) fire(from) end + for d in pairs(seen) do + if isRef(d) then + self._deps[d] = onRefFire + d:WeakCallback(onRefFire) + else + local o = d:Observer(onStateFire) + self._deps[d] = o + o:WeakSubscribe() + end + if isEpoch(d) then self._epochs:Sync(d) else self._epochs:TrackFrom(d.valueEpochMap) end + end + self._blocker:OffWithoutEmit() + self:Rerun() + return self +end +function EffectHandle:_bindDestroying(inst) + self:_unbindDestroying() + local conn = {Connected = true} + table.insert(inst.destroying, function() + if not conn.Connected then return end + self:_unbindDestroying(); self:_consumeCleanup() + end) + self._destroyConn = conn + local depsChanged = self._epochs:Refresh() + if not self._installed or depsChanged then self:Rerun() end +end +function EffectHandle:_unbindDestroying() + if self._destroyConn then self._destroyConn.Connected = false; self._destroyConn = nil end +end +function EffectHandle:_consumeCleanup() + local c = self._cleanup; self._cleanup = nil; self._installed = false + if c then c() end +end +function EffectHandle:Rerun() + if self._running then self._pending = true; return end + self._running = true + repeat + self._pending = false + self:_consumeCleanup() + self._cleanup = self.fn(self) + self._installed = true + until not self._pending + self._running = false +end +-- Effect의 Subscribe/Unsubscribe — lifecycle-pattern의 Observer 4진입점을 그대로 재사용 +-- (핸들 자신만 등록, 내부 Observer는 건드리지 않는다 — H-59) +EffectHandle.WeakSubscribe, EffectHandle.WeakUnsubscribe = Observer.WeakSubscribe, Observer.WeakUnsubscribe +EffectHandle.Subscribe = Observer.Subscribe +function EffectHandle:Unsubscribe() + Observer.Unsubscribe(self) -- 게이트 + 레지스트리 + .Subscribed = false + self:_consumeCleanup() -- 마지막 cleanup 정확히 1회 + return self +end + +-- ── Blocker ───────────────────────────────────────────────────────────── +local Blocker = {}; Blocker.__index = Blocker +function M.Blocker() return setmetatable({IsBlocked = false, handles = weakK()}, Blocker) end +function Blocker:IsOn() return self.IsBlocked end +function Blocker:On() self.IsBlocked = true; return self end +function Blocker:_run(emit) + local snap = {}; for h in pairs(self.handles) do snap[#snap+1] = h end + for _, h in ipairs(snap) do h(emit) end +end +function Blocker:Off() self.IsBlocked = false; self:_run(true); return self end +function Blocker:OffWithoutEmit() self.IsBlocked = false; self:_run(false); return self end + +return M diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d10_remove_at_tail.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d10_remove_at_tail.luau new file mode 100644 index 0000000..f65bcdf --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d10_remove_at_tail.luau @@ -0,0 +1,26 @@ +--!nocheck +-- H-124 후보: recompute가 커서 i == N을 처리하는 중 사용자 코드가 요소를 제거하면 +-- `local v = bk.lengthList[i]`가 되감기 판정보다 먼저 nil을 읽는다. +local q = require("./core9"); local D = require("./dispatch9") +local inst = q.newInst("inst") +local function L(n) return D.newSlot(n, {{name = n .. "_x"}}) end +local s1, s2, s3 = L("s1"), L("s2"), L("s3") +local O = D.newSlot("O", { s1, s2, s3 }) +D.mountTop(inst, O) +print("초기: s3.Offset", s3.Offset:Get(), "O.Length", O.Length:Get()) +-- s3.Offset(마지막 자리)을 보는 사용자 Observer가 그 통지 도중 O:Remove(1) +local fired = false +local w = s3.Offset:Observer(function(_, _, from) + if from == nil or fired then return end + fired = true + print(" [watcher] s3.Offset ->", s3.Offset:Get(), "→ O:Remove(1) 호출") + D.rawRemove(O, 1) + print(" [watcher] rawRemove 반환. N =", D.getBookkeeping(O).N, "offsetSetUpTo =", D.getBookkeeping(O).offsetSetUpTo) +end) +q.bindLifetime(inst, w) +D.log = {} +local ok, err = pcall(function() D.rawAdd(s1, {name="s1_y"}) end) -- s1 길이 1→2, s2·s3 offset이 밀린다 → i=3에서 watcher +print("rawAdd(s1, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err)) +local bk = D.getBookkeeping(O) +print("O.Length", O.Length:Get(), "| N", bk.N, "| s2.Offset", s2.Offset:Get(), "(기대 0) | s3.Offset", s3.Offset:Get(), "(기대 1)") +print("log:", table.concat(D.log, " ")) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d10_remove_at_tail_r10.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d10_remove_at_tail_r10.luau new file mode 100644 index 0000000..306f5d0 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d10_remove_at_tail_r10.luau @@ -0,0 +1,26 @@ +--!nocheck +-- H-124 후보: recompute가 커서 i == N을 처리하는 중 사용자 코드가 요소를 제거하면 +-- `local v = bk.lengthList[i]`가 되감기 판정보다 먼저 nil을 읽는다. +local q = require("./core10"); local D = require("./dispatch10") +local inst = q.newInst("inst") +local function L(n) return D.newSlot(n, {{name = n .. "_x"}}) end +local s1, s2, s3 = L("s1"), L("s2"), L("s3") +local O = D.newSlot("O", { s1, s2, s3 }) +D.mountTop(inst, O) +print("초기: s3.Offset", s3.Offset:Get(), "O.Length", O.Length:Get()) +-- s3.Offset(마지막 자리)을 보는 사용자 Observer가 그 통지 도중 O:Remove(1) +local fired = false +local w = s3.Offset:Observer(function(_, _, from) + if from == nil or fired then return end + fired = true + print(" [watcher] s3.Offset ->", s3.Offset:Get(), "→ O:Remove(1) 호출") + D.rawRemove(O, 1) + print(" [watcher] rawRemove 반환. N =", D.getBookkeeping(O).N, "offsetSetUpTo =", D.getBookkeeping(O).offsetSetUpTo) +end) +q.bindLifetime(inst, w) +D.log = {} +local ok, err = pcall(function() D.rawAdd(s1, {name="s1_y"}) end) -- s1 길이 1→2, s2·s3 offset이 밀린다 → i=3에서 watcher +print("rawAdd(s1, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err)) +local bk = D.getBookkeeping(O) +print("O.Length", O.Length:Get(), "| N", bk.N, "| s2.Offset", s2.Offset:Get(), "(기대 0) | s3.Offset", s3.Offset:Get(), "(기대 1)") +print("log:", table.concat(D.log, " ")) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d11_two_ops.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d11_two_ops.luau new file mode 100644 index 0000000..6f502ab --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d11_two_ops.luau @@ -0,0 +1,26 @@ +--!nocheck +-- 4차 code-review의 2-연산 경로(Remove 뒤 Add, 한 콜백 안)를 두 필드 분리 위에서 실제 값으로. +local q = require("./core9"); local D = require("./dispatch9") +local inst = q.newInst("inst") +local s = {}; for i = 1, 4 do s[i] = D.newSlot("s" .. i, {{name = "x" .. i}}) end +local O = D.newSlot("O", { s[1], s[2], s[3], s[4] }) +D.mountTop(inst, O) +print("초기 offsets:", s[1].Offset:Get(), s[2].Offset:Get(), s[3].Offset:Get(), s[4].Offset:Get(), "| O.Length", O.Length:Get()) +local s5 = D.newSlot("s5", {{name = "x5"}}) +local fired = false +local w = s[3].Offset:Observer(function(_, _, from) + if from == nil or fired then return end + fired = true + local bk = D.getBookkeeping(O) + print(" [watcher@i=3] Remove(1): before setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo) + D.rawRemove(O, 1) + print(" [watcher] after Remove: N", bk.N, "setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo) + D.rawAdd(O, s5) + print(" [watcher] after Add: N", bk.N, "setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo, "s5.Offset", s5.Offset:Get()) +end) +q.bindLifetime(inst, w) +D.log = {} +local ok, err = pcall(function() D.rawAdd(s[2], {name="x2b"}) end) +print("rawAdd(s2, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err)) +print("최종 offsets: s2", s[2].Offset:Get(), "(기대 0) s3", s[3].Offset:Get(), "(기대 2) s4", s[4].Offset:Get(), "(기대 3) s5", s5.Offset:Get(), "(기대 4) | O.Length", O.Length:Get(), "(기대 5)") +print("log:", table.concat(D.log, " ")) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d11_two_ops_r10.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d11_two_ops_r10.luau new file mode 100644 index 0000000..d4833fe --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d11_two_ops_r10.luau @@ -0,0 +1,26 @@ +--!nocheck +-- 4차 code-review의 2-연산 경로(Remove 뒤 Add, 한 콜백 안)를 두 필드 분리 위에서 실제 값으로. +local q = require("./core10"); local D = require("./dispatch10") +local inst = q.newInst("inst") +local s = {}; for i = 1, 4 do s[i] = D.newSlot("s" .. i, {{name = "x" .. i}}) end +local O = D.newSlot("O", { s[1], s[2], s[3], s[4] }) +D.mountTop(inst, O) +print("초기 offsets:", s[1].Offset:Get(), s[2].Offset:Get(), s[3].Offset:Get(), s[4].Offset:Get(), "| O.Length", O.Length:Get()) +local s5 = D.newSlot("s5", {{name = "x5"}}) +local fired = false +local w = s[3].Offset:Observer(function(_, _, from) + if from == nil or fired then return end + fired = true + local bk = D.getBookkeeping(O) + print(" [watcher@i=3] Remove(1): before setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo) + D.rawRemove(O, 1) + print(" [watcher] after Remove: N", bk.N, "setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo) + D.rawAdd(O, s5) + print(" [watcher] after Add: N", bk.N, "setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo, "s5.Offset", s5.Offset:Get()) +end) +q.bindLifetime(inst, w) +D.log = {} +local ok, err = pcall(function() D.rawAdd(s[2], {name="x2b"}) end) +print("rawAdd(s2, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err)) +print("최종 offsets: s2", s[2].Offset:Get(), "(기대 0) s3", s[3].Offset:Get(), "(기대 2) s4", s[4].Offset:Get(), "(기대 3) s5", s5.Offset:Get(), "(기대 4) | O.Length", O.Length:Get(), "(기대 5)") +print("log:", table.concat(D.log, " ")) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d12_remount_stale.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d12_remount_stale.luau new file mode 100644 index 0000000..000eefa --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d12_remount_stale.luau @@ -0,0 +1,24 @@ +--!nocheck +-- H-125 후보: 언마운트→다른 베이스로 재마운트 시 setOffsetSource가 S.Offset을 바꾸는 순간 +-- S._baseObserver는 unbindLifetime된 상태라 canExecute가 거짓 → 두 필드가 0으로 안 내려가 +-- 재마운트 꼬리 recompute(S)가 옛 offsetCache[1](옛 베이스)을 그대로 쓴다. +local q = require("./core9"); local D = require("./dispatch9") +local inst = q.newInst("inst") +local t1, t2 = D.newSlot("t1", {{name="t1x"}}), D.newSlot("t2", {{name="t2x"}}) +local S = D.newSlot("S", { t1, t2 }) +local O = D.newSlot("O", { {name="a"}, {name="b"}, S }) -- S는 베이스 2에서 시작 +D.mountTop(inst, O) +print("1차 마운트: S.Offset", S.Offset:Get(), "(기대 2) t1", t1.Offset:Get(), "(2) t2", t2.Offset:Get(), "(3)") +local bkS = D.getBookkeeping(S) +print(" S.bk: cacheValid", bkS.offsetCacheValidUpTo, "setUpTo", bkS.offsetSetUpTo, "cache[1]", bkS.offsetCache[1]) +-- O에서 S를 뗀다(rawUnmount 상당: 언마운트 + splice) — 포탈 +D.rawRemove(O, 3) +print("언마운트 후: canExecute(S._baseObserver) =", q.canExecute(S._baseObserver), "| S.Offset(보존)", S.Offset:Get()) +-- 다른 owner O2(베이스 0)의 1번 자리에 재마운트 +local O2 = D.newSlot("O2", {}) +local inst2 = q.newInst("inst2") +D.mountTop(inst2, O2) +D.rawAdd(O2, S) -- → materializeSlotTree(S, inst2, O2, 1) +print("재마운트: S.Offset", S.Offset:Get(), "(기대 0) | t1.Offset", t1.Offset:Get(), "(기대 0) | t2.Offset", t2.Offset:Get(), "(기대 1)") +print(" S.bk: cacheValid", bkS.offsetCacheValidUpTo, "setUpTo", bkS.offsetSetUpTo, "cache[1]", bkS.offsetCache[1], "(베이스 0이어야)") +if t1.Offset:Get() ~= 0 then print(" ❌ 자식 offset이 옛 베이스 위에 남았다") else print(" ✅ 정합") end diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d13_splice_at_cursor.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d13_splice_at_cursor.luau new file mode 100644 index 0000000..38e3331 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d13_splice_at_cursor.luau @@ -0,0 +1,18 @@ +--!nocheck +-- H-113 양성 확인: 커서 자리 i(< N)에서 제거하면 -1 덕에 i를 재방문해 밀려 들어온 요소가 Set을 받는다. +local q = require("./core9"); local D = require("./dispatch9") +local inst = q.newInst("inst") +local s = {}; for i = 1, 4 do s[i] = D.newSlot("s" .. i, {{name = "x" .. i}}) end +local O = D.newSlot("O", { s[1], s[2], s[3], s[4] }) +D.mountTop(inst, O) +local fired = false +local w = s[2].Offset:Observer(function(_, _, from) + if from == nil or fired then return end + fired = true + print(" [watcher@i=2] Remove(2) — 커서 자리 자신을 제거") + D.rawRemove(O, 2) +end) +q.bindLifetime(inst, w) +local ok, err = pcall(function() D.rawAdd(s[1], {name="x1b"}) end) +print("rawAdd(s1, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err)) +print("최종: s3.Offset", s[3].Offset:Get(), "(기대 2) s4.Offset", s[4].Offset:Get(), "(기대 3) | O.Length", O.Length:Get(), "(기대 4)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d13_splice_at_cursor_r10.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d13_splice_at_cursor_r10.luau new file mode 100644 index 0000000..43767c1 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d13_splice_at_cursor_r10.luau @@ -0,0 +1,18 @@ +--!nocheck +-- H-113 양성 확인: 커서 자리 i(< N)에서 제거하면 -1 덕에 i를 재방문해 밀려 들어온 요소가 Set을 받는다. +local q = require("./core10"); local D = require("./dispatch10") +local inst = q.newInst("inst") +local s = {}; for i = 1, 4 do s[i] = D.newSlot("s" .. i, {{name = "x" .. i}}) end +local O = D.newSlot("O", { s[1], s[2], s[3], s[4] }) +D.mountTop(inst, O) +local fired = false +local w = s[2].Offset:Observer(function(_, _, from) + if from == nil or fired then return end + fired = true + print(" [watcher@i=2] Remove(2) — 커서 자리 자신을 제거") + D.rawRemove(O, 2) +end) +q.bindLifetime(inst, w) +local ok, err = pcall(function() D.rawAdd(s[1], {name="x1b"}) end) +print("rawAdd(s1, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err)) +print("최종: s3.Offset", s[3].Offset:Get(), "(기대 2) s4.Offset", s[4].Offset:Get(), "(기대 3) | O.Length", O.Length:Get(), "(기대 4)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d14_baseline.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d14_baseline.luau new file mode 100644 index 0000000..006ffb3 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d14_baseline.luau @@ -0,0 +1,13 @@ +--!nocheck +local q = require("./core9"); local D = require("./dispatch9") +-- inst > O { a(plain), S{ t1, t2 }, b(plain) } — 형제 offset 전파 대조군 +local inst = q.newInst("inst") +local t1, t2 = D.newSlot("t1", {{name="t1x"}}), D.newSlot("t2", {{name="t2x"}}) +local S = D.newSlot("S", { t1, t2 }) +local O = D.newSlot("O", { {name="a"}, S, {name="b"} }) +D.mountTop(inst, O) +print("O.Length", O.Length:Get(), "(기대 4) | S.Offset", S.Offset:Get(), "(기대 1) | t1", t1.Offset:Get(), "(1) t2", t2.Offset:Get(), "(2) | S.Length", S.Length:Get(), "(2)") +-- t1이 커지면 t2와 b가 밀린다 +t1.Length:Set(3) +print("after t1=3: S.Length", S.Length:Get(), "(기대 4) | t2.Offset", t2.Offset:Get(), "(기대 4) | O.Length", O.Length:Get(), "(기대 6)") +print("log:", table.concat(D.log, " ")) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d14_baseline_r10.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d14_baseline_r10.luau new file mode 100644 index 0000000..8db52f6 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d14_baseline_r10.luau @@ -0,0 +1,13 @@ +--!nocheck +local q = require("./core10"); local D = require("./dispatch10") +-- inst > O { a(plain), S{ t1, t2 }, b(plain) } — 형제 offset 전파 대조군 +local inst = q.newInst("inst") +local t1, t2 = D.newSlot("t1", {{name="t1x"}}), D.newSlot("t2", {{name="t2x"}}) +local S = D.newSlot("S", { t1, t2 }) +local O = D.newSlot("O", { {name="a"}, S, {name="b"} }) +D.mountTop(inst, O) +print("O.Length", O.Length:Get(), "(기대 4) | S.Offset", S.Offset:Get(), "(기대 1) | t1", t1.Offset:Get(), "(1) t2", t2.Offset:Get(), "(2) | S.Length", S.Length:Get(), "(2)") +-- t1이 커지면 t2와 b가 밀린다 +t1.Length:Set(3) +print("after t1=3: S.Length", S.Length:Get(), "(기대 4) | t2.Offset", t2.Offset:Get(), "(기대 4) | O.Length", O.Length:Get(), "(기대 6)") +print("log:", table.concat(D.log, " ")) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d15_spliceup_vacate.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d15_spliceup_vacate.luau new file mode 100644 index 0000000..65f64b7 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d15_spliceup_vacate.luau @@ -0,0 +1,30 @@ +--!nocheck +-- spliceArraysUp이 비운 자리(index)의 observers/tokens를 명시적으로 nil로 두지 않으면? +-- (요구 목록은 lengthList=0/sourceList=None 자리표시자만 말한다) +local q = require("./core9"); local D = require("./dispatch9") +local function run(vacate) + local inst = q.newInst("inst") + local A, B = D.newSlot("A", {{name="a"}}), D.newSlot("B", {{name="b"}}) + local O = D.newSlot("O", { A, B }) + D.mountTop(inst, O) + local bk = D.getBookkeeping(O) + -- 손으로 spliceArraysUp(O, 1)을 "복사 루프" 방식으로 흉내: i→i+1 복사, 비운 자리는 vacate 여부에 따라 + local N = bk.N + for i = N, 1, -1 do + bk.lengthList[i+1] = bk.lengthList[i]; bk.sourceList[i+1] = bk.sourceList[i] + bk.observers[i+1] = bk.observers[i]; bk.tokens[i+1] = bk.tokens[i] + bk.indexOfToken[bk.tokens[i+1]] = i+1 + end + bk.lengthList[1] = 0; bk.sourceList[1] = D.None + if vacate then bk.observers[1] = nil; bk.tokens[1] = nil end + bk.N = N + 1 + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, 0); bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, 0) + table.insert(O._elements, 1, {name = "new"}) + D.setOffsetSource(O, 1, D.None) + D.setLength(O, 1, 1, inst) -- oldObserver가 있으면 unbindLifetime한다 + -- 이제 A(2번 자리)의 길이가 바뀌면 B가 밀려야 한다 + A.Length:Set(3) + print(("[vacate=%s] A.Length=3 후 B.Offset = %d (기대 4) | canExecute(observers[2]) = %s | indexOfToken[tokens[2]] = %s") + :format(tostring(vacate), B.Offset:Get(), tostring(q.canExecute(bk.observers[2])), tostring(bk.indexOfToken[bk.tokens[2]]))) +end +run(true); run(false) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d16_remount_variants.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d16_remount_variants.luau new file mode 100644 index 0000000..39933c6 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d16_remount_variants.luau @@ -0,0 +1,40 @@ +--!nocheck +-- Q2 대조: 재마운트 처방 none / (a) / (c) +-- 트리: O = { a, b, S }, S = { C, plain }, C = { plain } (S의 베이스 2) +-- 유저 체인: `:List`의 updateFn이 만든 요소가 S.Offset을 구독해 LayoutOrder를 정하는 모양. +-- 요소 인스턴스는 언마운트를 넘어 살아남으므로(파괴 아님) 그 Observer도 살아 있다. +local q = require("./core9"); local D = require("./dispatch9") +local function run(fix) + D.remountFix = fix + local inst = q.newInst("inst1") + local elem = q.newInst("elem") -- updateFn이 만든 요소(살아남는다) + local C = D.newSlot("C", {{name="cx"}}) + local S = D.newSlot("S", { C, {name="sx"} }) + local O = D.newSlot("O", { {name="a"}, {name="b"}, S }) + D.mountTop(inst, O) + -- 유저 LayoutOrder: index(=1) + S.Offset + local lo = { value = nil } + local userObs = S.Offset:Observer(function(target) lo.value = 1 + target:Get() end) + q.bindLifetime(elem, userObs) + print(("[%s] 1차: S.Offset=%d C.Offset=%d userLO=%s | bk(S) valid=%d setUpTo=%d cache[1]=%s") + :format(fix, S.Offset:Get(), C.Offset:Get(), tostring(lo.value), + D.getBookkeeping(S).offsetCacheValidUpTo, D.getBookkeeping(S).offsetSetUpTo, + tostring(D.getBookkeeping(S).offsetCache[1]))) + D.rawRemove(O, 3) -- 언마운트(포탈로 떼어냄) + local inst2 = q.newInst("inst2") + local O2 = D.newSlot("O2", {}) + D.mountTop(inst2, O2) + D.rawAdd(O2, S) -- 베이스 0에 재마운트 + local bkS = D.getBookkeeping(S) + print(("[%s] 재마운트: S.Offset=%d (기대 0) C.Offset=%d (기대 0) userLO=%s (기대 1) | bk(S) valid=%d setUpTo=%d cache[1]=%s") + :format(fix, S.Offset:Get(), C.Offset:Get(), tostring(lo.value), + bkS.offsetCacheValidUpTo, bkS.offsetSetUpTo, tostring(bkS.offsetCache[1]))) + local ok = (S.Offset:Get() == 0) and (C.Offset:Get() == 0) and (lo.value == 1) + print(("[%s] → %s"):format(fix, if ok then "✅ 전부 정합" else "❌ 어긋남")) + -- 재마운트 뒤 런타임 베이스 변경도 도는가(O2 앞에 요소를 하나 끼워 S를 민다) + D.rawAdd(O2, {name="z"}, 1) + print(("[%s] 앞에 z 삽입 후: S.Offset=%d (기대 1) C.Offset=%d (기대 1) userLO=%s (기대 2)") + :format(fix, S.Offset:Get(), C.Offset:Get(), tostring(lo.value))) + print("") +end +run("none"); run("a"); run("c"); run("ctor") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d17_ctor_cost.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d17_ctor_cost.luau new file mode 100644 index 0000000..cc8462e --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d17_ctor_cost.luau @@ -0,0 +1,13 @@ +--!nocheck +local q = require("./core9"); local D = require("./dispatch9") +D.remountFix = "ctor" +local s = D.newSlot("lonely") +print("생성 직후: Offset =", s.Offset:Get(), "| _baseObserver 존재 =", s._baseObserver ~= nil, + "| bk 미생성(books에 없음) =", rawget(D, "x") == nil) +-- 미실체화 Slot에 Add — rawAdd의 _physicalTarget 얼리리턴이 그대로 성립하는가 +local i = D.rawAdd(s, {name="q"}) +print("미실체화 Add: index =", i, "| _elements =", #s._elements, "| 크래시 없음") +-- 실체화 후 정상 동작 +local inst = q.newInst("i"); local O = D.newSlot("O", { s }) +D.mountTop(inst, O) +print("마운트 후: O.Length =", O.Length:Get(), "(기대 1) | s.Offset =", s.Offset:Get(), "(기대 0)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d18_add_middle_elemkey.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d18_add_middle_elemkey.luau new file mode 100644 index 0000000..299e90f --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d18_add_middle_elemkey.luau @@ -0,0 +1,12 @@ +--!nocheck +-- Q3 실측: 중간 삽입 뒤 밀려난 요소의 길이 Observer가 살아 있고, 요소 키 조회가 새 자리를 준다 +local q = require("./core9"); local D = require("./dispatch9") +local inst = q.newInst("inst") +local A, B = D.newSlot("A", {{name="a"}}), D.newSlot("B", {{name="b"}}) +local O = D.newSlot("O", { A, B }); D.mountTop(inst, O) +D.rawAdd(O, D.newSlot("X", {{name="x"}}), 1) -- 맨 앞 삽입 → A는 2번, B는 3번 +local bk = D.getBookkeeping(O) +print("indexOfElement: X", bk.indexOfElement[O._elements[1]], "A", bk.indexOfElement[A], "B", bk.indexOfElement[B], "(기대 1 2 3)") +print("삽입 후 offsets: A", A.Offset:Get(), "(기대 1) B", B.Offset:Get(), "(기대 2)") +D.rawAdd(A, {name="a2"}) -- A 길이 1→2 — 밀려난 A의 Observer가 살아있어야 B가 밀린다 +print("A 성장 후 B.Offset", B.Offset:Get(), "(기대 3) | O.Length", O.Length:Get(), "(기대 4)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d1_basic.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d1_basic.luau new file mode 100644 index 0000000..5e6be7f --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d1_basic.luau @@ -0,0 +1,12 @@ +--!nocheck +local q = require("./core"); local D = require("./dispatch") +local inst = {} -- 최상위 물리 inst가 owner +-- Frame { A, B, C } — 전부 plain 요소(길이 1, 발행 채널 None) +local bl = D.getBlocker(inst); bl:On() +for i = 1, 3 do + D.setOffsetSource(inst, i, D.None) + D.setLength(inst, i, 1, inst) +end +bl:OffWithoutEmit(); D.recompute(inst, D.getBookkeeping(inst)) +print("offsets:", D.getOffsetAt(inst,1), D.getOffsetAt(inst,2), D.getOffsetAt(inst,3), "| 다음 자리:", D.getOffsetAt(inst,4)) +print("recompute 횟수:", D.stats()) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d20_indexOfElement_weak.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d20_indexOfElement_weak.luau new file mode 100644 index 0000000..af0478c --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d20_indexOfElement_weak.luau @@ -0,0 +1,40 @@ +--!nocheck +-- H-145: 최상위 Slot 교체(해제 setLength(inst,k,0))가 옛 Slot 키를 못 지워도 weak-key라 GC로 빠지는가. +-- 요소가 테이블이라 weak-key 자체는 동작한다 — Instance userdata 키의 거동은 여기서 못 본다(한계). +local q = require("./core10"); local D = require("./dispatch10") +local inst = q.newInst("inst") +local probe = setmetatable({}, {__mode = "k"}) +local retract +do + local S1 = D.newSlot("S1", { {name="a"}, {name="b"} }) + probe[S1] = true + retract = D.mountTop(inst, S1, 1) + local bk = D.getBookkeeping(inst) + print("마운트 후 bk(inst).indexOfElement[S1] =", bk.indexOfElement[S1], "lengthList[1] =", tostring(bk.lengthList[1])) + local S2 = D.newSlot("S2", { {name="c"} }) + retract(S2) -- State 교체: retractor(nextValue=S2) → 언마운트 + setLength(inst,1,0) + print("교체(해제) 후 indexOfElement[S1] =", bk.indexOfElement[S1], "(해제엔 요소 인자가 없어 안 지워짐)") + print(" S1 canExecute(_baseObserver) =", q.canExecute(S1._baseObserver), "| observers[1] =", tostring(bk.observers[1])) +end +retract = nil -- retractor 클로저가 S1을 잡고 있으므로 놓는다(SlotHandler 체인 슬롯이 교체된 것에 해당) +collectgarbage(); collectgarbage() +local alive = 0; for _ in pairs(probe) do alive += 1 end +local cnt = 0; for _ in pairs(D.getBookkeeping(inst).indexOfElement) do cnt += 1 end +print("참조 놓고 GC → 옛 Slot 생존", alive, "(기대 0) | indexOfElement 항목 수", cnt, "(기대 0)") + +-- 마운트 중인 요소는 절대 빠지지 않아야 한다(보장자: _elements + gatedRecompute 캡처) +local inst2 = q.newInst("inst2") +local outer = D.newSlot("outer", {}) +D.mountTop(inst2, outer, 1) +do + local inner = D.newSlot("inner", { {name="x"} }) + D.rawAdd(outer, inner) + D.rawAdd(outer, {name="y"}) +end +collectgarbage(); collectgarbage() +local bko = D.getBookkeeping(outer) +local keys = 0; for _ in pairs(bko.indexOfElement) do keys += 1 end +print("마운트 중 요소(로컬 참조 없음) GC 뒤 indexOfElement 항목", keys, "(기대 2 — _elements가 붙잡음)") +print(" inner.Length 변경 전파: outer.Length =", outer.Length:Get()) +D.rawAdd(outer._elements[1], {name="z"}) +print(" inner에 z 추가 → outer.Length =", outer.Length:Get(), "(기대 3)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d21_reconcile_batch.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d21_reconcile_batch.luau new file mode 100644 index 0000000..d315d14 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d21_reconcile_batch.luau @@ -0,0 +1,50 @@ +--!nocheck +-- H-136/Q10: `:List` 재실행 reconcile이 배치 Blocker 하나로 도는가 — {a,b} → {b,a,c} → {b} 사이클별 recompute 수 +local q = require("./core10"); local D = require("./dispatch10") +local inst = q.newInst("inst") +local items = q.Source({ "a", "b" }) +local made = {} +local slot = D.newSlot("L") +D.List(slot, items, function(item, physIndex, offset, prev, ud) + if item == D.KeyGone then return nil end + if prev then return prev end + local e = { name = item }; made[item] = e + return e +end, function(item) return item end) +D.mountTop(inst, slot, 1) +local function names() local t = {}; for _, e in ipairs(slot._elements) do t[#t+1] = e.name end; return table.concat(t, ",") end +print("최초 population:", names(), "| Length", slot.Length:Get(), "| recomputes", D.recomputes) +local function cycle(list, label) + local l0, n0 = #D.log, #D.native + items:Set(list) + local rl, ri = 0, 0 + for i = l0 + 1, #D.log do if D.log[i] == "recompute(L)" then rl += 1 elseif D.log[i] == "recompute(inst)" then ri += 1 end end + print(("%s: %s | Length %d | recompute(L) %d (기대 1) recompute(inst) %d | 물리 op: %s"):format(label, names(), slot.Length:Get(), + rl, ri, table.concat(D.native, " ", n0 + 1))) + local bk = D.getBookkeeping(slot) + local offs = {}; for i = 1, bk.N do offs[i] = tostring(bk.sourceList[i] == D.None and "-" or bk.sourceList[i]:Get()) end + print((" bk.N=%d lengthList={%s} validUpTo=%d setUpTo=%d blocker=%s"):format(bk.N, + table.concat(bk.lengthList, ",", 1, bk.N), bk.offsetCacheValidUpTo, bk.offsetSetUpTo, tostring(D.getBlockerOf(slot):IsOn()))) +end +cycle({ "b", "a", "c" }, "{b,a,c}") +cycle({ "b" }, "{b}") +cycle({ "d", "b" }, "{d,b}") +-- 중첩 Slot 요소와 Length 전파 +local inner = D.newSlot("inner", { {name="i1"}, {name="i2"} }) +local items2 = q.Source({ "p", "q" }) +local slot2 = D.newSlot("L2") +D.List(slot2, items2, function(item, physIndex, offset, prev) + if item == D.KeyGone then return nil end + if prev then return prev end + if item == "q" then return inner end + return { name = item } +end, function(item) return item end) +local inst2 = q.newInst("inst2") +D.mountTop(inst2, slot2, 1) +print("중첩: L2.Length", slot2.Length:Get(), "(기대 3) inner.Offset", inner.Offset:Get(), "(기대 1)") +D.rawAdd(inner, {name="i3"}) +print("inner에 추가 → L2.Length", slot2.Length:Get(), "(기대 4)") +items2:Set({ "q", "p" }) +print("리오더 {q,p} → inner.Offset", inner.Offset:Get(), "(기대 0) L2.Length", slot2.Length:Get(), "(기대 4)") +local bk2 = D.getBookkeeping(slot2) +print(" p의 offset(getOffsetAt 2) =", D.getOffsetAt(slot2, 2), "(기대 3)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d22_detach_keygone.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d22_detach_keygone.luau new file mode 100644 index 0000000..954af6b --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d22_detach_keygone.luau @@ -0,0 +1,34 @@ +--!nocheck +-- 레인 B: `:List` × Detach × KeyGone × 재등장(재마운트) × 파괴 — settle 분기 전부를 값으로 +local q = require("./core10"); local D = require("./dispatch10") +local inst = q.newInst("inst") +local items = q.Source({ "a", "b", "c" }) +local created, disposed = {}, {} +local slot = D.newSlot("L") +D.List(slot, items, function(item, physIndex, offset, prev, ud) + if item == D.KeyGone then return D.Detach end -- 사라진 키는 홀드 + if prev then return prev end + local e = { name = item }; created[#created+1] = item + return e +end, function(item) return item end) +D.mountTop(inst, slot, 1) +local function names() local t = {}; for _, e in ipairs(slot._elements) do t[#t+1] = e.name end; return table.concat(t, ",") end +local function detachedKeys() local t = {}; for k in pairs(slot._detached or {}) do t[#t+1] = k end; table.sort(t); return table.concat(t, ",") end +print("population:", names(), "| created", table.concat(created, ",")) +items:Set({ "a", "c" }) +print("{a,c}: mounted", names(), "| detached", detachedKeys(), "| Length", slot.Length:Get(), "| 물리:", table.concat(D.native, " ", #D.native - 0)) +local n0 = #D.native +items:Set({ "a", "b", "c" }) +print("{a,b,c} 재등장: mounted", names(), "| detached", detachedKeys(), "| created 추가?", #created, "(기대 3 — 재사용) | 물리:", table.concat(D.native, " ", n0 + 1)) +local bk = D.getBookkeeping(slot) +print(" bk.N", bk.N, "lengthList", table.concat(bk.lengthList, ",", 1, bk.N), "indexOfElement[b] =", bk.indexOfElement[slot._elements[2]]) +-- KeyGone 뒤 새 값 반환은 error +local slot2 = D.newSlot("L2"); local items2 = q.Source({ "x" }) +D.List(slot2, items2, function(item, _, _, prev) if item == D.KeyGone then return { name = "new" } end; return prev or { name = item } end, function(i) return i end) +local inst2 = q.newInst("inst2"); D.mountTop(inst2, slot2, 1) +print("KeyGone에 새 값:", select(2, pcall(function() items2:Set({}) end))) +-- Detach 홀드 중 Slot 파괴 → _detached도 파괴 +n0 = #D.native +D.destroySlotTree(slot) +print("destroySlotTree 뒤 물리:", table.concat(D.native, " ", n0 + 1), "| _destroyed", slot._destroyed) +print("파괴된 Slot 재마운트:", select(2, pcall(function() D.mountTop(q.newInst("i3"), slot, 1) end))) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d23_instancechild_spurious.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d23_instancechild_spurious.luau new file mode 100644 index 0000000..5fa6d84 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d23_instancechild_spurious.luau @@ -0,0 +1,42 @@ +--!nocheck +-- 레인 B: `InstanceChildHandler`(ROADMAP M5 / dispatch-core `H-134` 다섯째 문단)를 하강 diff 체인 위에 그대로 옮기고, +-- `state`의 **같은 Frame 재발행**(spurious)과 **교체**에서 `Parent` 대입이 몇 번 나는지 센다. +-- 대조군: `SlotHandler`(같은 값이면 retractor 얼리리턴) / `RefLeafHandler`(`old ~= v` dedup). +local q = require("./core10"); local D = require("./dispatch10"); local C = require("./chain") +local inst = q.newInst("inst") +local parentOps = {} +local function log(s) parentOps[#parentOps+1] = s end +local None = D.None +-- StoreBind (dispatch-core "Store 바인드는 … 래핑" 의사코드) +local StoreBind = { priority = 100, + isHandlable = function(_, _, v) return q.isState(v) end, + process = function(inst, k, state, index) + local observer = state:Observer(function() C.process(inst, k, state:Get(), index + 1) end) + q.bindLifetime(inst, observer) + return function() q.unbindLifetime(observer) end + end } +-- InstanceChildHandler — ROADMAP M5 그대로: None → Parent → setLength(1); retractor: Parent=nil → None → setLength(0) +local InstanceChild = { priority = 10, + isHandlable = function(_, k, v) return type(k) == "number" and type(v) == "table" and v.__isInst == true end, + process = function(inst, k, v, index) + D.setOffsetSource(inst, k, None) + v.Parent = inst; log("Parent(" .. v.name .. ")=inst") + D.setLength(inst, k, 1, inst) + return function(nextValue) + v.Parent = nil; log("Parent(" .. v.name .. ")=nil") + D.setOffsetSource(inst, k, None) + D.setLength(inst, k, 0) + end + end } +C.addHandler(StoreBind); C.addHandler(InstanceChild) +local A, B = { __isInst = true, name = "A" }, { __isInst = true, name = "B" } +local child = q.Source(A) +D.recomputes = 0 +C.process(inst, 1, child, 1) +print("최초:", table.concat(parentOps, " "), "| recompute(inst)", D.recomputes) +parentOps = {}; D.recomputes = 0 +child:Set(A) -- spurious 재발행 — 같은 Frame +print("같은 Frame 재발행:", table.concat(parentOps, " "), "| recompute", D.recomputes, "(SlotHandler/RefLeaf라면 0)") +parentOps = {}; D.recomputes = 0 +child:Set(B) +print("교체 A→B:", table.concat(parentOps, " "), "| recompute", D.recomputes, "| A.Parent =", tostring(A.Parent)) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d2_slots.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d2_slots.luau new file mode 100644 index 0000000..3cb59fa --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d2_slots.luau @@ -0,0 +1,23 @@ +--!nocheck +local q = require("./core"); local D = require("./dispatch") +-- Frame { Slot1, E(plain), Slot2 } +local inst = {} +local s1, s2 = D.newSlot("s1"), D.newSlot("s2") +local bl = D.getBlocker(inst); bl:On() +D.setOffsetSource(inst, 1, s1.Offset); D.setLength(inst, 1, s1.Length, inst) +D.setOffsetSource(inst, 2, D.None); D.setLength(inst, 2, 1, inst) +D.setOffsetSource(inst, 3, s2.Offset); D.setLength(inst, 3, s2.Length, inst) +bl:OffWithoutEmit(); D.recompute(inst, D.getBookkeeping(inst)) +print("초기: s1.Offset =", s1.Offset:Get(), "| s2.Offset =", s2.Offset:Get(), "| recompute:", D.stats()) + +print("-- 런타임에 Slot1이 3개가 됨 (s1.Length:Set(3)) --") +s1.Length:Set(3) +print("s1.Offset =", s1.Offset:Get(), "| s2.Offset =", s2.Offset:Get(), "(기대 4) | recompute:", D.stats()) + +print("-- Slot2도 2개가 됨 --") +s2.Length:Set(2) +print("s2.Offset =", s2.Offset:Get(), "(기대 4) | 4번 자리 offset =", D.getOffsetAt(inst,4), "(기대 6)") + +print("-- Slot1이 다시 0개 --") +s1.Length:Set(0) +print("s2.Offset =", s2.Offset:Get(), "(기대 1) | 4번 자리 =", D.getOffsetAt(inst,4), "(기대 3)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d4_reentrant.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d4_reentrant.luau new file mode 100644 index 0000000..7e792c3 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d4_reentrant.luau @@ -0,0 +1,31 @@ +--!nocheck +local q = require("./core"); local D = require("./dispatch") +local owner = D.newSlot("owner") +local s1, s2 = D.newSlot("s1"), D.newSlot("s2") +local bl = D.getBlocker(owner); bl:On() +D.setOffsetSource(owner, 1, s1.Offset); D.setLength(owner, 1, s1.Length, owner) +D.setOffsetSource(owner, 2, s2.Offset); D.setLength(owner, 2, s2.Length, owner) +bl:OffWithoutEmit(); D.recompute(owner, D.getBookkeeping(owner)) +print("초기 owner.Length =", owner.Length:Get(), "| s2.Offset =", s2.Offset:Get()) + +-- s2.Offset을 보는 소비자가 그 통지 도중 같은 owner에 3번째 자리를 추가한다 +local added = false +local watcher = q.Observer(s2.Offset, function() + if added then return end + added = true + D.setOffsetSource(owner, 3, D.None) + D.setLength(owner, 3, 1, owner) -- 재진입 recompute +end) +watcher.Subscribed = true + +print("-- s1.Length:Set(2) --") +s1.Length:Set(2) +local bk = D.getBookkeeping(owner) +local expect = 0 +for i = 1, bk.N do + local v = bk.lengthList[i] + expect += (if type(v) == "table" then v:Get() else v) +end +print("owner.Length =", owner.Length:Get(), "| 실제 합계 =", expect, "| N =", bk.N, "| recompute:", D.stats()) +print("s2.Offset =", s2.Offset:Get(), "(기대 2) | 3번 자리 offset =", D.getOffsetAt(owner, 3), "(기대 2)") +if owner.Length:Get() ~= expect then print(" ❌ owner.Length가 실제 합계와 다르다") else print(" ✅ 일치") end diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d5_chain_error.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d5_chain_error.luau new file mode 100644 index 0000000..a37d63e --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d5_chain_error.luau @@ -0,0 +1,49 @@ +--!nocheck +local C = require("./chain") +-- AttributeKeyHandler(attribute-plan.md 확정 의사코드)를 그대로 옮긴 것 +local nameClaims = {} +local setAttributeInjected = false +local function setAttribute(inst, name, v) + if not setAttributeInjected then + error("quad: setAttribute op이 주입되지 않았습니다(백엔드 팩토리 미설치)") -- 확정된 "에러내는 스텁" + end +end +local AttributeKeyHandler = { + isHandlable = function(inst, k, v) return type(k) == "table" and k.Name ~= nil end, + process = function(inst, k, v, index) + local cur = nameClaims[inst] and nameClaims[inst][k.Name] + if cur ~= nil and cur ~= k then + error(`attribute "{k.Name}" is already bound by another owner`) + end + nameClaims[inst] = nameClaims[inst] or {} + nameClaims[inst][k.Name] = k + setAttribute(inst, k.Name, v) -- ← 주입 안 됐으면 여기서 던진다 + return function() + if nameClaims[inst][k.Name] == k then nameClaims[inst][k.Name] = nil end + end + end, +} +C.addHandler(AttributeKeyHandler) + +local inst = {} +local keyA = { Name = "Score" } +local keyB = { Name = "Score" } -- 같은 이름을 쓰는 다른 소유자(그룹 Attribute 등) + +print("-- 백엔드 미주입 상태에서 Attribute 한 번 --") +local ok, err = pcall(C.process, inst, keyA, 10, 1) +print("던졌는가:", not ok, "|", err) +print("claim 남았는가:", nameClaims[inst] and nameClaims[inst].Score ~= nil) +local list = C.dump(inst, keyA) +print("체인 슬롯:", list and list[1] and "있음(retractor=" .. tostring(list[1].retractor) .. ")" or "없음") + +print("-- 이제 백엔드를 붙이고 정상적으로 다시 시도 --") +setAttributeInjected = true +local ok2, err2 = pcall(C.process, inst, keyB, 20, 1) +print("성공했는가:", ok2, "|", err2) + +print("-- 명시적 철거로 회수되는가 --") +setAttributeInjected = true +pcall(C.retractFrom, inst, keyA, 1) +print("철거 후 claim 남았는가:", nameClaims[inst] and nameClaims[inst].Score ~= nil) +local ok3, err3 = pcall(C.process, inst, keyB, 20, 1) +print("철거 후 재시도 성공?:", ok3, "|", err3) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d7_splice_fix.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d7_splice_fix.luau new file mode 100644 index 0000000..06301a6 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d7_splice_fix.luau @@ -0,0 +1,39 @@ +--!nocheck +local q = require("./core"); local D = require("./dispatch") +-- RETUNE = true면 splice가 이동된 observer의 위치 인덱스를 실제 위치로 다시 맞춘다 +local function run(RETUNE) + local function spliceArraysDown(ownerKey, index) + local bk = D.getBookkeeping(ownerKey) + for i = index, bk.N - 1 do + bk.lengthList[i] = bk.lengthList[i + 1] + bk.sourceList[i] = bk.sourceList[i + 1] + bk.observers[i] = bk.observers[i + 1] + end + bk.lengthList[bk.N] = nil; bk.sourceList[bk.N] = nil; bk.observers[bk.N] = nil + bk.N -= 1 + bk.invalidAfter = math.min(bk.invalidAfter, index) + if RETUNE then + for i = index, bk.N do + local o = bk.observers[i] + if o and o.box then o.box.pos = i end + end + end + end + local owner, s = {}, {} + for i = 1, 3 do s[i] = D.newSlot("s" .. i) end + local bl = D.getBlocker(owner); bl:On() + for i = 1, 3 do + D.setOffsetSource(owner, i, s[i].Offset) + D.setLength(owner, i, s[i].Length, owner) + s[i].Length:Set(1) + end + bl:OffWithoutEmit(); D.recompute(owner, D.getBookkeeping(owner)) + spliceArraysDown(owner, 1) + D.recompute(owner, D.getBookkeeping(owner)) + s[2].Length:Set(5) + print(("[%s] s3.Offset = %d (기대 5) | invalidAfter = %d") + :format(if RETUNE then "위치 재조정함" else "확정 설계 그대로", s[3].Offset:Get(), + D.getBookkeeping(owner).invalidAfter)) +end +run(false) +run(true) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/d9_hole2.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/d9_hole2.luau new file mode 100644 index 0000000..c86d114 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/d9_hole2.luau @@ -0,0 +1,11 @@ +--!nocheck +local q = require("./core"); local D = require("./dispatch") +local inst = {} +local s2 = D.newSlot("s2") +local bl = D.getBlocker(inst); bl:On() +-- 1번 자리 등록 생략(H-39 버그 재발), 2번은 발행 채널이 있는 Slot +local ok, err = pcall(function() + D.setOffsetSource(inst, 2, s2.Offset) -- 여기서 getOffsetAt(inst, 2)를 부른다 +end) +print("setOffsetSource에서 터지는가:", not ok) +print("메시지:", err) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/dispatch.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/dispatch.luau new file mode 100644 index 0000000..b399e6f --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/dispatch.luau @@ -0,0 +1,115 @@ +--!nocheck +-- base/dispatch-core-plan.md의 Length/Offset 부기를 확정 의사코드 그대로 옮긴 것. +-- getOffsetAt(접두합 캐시, 2026-08-21 G절 + H-3) / recompute(H-10 정정 반영) / +-- setLength / setOffsetSource / 배치 게이팅. +local q = require("./core") +local D = {} + +local None = setmetatable({}, {__tostring = function() return "None" end}) +D.None = None +local function isSlot(x) return type(x) == "table" and x.__isSlot == true end +local function isState(x) return type(x) == "table" and (getmetatable(x) ~= nil) and x.Get ~= nil end + +local books, blockers = {}, {} +local function getBookkeeping(ownerKey) + local bk = books[ownerKey] + if not bk then + bk = { lengthList = {}, sourceList = {}, observers = {}, N = nil, + offsetCache = {}, invalidAfter = 0 } + books[ownerKey] = bk + end + return bk +end +local function getBlocker(ownerKey) + local b = blockers[ownerKey] + if not b then b = q.Blocker(); blockers[ownerKey] = b end + return b +end +D.getBookkeeping, D.getBlocker = getBookkeeping, getBlocker + +local function contribution(bk, i) + local v = bk.lengthList[i] + return (if isState(v) then v:Get() else v) +end + +function D.getOffsetAt(ownerKey, at) + local bk = getBookkeeping(ownerKey) + if bk.invalidAfter == 0 then + bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0 + bk.invalidAfter = 1 + end + if at <= bk.invalidAfter then + return bk.offsetCache[at] + end + local cur = bk.offsetCache[bk.invalidAfter] + for i = bk.invalidAfter, at - 1 do + cur += contribution(bk, i) + bk.offsetCache[i + 1] = cur + end + bk.invalidAfter = at + return cur +end + +local recomputes = 0 +D.stats = function() return recomputes end +local function recompute(ownerKey, bk) + recomputes += 1 + local sum = 0 + for i = 1, bk.N or 0 do + local offset = bk.sourceList[i] + if offset == nil then + error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)") + end + local abs = D.getOffsetAt(ownerKey, i) + if offset ~= None and offset:Get() ~= abs then + offset:Set(abs) + end + local v = bk.lengthList[i] + sum += (if isState(v) then v:Get() else v) + end + if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then + ownerKey.Length:Set(sum) + end +end +D.recompute = recompute + +function D.setLength(ownerKey, i, len, anchor) + anchor = anchor or ownerKey + local bk = getBookkeeping(ownerKey) + local blocker = getBlocker(ownerKey) + local oldObserver = bk.observers[i] + if oldObserver then bk.observers[i] = nil end + bk.lengthList[i] = len + bk.N = math.max(bk.N or 0, i) + bk.invalidAfter = math.min(bk.invalidAfter, i) + + local box = { pos = i } -- [대조군용] 실제 위치를 가변 박스로 들면 splice가 고칠 수 있다 + local function gatedRecompute() + bk.invalidAfter = math.min(bk.invalidAfter, box.pos) + if not blocker:IsOn() then + recompute(ownerKey, bk) + end + end + if isState(len) then + local observer = q.Observer(len, gatedRecompute) + observer.pos = box.pos; observer.box = box + bk.observers[i] = observer + gatedRecompute() -- "등록 즉시 1회 실행도 게이팅됨" + else + gatedRecompute() + end +end + +function D.setOffsetSource(ownerKey, i, source) + local bk = getBookkeeping(ownerKey) + bk.sourceList[i] = source + if source == None then return end + local offset = D.getOffsetAt(ownerKey, i) + if source:Get() ~= offset then source:Set(offset) end +end + +-- 테스트 편의: Slot 흉내(Length/Offset 공개 필드만) +function D.newSlot(name) + return { __isSlot = true, name = name, Length = q.Source(0), Offset = q.Source(0) } +end +return D diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/dispatch10.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/dispatch10.luau new file mode 100644 index 0000000..3bf14bd --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/dispatch10.luau @@ -0,0 +1,587 @@ +--!nocheck +-- 10라운드 참조 구현 — 9라운드 전사물 `dispatch9.luau`(같은 폴더 보존)를 HEAD `0ec22fb` +-- 계약으로 갱신한 것. 옮긴 원문: +-- · dispatch-core-plan.md — getBookkeeping 초기화 열거(`indexOfElement` **weak-key**, H-145), +-- getOffsetAt / 두 필드 / 무효화 표 / recompute(H-124 `continue` 순서, 클램프, recomputeBlocker) / +-- setLength(5번째 `element`, 요소 캡처 조회) / setOffsetSource / 배치 게이팅 +-- · slot-plan.md — Slot 생성자(Length·Offset·_baseObserver·_destroyed, Q2), makeBaseObserver, +-- materializeSlotTree(게이트→바인드→setOffsetSource 순서) / mountSlotTree / attachSlot, +-- unmountSlotTree / destroySlotTree(핸들 보존, `_destroyed`), rawAdd / rawReplace / rawRemove / +-- rawUnmount / rawDetach, spliceArraysUp/Down(세 배열 비우기, `index-1`), reindexFrom, +-- `H-29` 규약대로 쓴 rawMove(의사코드가 없는 함수 — **규약을 옮긴 것**이라 여기 결함은 +-- 규약 자체의 결함일 때만 발견이다), activateList/reconcile(`H-136` `ownsGate` 배치). +-- · 최상위 `SlotHandler.process`의 마운트/retractor(설치·해제 `setLength(inst,k,0)`) 흉내 — `H-145`. +-- 물리 op(native*)은 no-op 기록이고, 요소는 `{name=}` 테이블(길이 1) 또는 Slot. +-- ⚠️ Instance를 weak-key로 쓰는 경로는 흉내낼 수 없다 — 요소가 테이블이라 weak-key 자체는 동작하지만 +-- Roblox Instance userdata의 weak-key 거동(`audit/gcconn-trick-verification.md` 미확인)은 별개다. +local q = require("./core10") +local D = {} +local None = setmetatable({}, {__tostring = function() return "None" end}) +D.None = None +local Detach, KeyGone = setmetatable({}, {__tostring = function() return "Detach" end}), + setmetatable({}, {__tostring = function() return "KeyGone" end}) +D.Detach, D.KeyGone = Detach, KeyGone +local isState = q.isState +local function isSlot(x) return type(x) == "table" and x.__isSlot == true end +D.isSlot = isSlot +local function weakK() return setmetatable({}, {__mode = "k"}) end + +D.native = {} -- 물리 op 기록 +local function nativeLog(op, target, offset, elements, extra) + local names = {} + for _, e in ipairs(elements or {}) do names[#names+1] = tostring(e.name) end + D.native[#D.native+1] = ("%s(%s@%s:[%s]%s)"):format(op, tostring(target.name), tostring(offset), + table.concat(names, ","), if extra then "→" .. tostring(extra.name) else "") +end +local function nativeInsert(t, off, els) nativeLog("insert", t, off, els) end +local function nativeExtract(t, off, els, new) nativeLog("extract", t, off, els, new and new[1]) end +local function nativeRemove(t, off, els, new) nativeLog("remove", t, off, els, new and new[1]) end +local function nativeMove(t, from, els, to) nativeLog("move", t, from .. "→" .. to, els) end +local function nativeDispose(e) D.native[#D.native+1] = "dispose(" .. tostring(e.name) .. ")" end + +-- ── 부기 (dispatch-core-plan.md "저장 위치") ───────────────────────────── +local books, blockers = weakK(), weakK() +local function getBookkeeping(ownerKey) + local bk = books[ownerKey] + if not bk then + bk = { lengthList = {}, sourceList = {}, observers = {}, N = nil, + offsetCache = {}, offsetCacheValidUpTo = 0, offsetSetUpTo = 0, + recomputeBlocker = q.Blocker(), + indexOfElement = weakK() } -- H-145 weak-key + books[ownerKey] = bk + end + return bk +end +local function getBlocker(ownerKey) + local b = blockers[ownerKey] + if not b then b = q.Blocker(); blockers[ownerKey] = b end + return b +end +D.getBookkeeping, D.getBlocker, D.books = getBookkeeping, getBlocker, books + +local function contribution(bk, i) + local v = bk.lengthList[i] + return (if isState(v) then v:Get() else v) +end + +function D.getOffsetAt(ownerKey, at) + local bk = getBookkeeping(ownerKey) + if bk.offsetCacheValidUpTo == 0 then + bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0 + bk.offsetCacheValidUpTo = 1 + end + if at <= bk.offsetCacheValidUpTo then + return bk.offsetCache[at] + end + local cur = bk.offsetCache[bk.offsetCacheValidUpTo] + for i = bk.offsetCacheValidUpTo, at - 1 do + if bk.lengthList[i] == nil then + error("Dispatch.getOffsetAt: lengthList[" .. i .. "] is nil — bookkeeping is broken", 1) + end + cur += contribution(bk, i) + bk.offsetCache[i + 1] = cur + end + bk.offsetCacheValidUpTo = at + return cur +end + +D.log = {} +D.recomputes = 0 +local function recompute(ownerKey, bk) + D.recomputes += 1 + table.insert(D.log, "recompute(" .. tostring(ownerKey.name) .. ")") + local sum = 0 + bk.recomputeBlocker:On() + local prefix, i = {}, 1 + while i <= (bk.N or 0) do + prefix[i] = sum + local offset = bk.sourceList[i] + if offset == nil then + error("Dispatch.recompute: sourceList[" .. i .. "] is nil — bookkeeping is broken", 1) + end + local abs = D.getOffsetAt(ownerKey, i) + bk.offsetSetUpTo = i + if offset ~= None and offset:Get() ~= abs then + offset:Set(abs) + end + if bk.offsetSetUpTo < i then -- H-124: 되감기 판정이 읽기보다 먼저 + i = math.max(bk.offsetSetUpTo, 1) + sum = prefix[i] + continue + end + local v = bk.lengthList[i] + sum += (if isState(v) then v:Get() else v) + i += 1 + end + bk.offsetSetUpTo = bk.N or 0 + bk.recomputeBlocker:OffWithoutEmit() + if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then + ownerKey.Length:Set(sum) + end +end +D.recompute = recompute + +function D.setLength(ownerKey, i, len, anchor, element) + anchor = anchor or ownerKey + local bk = getBookkeeping(ownerKey) + local blocker = getBlocker(ownerKey) + local oldObserver = bk.observers[i] + if oldObserver then q.unbindLifetime(oldObserver); bk.observers[i] = nil end + bk.lengthList[i] = len + bk.N = math.max(bk.N or 0, i) + if element ~= nil then bk.indexOfElement[element] = i end + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, i) + bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, i) + local function gatedRecompute() + local cur = if element ~= nil then bk.indexOfElement[element] else i + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, cur) + bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, cur) + if blocker:IsOn() then return end + if bk.recomputeBlocker:IsOn() then return end + recompute(ownerKey, bk) + end + if isState(len) then + local observer = len:Observer(gatedRecompute) + q.bindLifetime(anchor, observer) + bk.observers[i] = observer + else + gatedRecompute() + end +end + +function D.setOffsetSource(ownerKey, i, source) + local bk = getBookkeeping(ownerKey) + bk.sourceList[i] = source + if source == None then return end + local offset = D.getOffsetAt(ownerKey, i) + if source:Get() ~= offset then source:Set(offset) end +end + +-- ── splice / reindex (slot-plan.md 요구 목록) ──────────────────────────── +local function reindexFrom(self, from) + local bk = getBookkeeping(self) + for i = from, #self._elements do bk.indexOfElement[self._elements[i]] = i end +end +local function spliceArraysUp(self, index) + local bk = getBookkeeping(self) + local N = bk.N or 0 + for i = N, index, -1 do + bk.lengthList[i + 1] = bk.lengthList[i]; bk.sourceList[i + 1] = bk.sourceList[i] + bk.observers[i + 1] = bk.observers[i] + end + bk.lengthList[index] = 0 -- H-5 자리표시자 + bk.sourceList[index] = None + bk.observers[index] = nil -- Q3: 비운 자리는 세 배열 전부 + bk.N = N + 1 + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, index - 1) + bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, index - 1) +end +local function spliceArraysDown(self, index) + local bk = getBookkeeping(self) + local N = bk.N + for i = index, N - 1 do + bk.lengthList[i] = bk.lengthList[i + 1]; bk.sourceList[i] = bk.sourceList[i + 1] + bk.observers[i] = bk.observers[i + 1] + end + bk.lengthList[N], bk.sourceList[N], bk.observers[N] = nil, nil, nil + bk.N = N - 1 + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, index - 1) + bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, index - 1) +end +D.spliceArraysUp, D.spliceArraysDown, D.reindexFrom = spliceArraysUp, spliceArraysDown, reindexFrom + +-- ── 소유권 (slot-plan.md "요소 소유권") — 최소형 ───────────────────────── +local elementOwner = setmetatable({}, {__mode = "kv"}) -- slot-plan.md: elementOwner는 전부 SetWeak(값도 weak) — 값이 키(요소)를 되참조하므로 강하면 샌다(H-71) +local function claimOwner(element, slot, fromDetached) + local cur = elementOwner[element] + if cur ~= nil and cur ~= slot then error("Slot: element already mounted elsewhere", 2) end + if cur == slot and not fromDetached then error("Slot: element already in this Slot", 2) end + elementOwner[element] = slot +end +local function releaseOwner(element, slot) + if elementOwner[element] == slot then elementOwner[element] = nil end +end + +-- ── Slot 값 타입 (Q2 생성자) ───────────────────────────────────────────── +local makeBaseObserver, materializeSlotTree, mountSlotTree, attachSlot, unmountSlotTree, destroySlotTree +local rawAdd, rawRemove, rawUnmount, rawDetach, rawReplace, rawMove + +function D.newSlot(name, elements) + local slot = { __isSlot = true, name = name, _elements = {}, + Length = q.Source(0), Offset = q.Source(0), + _physicalTarget = nil, _mounted = false, _mountedInst = nil } + slot._baseObserver = makeBaseObserver(slot) -- 등록 즉시 1회는 가드가 삼킨다 + for _, e in ipairs(elements or {}) do rawAdd(slot, e) end + return slot +end + +makeBaseObserver = function(slot) + return slot.Offset:Observer(function() + if slot._physicalTarget == nil then return end + local bk = getBookkeeping(slot) + bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0 + table.insert(D.log, "baseObs(" .. tostring(slot.name) .. ")") + if getBlocker(slot):IsOn() or bk.recomputeBlocker:IsOn() then return end + recompute(slot, bk) + end) +end + +local activateList +materializeSlotTree = function(slot, physicalTarget, ownerKey, position) + if slot._destroyed then error("Slot: destroyed Slot cannot be mounted", 2) end + slot._physicalTarget = physicalTarget + local blocker = getBlocker(slot) + blocker:On() + q.bindLifetime(physicalTarget, slot._baseObserver) + D.setOffsetSource(ownerKey, position, slot.Offset) + if slot._listed and not slot._listActivated then + activateList(slot, physicalTarget) + else + if slot._listed then activateList(slot, physicalTarget) end + for i, element in ipairs(slot._elements) do + if isSlot(element) then + materializeSlotTree(element, physicalTarget, slot, i) + else + D.setOffsetSource(slot, i, None) + D.setLength(slot, i, 1, physicalTarget, element) + end + end + end + blocker:OffWithoutEmit() + local bk = getBookkeeping(slot) + if not bk.recomputeBlocker:IsOn() then recompute(slot, bk) end + D.setLength(ownerKey, position, slot.Length, physicalTarget, slot) +end +mountSlotTree = function(slot, physicalTarget) + slot._mounted = true + slot._mountedInst = physicalTarget + local acc = slot.Offset:Get() + for _, element in ipairs(slot._elements) do + if isSlot(element) then + mountSlotTree(element, physicalTarget); acc += element.Length:Get() + else + nativeInsert(physicalTarget, acc, { element }); acc += 1 + end + end +end +attachSlot = function(slot, physicalTarget, ownerKey, position, mount) + materializeSlotTree(slot, physicalTarget, ownerKey, position) + if mount ~= false then mountSlotTree(slot, physicalTarget) end +end +D.materializeSlotTree, D.mountSlotTree, D.attachSlot = materializeSlotTree, mountSlotTree, attachSlot + +unmountSlotTree = function(slot) + local physicalTarget = slot._mountedInst + for i = #slot._elements, 1, -1 do + local element = slot._elements[i] + if isSlot(element) then unmountSlotTree(element) + elseif physicalTarget then nativeExtract(physicalTarget, D.getOffsetAt(slot, i), { element }) end + end + local bk = getBookkeeping(slot) + for _, observer in pairs(bk.observers) do q.unbindLifetime(observer) end + if slot._listObserver then q.unbindLifetime(slot._listObserver) end + if slot._detachCleanup then q.unbindLifetime(slot._detachCleanup) end + q.unbindLifetime(slot._baseObserver) + slot._mounted, slot._mountedInst = false, nil + slot._physicalTarget = nil +end +destroySlotTree = function(slot) + if slot._destroyed then return end + if slot._owned == false then unmountSlotTree(slot); return end + for _, element in ipairs(slot._elements) do + if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end + end + if slot._detached then + for key, element in pairs(slot._detached) do + if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end + slot._detached[key] = nil + end + end + if slot._detachCleanup then q.unbindLifetime(slot._detachCleanup); slot._detachCleanup = nil end + if slot._listObserver then q.unbindLifetime(slot._listObserver) end + q.unbindLifetime(slot._baseObserver) + local bk = getBookkeeping(slot) + for _, observer in pairs(bk.observers) do q.unbindLifetime(observer) end + slot._mounted, slot._mountedInst = false, nil + slot._physicalTarget = nil + slot._destroyed = true +end +D.unmountSlotTree, D.destroySlotTree = unmountSlotTree, destroySlotTree + +-- ── raw* ────────────────────────────────────────────────────────────────── +rawAdd = function(self, element, index, fromDetached) + claimOwner(element, self, fromDetached) + index = index or (#self._elements + 1) + table.insert(self._elements, index, element) + reindexFrom(self, index) + if self._physicalTarget == nil then return index end + spliceArraysUp(self, index) + if isSlot(element) then + attachSlot(element, self._physicalTarget, self, index, self._mounted) + else + D.setOffsetSource(self, index, None) + if self._mounted then nativeInsert(self._mountedInst, D.getOffsetAt(self, index), { element }) end + D.setLength(self, index, 1, self._physicalTarget, element) + end + return index +end +rawReplace = function(self, index, newElement, destroyOld) + local oldElement = self._elements[index] + claimOwner(newElement, self) + releaseOwner(oldElement, self) + self._elements[index] = newElement + local bk0 = getBookkeeping(self) + bk0.indexOfElement[oldElement] = nil + bk0.indexOfElement[newElement] = index + if not self._mounted then + if destroyOld then + if isSlot(oldElement) then destroySlotTree(oldElement) else nativeDispose(oldElement) end + end + if self._physicalTarget ~= nil then + if isSlot(newElement) then attachSlot(newElement, self._physicalTarget, self, index, false) + else + D.setOffsetSource(self, index, None) + D.setLength(self, index, 1, self._physicalTarget, newElement) + end + end + return + end + local offset = D.getOffsetAt(self, index) + if isSlot(oldElement) or isSlot(newElement) then + if isSlot(oldElement) then + if destroyOld then destroySlotTree(oldElement) else unmountSlotTree(oldElement) end + else + (if destroyOld then nativeRemove else nativeExtract)(self._mountedInst, offset, { oldElement }) + end + if isSlot(newElement) then + attachSlot(newElement, self._physicalTarget, self, index, self._mounted); return + end + D.setOffsetSource(self, index, None) + nativeInsert(self._mountedInst, offset, { newElement }) + else + (if destroyOld then nativeRemove else nativeExtract)(self._mountedInst, offset, { oldElement }, { newElement }) + end + D.setLength(self, index, 1, self._physicalTarget, newElement) +end +local function removeLike(self, index, mode) -- mode: "remove" | "unmount" | "detach" + local element = self._elements[index] + local bk = getBookkeeping(self) + if bk.observers[index] then q.unbindLifetime(bk.observers[index]) end + if mode ~= "detach" then releaseOwner(element, self) end + if isSlot(element) then + if mode == "remove" then destroySlotTree(element) else unmountSlotTree(element) end + elseif self._mounted then + if mode == "remove" then nativeRemove(self._mountedInst, D.getOffsetAt(self, index), { element }) + else nativeExtract(self._mountedInst, D.getOffsetAt(self, index), { element }) end + elseif mode == "remove" then + nativeDispose(element) + end + table.remove(self._elements, index) + bk.indexOfElement[element] = nil + reindexFrom(self, index) + if self._physicalTarget ~= nil then + spliceArraysDown(self, index) + if not (getBlocker(self):IsOn() or bk.recomputeBlocker:IsOn()) then recompute(self, bk) end + end +end +rawRemove = function(self, index) removeLike(self, index, "remove") end +rawUnmount = function(self, index) removeLike(self, index, "unmount") end +rawDetach = function(self, index) removeLike(self, index, "detach") end +-- rawMove — `H-29` 규약 1~6을 옮긴 것(의사코드 없음). 치환: _elements/indexOfElement/lengthList/ +-- sourceList/observers 같은 순열, N 불변, 두 필드 minPos-1, 이동 구간 setLength 재등록, 물리는 _mounted로. +rawMove = function(self, from, to) + if from == to then return end + local bk = getBookkeeping(self) + local element = table.remove(self._elements, from) + table.insert(self._elements, to, element) + reindexFrom(self, math.min(from, to)) + if self._physicalTarget == nil then return end + local function permute(t) + local v = table.remove(t, from); table.insert(t, to, v) + end + -- observers 배열은 구멍이 있을 수 있어 table.remove가 안 맞는다 — 수동 순열 + local newObs = {} + for i = 1, bk.N do newObs[i] = bk.observers[i] end + local mv = newObs[from]; table.remove(newObs, from); table.insert(newObs, to, mv) + for i = 1, bk.N do bk.observers[i] = newObs[i] end + permute(bk.lengthList); permute(bk.sourceList) + local minPos = math.min(from, to) + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, minPos - 1) + bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, minPos - 1) + if self._mounted then + local leaves = {} + local function collect(e) if isSlot(e) then for _, c in ipairs(e._elements) do collect(c) end else leaves[#leaves+1] = e end end + collect(element) + nativeMove(self._mountedInst, from, leaves, to) + end + for p = minPos, math.max(from, to) do -- 규약 4: 이동 구간 setLength 재등록 + D.setLength(self, p, bk.lengthList[p], self._physicalTarget, self._elements[p]) + end +end +D.rawAdd, D.rawRemove, D.rawUnmount, D.rawDetach, D.rawReplace, D.rawMove = + rawAdd, rawRemove, rawUnmount, rawDetach, rawReplace, rawMove + +-- ── :List / activateList / reconcile (slot-plan.md "구현", H-136 배치) ─── +local function indexOfRaw(self, element) return getBookkeeping(self).indexOfElement[element] end +function D.List(slot, data, updateFn, keyFn) + slot._listed = true + slot._listData, slot._updateFn = data, updateFn + slot._keyFn = keyFn or function(_, i) return i end + if slot._physicalTarget then + local blocker = getBlocker(slot); blocker:On() + activateList(slot, slot._physicalTarget) + blocker:OffWithoutEmit() + local bk = getBookkeeping(slot) + if not bk.recomputeBlocker:IsOn() then recompute(slot, bk) end + end + return slot +end +activateList = function(self, physicalTarget) + if self._listActivated then + if self._listObserver then q.bindLifetime(physicalTarget, self._listObserver) end + q.bindLifetime(physicalTarget, self._detachCleanup) + return + end + self._listActivated = true + local keyFn, updateFn = self._keyFn, self._updateFn + local offset = self.Offset + local mounted, userdata, prevKeys = {}, {}, {} + local function settle(key, result, detach, slotPos) + local wasMounted = mounted[key] + local detached = self._detached + local wasDetached = detached and detached[key] + local prev = wasMounted or wasDetached + if detach then + if wasMounted ~= nil then + local idx = indexOfRaw(self, wasMounted) + rawDetach(self, idx) + self._detached = self._detached or {} + self._detached[key], mounted[key] = wasMounted, nil + end + elseif result == nil then + if prev ~= nil then + if wasDetached ~= nil then + if isSlot(prev) then destroySlotTree(prev) else nativeDispose(prev) end + else + rawRemove(self, indexOfRaw(self, wasMounted)) + end + end + mounted[key] = nil + if detached then detached[key] = nil end + elseif result == prev then + if wasDetached ~= nil then + detached[key] = nil + rawAdd(self, prev, slotPos, true) + mounted[key] = prev + else + local idx = indexOfRaw(self, wasMounted) + if idx ~= slotPos then rawMove(self, idx, slotPos) end + end + else + local element = result + if wasDetached ~= nil then + if isSlot(prev) then destroySlotTree(prev) else nativeDispose(prev) end + detached[key] = nil + rawAdd(self, element, slotPos) + elseif wasMounted ~= nil then + local idx = indexOfRaw(self, wasMounted) + rawReplace(self, idx, element, true) + if idx ~= slotPos then rawMove(self, idx, slotPos) end + else + rawAdd(self, element, slotPos) + end + mounted[key] = element + end + end + local function reconcile(items) + local keys, seen = {}, {} + for i, item in ipairs(items) do + local key = keyFn(item, i) + if seen[key] then error("Slot:List — duplicate key: " .. tostring(key)) end + seen[key] = true; keys[i] = key + end + local blocker = getBlocker(self) + local ownsGate = not blocker:IsOn() + if ownsGate then blocker:On() end + local slotPos = 0 + for i, item in ipairs(items) do + local key = keys[i] + local prev = mounted[key] or (self._detached and self._detached[key]) + local candidateSlot = slotPos + 1 + local physIndex = D.getOffsetAt(self, candidateSlot) - offset:Get() + 1 + local result, ud = updateFn(item, physIndex, offset, prev, userdata[key]) + if result == None then result = nil end + local detach = (result == Detach) + if detach then result = nil end + if result ~= nil then slotPos = candidateSlot end + prevKeys[key] = true + settle(key, result, detach, slotPos) + userdata[key] = ud + end + for key in pairs(table.clone(prevKeys)) do + if not seen[key] then + local prev = mounted[key] or (self._detached and self._detached[key]) + local result, ud = updateFn(KeyGone, 0, offset, prev, userdata[key]) + if result == None then result = nil end + local detach = (result == Detach) + if detach then result = nil end + if result ~= nil then error("Slot:List — KeyGone accepts only nil/None or Detach") end + settle(key, result, detach, 0) + userdata[key] = ud + prevKeys[key] = nil + end + end + if ownsGate then + blocker:OffWithoutEmit() + local bk = getBookkeeping(self) + if not bk.recomputeBlocker:IsOn() then recompute(self, bk) end + end + end + self._detachCleanup = q.Effect(function() + return function() + if not self._detached then return end + for key, element in pairs(self._detached) do + releaseOwner(element, self) + if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end + self._detached[key] = nil + end + end + end) + q.bindLifetime(physicalTarget, self._detachCleanup) + local data = self._listData + if isState(data) then + local observer = data:Observer(function() reconcile(data:Get()) end) + self._listObserver = observer + q.bindLifetime(physicalTarget, observer) + else + reconcile(data) + end +end + +-- ── 최상위 (Dispatch.drive + SlotHandler 흉내) ──────────────────────────── +-- 최상위 배열 자리 k에 Slot을 붙인다: SlotHandler.process의 마운트 + retractor 반환. +function D.mountTop(inst, slot, k) + k = k or 1 + local bl = getBlocker(inst); bl:On() + attachSlot(slot, inst, inst, k) + q.bindLifetime(inst, slot) + bl:OffWithoutEmit() + local bk = getBookkeeping(inst) + if not bk.recomputeBlocker:IsOn() then recompute(inst, bk) end + return function(nextValue) -- retractor: State 교체 = 언마운트(파괴 아님) + if nextValue ~= slot then + unmountSlotTree(slot) + q.unbindLifetime(slot) + D.setOffsetSource(inst, k, None) + D.setLength(inst, k, 0) -- 해제 호출엔 요소 인자가 없다(H-145) + end + end +end +-- 배열 자리 k에 plain 요소(InstanceChildHandler 흉내): None → Parent → setLength(1) +function D.mountChild(inst, child, k) + D.setOffsetSource(inst, k, None) + nativeInsert(inst, k - 1, { child }) + D.setLength(inst, k, 1, inst) +end +D.getBlockerOf = getBlocker +return D diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/dispatch9.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/dispatch9.luau new file mode 100644 index 0000000..224cd77 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/dispatch9.luau @@ -0,0 +1,289 @@ +--!nocheck +-- 9라운드 참조 구현 — base/dispatch-core-plan.md(getOffsetAt / "두 필드" / 무효화 표 / +-- recompute(되감기·클램프·recomputeBlocker) / setLength(토큰·두 게이트) / setOffsetSource) +-- + base/slot-plan.md(materializeSlotTree의 _baseObserver·꼬리 게이트, unmountSlotTree, +-- rawAdd, rawRemove, spliceArraysUp/Down 요구 목록)을 옮긴 전사물. +-- 물리 op(native*)은 no-op이고, 요소는 {name=} 테이블(길이 1) 또는 Slot. +local q = require("./core9") +local D = {} +D.remountFix = "ctor" -- "none" | "a"(materialize 진입부 0,0) | "c"(bind/blocker를 emit 위로) +local None = setmetatable({}, {__tostring = function() return "None" end}) +D.None = None +local isState = q.isState +local function isSlot(x) return type(x) == "table" and x.__isSlot == true end +D.isSlot = isSlot + +local books, blockers = {}, {} +local function getBookkeeping(ownerKey) + local bk = books[ownerKey] + if not bk then + bk = { lengthList = {}, sourceList = {}, observers = {}, indexOfElement = {}, N = nil, + offsetCache = {}, offsetCacheValidUpTo = 0, offsetSetUpTo = 0, + recomputeBlocker = q.Blocker() } + books[ownerKey] = bk + end + return bk +end +local function getBlocker(ownerKey) + local b = blockers[ownerKey] + if not b then b = q.Blocker(); blockers[ownerKey] = b end + return b +end +D.getBookkeeping, D.getBlocker = getBookkeeping, getBlocker + +local function contribution(bk, i) + local v = bk.lengthList[i] + return (if isState(v) then v:Get() else v) +end + +function D.getOffsetAt(ownerKey, at) + local bk = getBookkeeping(ownerKey) + if bk.offsetCacheValidUpTo == 0 then + bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0 + bk.offsetCacheValidUpTo = 1 + end + if at <= bk.offsetCacheValidUpTo then + return bk.offsetCache[at] + end + local cur = bk.offsetCache[bk.offsetCacheValidUpTo] + for i = bk.offsetCacheValidUpTo, at - 1 do + if bk.lengthList[i] == nil then + error("Dispatch.getOffsetAt: lengthList[" .. i .. "]가 nil — 부기가 깨졌음") + end + cur += contribution(bk, i) + bk.offsetCache[i + 1] = cur + end + bk.offsetCacheValidUpTo = at + return cur +end + +D.log = {} +local function recompute(ownerKey, bk) + table.insert(D.log, "recompute(" .. tostring(ownerKey.name) .. ")") + local sum = 0 + bk.recomputeBlocker:On() + local prefix, i = {}, 1 + while i <= (bk.N or 0) do + prefix[i] = sum + local offset = bk.sourceList[i] + if offset == nil then + error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)") + end + local abs = D.getOffsetAt(ownerKey, i) + bk.offsetSetUpTo = i + if offset ~= None and offset:Get() ~= abs then + offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리 + end + -- [Q1 (a), 사용자 확정] 되감기 판정이 `lengthList[i]` 읽기보다 **앞**. + -- 되감으면 `sum`은 `prefix[i]`로 복원되므로 새로 만든 sum은 어차피 버려진다. + if bk.offsetSetUpTo < i then + i = math.max(bk.offsetSetUpTo, 1) + sum = prefix[i] + continue + end + local v = bk.lengthList[i] + sum += (if isState(v) then v:Get() else v) + i += 1 + end + bk.offsetSetUpTo = bk.N or 0 + bk.recomputeBlocker:OffWithoutEmit() + if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then + ownerKey.Length:Set(sum) + end +end +D.recompute = recompute + +function D.setLength(ownerKey, i, len, anchor, element) -- [Q3] 5번째 = 그 자리의 요소 + anchor = anchor or ownerKey + local bk = getBookkeeping(ownerKey) + local blocker = getBlocker(ownerKey) + local oldObserver = bk.observers[i] + if oldObserver then q.unbindLifetime(oldObserver); bk.observers[i] = nil end + bk.lengthList[i] = len + bk.N = math.max(bk.N or 0, i) + if element ~= nil then bk.indexOfElement[element] = i end + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, i) + bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, i) + local function gatedRecompute() + local cur = if element ~= nil then bk.indexOfElement[element] else i + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, cur) + bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, cur) + if blocker:IsOn() then return end + if bk.recomputeBlocker:IsOn() then return end + recompute(ownerKey, bk) + end + if isState(len) then + local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회도 게이팅 + q.bindLifetime(anchor, observer) + bk.observers[i] = observer + else + gatedRecompute() + end +end + +function D.setOffsetSource(ownerKey, i, source) + local bk = getBookkeeping(ownerKey) + bk.sourceList[i] = source + if source == None then return end + local offset = D.getOffsetAt(ownerKey, i) + if source:Get() ~= offset then source:Set(offset) end +end + +-- ── splice (slot-plan.md 요구 목록: lengthList/sourceList/observers/tokens/indexOfToken/N, 두 필드 index-1) ── +local function spliceArraysUp(self, index) + local bk = getBookkeeping(self) + local N = bk.N or 0 + for i = N, index, -1 do + bk.lengthList[i + 1] = bk.lengthList[i]; bk.sourceList[i + 1] = bk.sourceList[i] + bk.observers[i + 1] = bk.observers[i] + end + bk.lengthList[index] = 0 -- H-5 자리표시자 + bk.sourceList[index] = None + bk.observers[index] = nil -- [Q3] 비운 자리는 세 배열 전부 + bk.N = N + 1 + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, index - 1) + bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, index - 1) +end +local function spliceArraysDown(self, index) + local bk = getBookkeeping(self) + local N = bk.N + for i = index, N - 1 do + bk.lengthList[i] = bk.lengthList[i + 1]; bk.sourceList[i] = bk.sourceList[i + 1] + bk.observers[i] = bk.observers[i + 1] + end + bk.lengthList[N], bk.sourceList[N], bk.observers[N] = nil, nil, nil + bk.N = N - 1 + bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, index - 1) + bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, index - 1) +end +D.spliceArraysUp, D.spliceArraysDown = spliceArraysUp, spliceArraysDown + +-- ── Slot 최소형 ────────────────────────────────────────────────────────── +local makeBaseObserver +function D.newSlot(name, elements) + local slot = { __isSlot = true, name = name, _elements = elements or {}, + Length = q.Source(0), Offset = nil, _physicalTarget = nil, _mounted = false } + if D.remountFix == "ctor" then + -- Length와 **같은 자리**에서 Offset을 만든다(공개 필드 대칭). + slot.Offset = q.Source(0) + slot._baseObserver = makeBaseObserver(slot) -- 등록 즉시 1회는 아래 가드가 삼킨다 + end + return slot +end + +local materializeSlotTree, unmountSlotTree +makeBaseObserver = function(slot) + return slot.Offset:Observer(function() + -- ⭐ 미실체화면 할 일이 없다 — 생성자에서 만들 때의 "등록 즉시 1회"를 여기서 삼킨다 + -- (그래야 bk/recomputeBlocker가 eager 생성되지 않는다). + if slot._physicalTarget == nil then return end + local bk = getBookkeeping(slot) + bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0 + table.insert(D.log, "baseObs(" .. tostring(slot.name) .. ")") + if getBlocker(slot):IsOn() or bk.recomputeBlocker:IsOn() then return end + recompute(slot, bk) + end) +end + +materializeSlotTree = function(slot, physicalTarget, ownerKey, position) + slot._physicalTarget = physicalTarget + local offsetSource = slot.Offset or q.Source(0) + slot.Offset = offsetSource + local blocker = getBlocker(slot) + local function installBase() + if slot._baseObserver then + q.bindLifetime(physicalTarget, slot._baseObserver) + else + slot._baseObserver = makeBaseObserver(slot) + q.bindLifetime(physicalTarget, slot._baseObserver) + end + end + if D.remountFix == "ctor" then + -- 분기 없음 — 관측자는 이미 있다. 게이트 → 바인드 → emit. + blocker:On() + q.bindLifetime(physicalTarget, slot._baseObserver) + D.setOffsetSource(ownerKey, position, offsetSource) + elseif D.remountFix == "c" then + blocker:On() -- (c): 게이트와 관측자를 emit **위로** + installBase() + D.setOffsetSource(ownerKey, position, offsetSource) + else + if D.remountFix == "a" then -- (a): 진입부 명시 초기화 + local bk0 = getBookkeeping(slot) + bk0.offsetCacheValidUpTo, bk0.offsetSetUpTo = 0, 0 + end + D.setOffsetSource(ownerKey, position, offsetSource) + blocker:On() + installBase() + end + for i, element in ipairs(slot._elements) do + if isSlot(element) then + materializeSlotTree(element, physicalTarget, slot, i) + else + D.setOffsetSource(slot, i, None) + D.setLength(slot, i, 1, physicalTarget, element) + end + end + blocker:OffWithoutEmit() + local bk = getBookkeeping(slot) + if not bk.recomputeBlocker:IsOn() then recompute(slot, bk) end + D.setLength(ownerKey, position, slot.Length, physicalTarget, slot) +end +D.materializeSlotTree = materializeSlotTree + +unmountSlotTree = function(slot) + for i = #slot._elements, 1, -1 do + local element = slot._elements[i] + if isSlot(element) then unmountSlotTree(element) end + end + local bk = getBookkeeping(slot) + for _, observer in pairs(bk.observers) do q.unbindLifetime(observer) end + if slot._baseObserver then q.unbindLifetime(slot._baseObserver) end + slot._mounted, slot._mountedInst, slot._physicalTarget = false, nil, nil +end +D.unmountSlotTree = unmountSlotTree + +-- 요소 소유권/래핑은 생략(트레이스 대상 아님) +local function reindexFrom(self, from) + local bk = getBookkeeping(self) + for i = from, #self._elements do bk.indexOfElement[self._elements[i]] = i end +end +function D.rawAdd(self, element, index) + index = index or (#self._elements + 1) + table.insert(self._elements, index, element) + reindexFrom(self, index) + if self._physicalTarget == nil then return index end + spliceArraysUp(self, index) + if isSlot(element) then + materializeSlotTree(element, self._physicalTarget, self, index) + else + D.setOffsetSource(self, index, None) + D.setLength(self, index, 1, self._physicalTarget, element) + end + return index +end + +function D.rawRemove(self, index) + local element = self._elements[index] + local bk = getBookkeeping(self) + if bk.observers[index] then q.unbindLifetime(bk.observers[index]) end + if isSlot(element) then unmountSlotTree(element) end -- (파괴 대신 언마운트로 흉내) + table.remove(self._elements, index) + bk.indexOfElement[element] = nil + reindexFrom(self, index) + spliceArraysDown(self, index) + if not (getBlocker(self):IsOn() or bk.recomputeBlocker:IsOn()) then + recompute(self, bk) + end +end + +-- 최상위 mount: inst를 owner로, slot을 position 1에 +function D.mountTop(inst, slot) + local bl = getBlocker(inst); bl:On() + materializeSlotTree(slot, inst, inst, 1) + bl:OffWithoutEmit() + local bk = getBookkeeping(inst) + if not bk.recomputeBlocker:IsOn() then recompute(inst, bk) end +end + +return D diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/e1_errlevel.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/e1_errlevel.luau new file mode 100644 index 0000000..18cd4c1 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/e1_errlevel.luau @@ -0,0 +1,14 @@ +--!nocheck +-- quad 확정 의사코드는 전부 level 인자 없는 error(msg) — level 1이 기본 +local Slot = {} +function Slot.Add(self, index) -- quad 내부(라이브러리) 코드 + error("Slot:Add — index가 범위 밖(1..3): " .. tostring(index)) +end +function Slot.AddL2(self, index) + error("Slot:Add — index가 범위 밖(1..3): " .. tostring(index), 2) +end + +local ok1, e1 = pcall(function() Slot.Add(nil, 9) end) -- 사용자 코드 줄 +local ok2, e2 = pcall(function() Slot.AddL2(nil, 9) end) +print("level 기본(1):", e1) +print("level 2 :", e2) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/g1_keyof_empty.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/g1_keyof_empty.luau new file mode 100644 index 0000000..f7a283d --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/g1_keyof_empty.luau @@ -0,0 +1,28 @@ +--!strict +type function CheckReservedKeys(keys: type) + if keys:is("never") then print("keys is never") return types.singleton(true) end + local list = if keys:is("union") then keys:components() else {keys} + for _, k in list do + if k:is("singleton") and (k:value() == "Of" or k:value() == "Names" or k:value() == "__reservedCheck") then + print(`quad.Store: "{k:value()}" is a reserved key`) + return types.never + end + end + return types.singleton(true) +end +type Source = { Get: (self: any) -> T, Set: (self: any, T) -> () } +type Store = T & { + Of: (self: any, name: string) -> Source, + Names: (self: any) -> { string }, + __reservedCheck: CheckReservedKeys>, +} +local function Store(defaults: T?): Store return (nil :: any) end +-- 빈 Store +local empty = Store({} :: {}) +local _n1: { string } = empty:Names() +-- 키 있는 Store (양성) +local st = Store({ hp = (nil :: any) :: Source }) +local _h: number = st.hp:Get() +-- 음성 대조군 +local bad = Store({ Of = (nil :: any) :: Source }) +local _o = bad.Names diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/g1b_keyof_empty_clean.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/g1b_keyof_empty_clean.luau new file mode 100644 index 0000000..4d6cf33 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/g1b_keyof_empty_clean.luau @@ -0,0 +1,17 @@ +--!strict +type function CheckReservedKeys(keys: type) + local list = if keys:is("union") then keys:components() else {keys} + for _, k in list do + if k:is("singleton") and (k:value() == "Of" or k:value() == "Names" or k:value() == "__reservedCheck") then + print(`quad.Store: "{k:value()}" is a reserved key`) + return types.never + end + end + return types.singleton(true) +end +type Source = { Get: (self: any) -> T, Set: (self: any, T) -> () } +type Store = T & { Of: (self: any, name: string) -> Source, Names: (self: any) -> { string }, __reservedCheck: CheckReservedKeys> } +local function Store(defaults: T?): Store return (nil :: any) end +local empty = Store({} :: {}) +local _n1: { string } = empty:Names() +local _d: Source = empty:Of("dyn") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/g2_arity.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/g2_arity.luau new file mode 100644 index 0000000..29caecc --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/g2_arity.luau @@ -0,0 +1,9 @@ +--!strict +-- lifecycle-hooks-plan.md: "Luau 함수 타입은 파라미터에 반변이라 인자를 덜 받는 함수가 대입 가능" +type Ref = { Value: number } +local function OnCreated(fn: (inst: string, ref: Ref) -> ()): () return end +OnCreated(function(inst) print(inst) end) -- 1-인자 람다 +OnCreated(function(inst: string) print(inst) end) -- 주석 붙은 1-인자 +OnCreated(function(inst, ref) print(inst, ref.Value) end) +local f: (number, string) -> () = function(x: number) end +local g: (number) -> () = function(x: number, y: string) end -- 더 받는 쪽: 에러여야 정상 diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/g3_for_in_table.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/g3_for_in_table.luau new file mode 100644 index 0000000..9495720 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/g3_for_in_table.luau @@ -0,0 +1,4 @@ +--!nocheck +local seen = { a = true } +local ok, err = pcall(function() for d in seen do print(d) end end) +print("for d in seen do (일반화 반복):", ok, err) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/g3b_for_in_strict.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/g3b_for_in_strict.luau new file mode 100644 index 0000000..c5fd9b8 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/g3b_for_in_strict.luau @@ -0,0 +1,4 @@ +--!strict +local seen: { [string]: boolean } = { a = true } +for d in seen do print(d) end +for d in pairs(seen) do print(d) end diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t10_subscribe_gc.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t10_subscribe_gc.luau new file mode 100644 index 0000000..dcb4dcc --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t10_subscribe_gc.luau @@ -0,0 +1,25 @@ +--!nocheck +local q = require("./core") +local SubscribedObservers = {} +local function Subscribe(o) SubscribedObservers[o] = true; o.Subscribed = true; return o end + +-- Observer가 상류를 되참조하지 않는 형태(확정 서술: 구독 엣지는 하류로 weak, +-- Observer를 살리는 건 gchold 또는 전역 레지스트리) +local Obs = {} +Obs.__index = Obs +function Obs.new(state, fn) local o = setmetatable({fn = fn, fires = 0}, Obs); state:_addSub(o); return o end +function Obs:_receive(from) self.fires += 1; self.fn(self, from) end + +local A = q.Source(1) +local fires = 0 +local o +do + local mid = q.State(function(self, prev, a) return a:Get() * 2 end, {A}, "mid") + o = Subscribe(Obs.new(mid, function() fires += 1 end)) +end +collectgarbage("collect"); collectgarbage("collect") +A:Set(2) +print("Observer 발화:", fires, " ← 공개 계약(\"예외 없이 그냥 계속 돎\")대로면 1") +local n = 0; for _ in A.subs do n += 1 end +print("A의 구독자 수:", n, " ← 중간 State가 수거됐으면 0") +print("Observer는 레지스트리에 살아있다:", SubscribedObservers[o] == true, "| 그러나 다시는 안 울린다") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t11_effect_deps.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t11_effect_deps.luau new file mode 100644 index 0000000..9a89bc2 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t11_effect_deps.luau @@ -0,0 +1,42 @@ +--!nocheck +local q = require("./core9") +local inst = q.newInst("inst") +-- (1) Ref dep: onRefFire → fire(ref) → Update(ref) → Rerun +local r = q.Ref(nil) +local runs, cleanups = 0, 0 +local e = q.Effect(function(self) runs += 1; return function() cleanups += 1 end end, r) +print("생성 직후 runs", runs, "(기대 1)") +r:Set(1) -- 아직 바인드 안 됨 → canExecute 거짓 → 무시 +print("미바인드 Set 후 runs", runs, "(기대 1)") +q.bindLifetime(inst, e) -- 캐치업: Refresh()가 r의 리비전 변화를 봄 → Rerun +print("바인드 캐치업 후 runs", runs, "(기대 2) cleanups", cleanups, "(기대 1)") +r:Set(2) +print("바인드 뒤 Set 후 runs", runs, "(기대 3)") +r:Set(2) -- 같은 값이라도 Set은 새 리비전 → 한 번 더 +print("같은 값 Set 후 runs", runs, "(기대 4)") +-- (2) 다이아몬드: A → b, A → c, Effect(fn, b, c) — A:Set 한 번에 fn 한 번 +local A = q.Source(1) +local b = A:Compute(function(_) return A:Get() + 1 end) +local c = A:Compute(function(_) return A:Get() * 2 end) +local n = 0 +local e2 = q.Effect(function() n += 1 end, b, c) +q.bindLifetime(inst, e2) +A:Set(5) +print("다이아몬드 A:Set 후 n", n, "(기대 2 = 설치 1 + 파동 1)") +-- (3) 혼합 deps: Ref + State 한 Effect에서, 각각 독립 발화 +local m = 0 +local e3 = q.Effect(function() m += 1 end, r, b) +q.bindLifetime(inst, e3) +r:Set(3); A:Set(6) +print("혼합 deps r:Set + A:Set 후 m", m, "(기대 3)") +-- (4) 무인자 state:Observer() 내부 콜백이 3-인자 루프에서 살아남는가 +local s = q.Source(0) +local o = s:Observer() -- function(targetState) targetState:Get() end +o:Subscribe() +local ok, err = pcall(function() s:Set(1) end) +print("무인자 Observer 전파:", if ok then "OK" else "ERROR " .. tostring(err)) +-- (5) leaf 죽음 → cleanup 1회, 이후 Ref:Set 무시 +q.destroyInst(inst) +print("destroy 후 cleanups", cleanups, "(기대: 직전 + 1)") +local before = runs; r:Set(9) +print("죽은 뒤 Set → runs 변화", runs - before, "(기대 0)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t12_subscribe_failfast.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t12_subscribe_failfast.luau new file mode 100644 index 0000000..e639845 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t12_subscribe_failfast.luau @@ -0,0 +1,26 @@ +--!nocheck +local q = require("./core9") +local s = q.Source(0) +local function try(label, f) local ok, err = pcall(f); print(label, if ok then "→ 통과(no error)" else "→ error: " .. tostring(err)) end +local o1 = s:Observer(function() end) +try("WeakSubscribe 뒤 Unsubscribe", function() o1:WeakSubscribe(); o1:Unsubscribe() end) +local o2 = s:Observer(function() end) +try("Subscribe 뒤 WeakUnsubscribe", function() o2:Subscribe(); o2:WeakUnsubscribe() end) +local o3 = s:Observer(function() end) +try("Subscribe 두 번", function() o3:Subscribe(); o3:Subscribe() end) +local o4 = s:Observer(function() end) +try("구독 안 한 값에 Unsubscribe", function() o4:Unsubscribe() end) +local o5 = s:Observer(function() end) +try("구독 안 한 값에 WeakUnsubscribe", function() o5:WeakUnsubscribe() end) +local o6 = s:Observer(function() end) +try("Subscribe → Unsubscribe → Subscribe (재구독)", function() o6:Subscribe(); o6:Unsubscribe(); o6:Subscribe() end) +-- Effect: 내부 Observer는 WeakSubscribe만. Effect:Subscribe/Unsubscribe가 내부에 손대지 않는지 +local n = 0 +local e = q.Effect(function() n += 1 end, s) +try("Effect:Subscribe", function() e:Subscribe() end) +s:Set(1); print(" Effect 구독 뒤 Set → n", n, "(기대 2)") +try("Effect:Unsubscribe", function() e:Unsubscribe() end) +s:Set(2); print(" Effect 해제 뒤 Set → n", n, "(기대 2)") +-- 내부 Observer에 범용 정리 코드가 Unsubscribe를 부르면? +local inner; for _, v in pairs(e._deps) do inner = v end +try("Effect 내부 Observer에 Unsubscribe", function() inner:Unsubscribe() end) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t13_ref_dedup.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t13_ref_dedup.luau new file mode 100644 index 0000000..8260ca3 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t13_ref_dedup.luau @@ -0,0 +1,20 @@ +--!nocheck +local q = require("./core9") +local r = q.Ref(nil) +local calls = {} +local function fn(v, ref) table.insert(calls, tostring(v) .. "/" .. tostring(ref == r)) end +r:WeakCallback(fn); r:Callback(fn) -- 같은 fn을 약·강 양쪽에 +print("등록 즉시 호출:", table.concat(calls, ","), "(기대 nil/true 두 번 — 등록마다 1회)") +calls = {} +r:Set(7) +print("Set(7) 후 호출:", table.concat(calls, ","), "(기대 7/true 한 번 — 교차 dedup)") +r:Uncallback(fn); calls = {} +r:Set(8) +print("Uncallback 뒤 Set(8):", #calls, "회 (기대 0)") +-- thread 대기자 + 값→리비전→콜백 순서 +local seenRev +local co = coroutine.create(function(ref) seenRev = ref.Revision end) +r.Callbacks[co] = true +local rev0 = r.Revision +r:Set(9) +print("thread가 본 Revision ~= 옛 리비전:", seenRev ~= rev0, "| 소진됨:", r.Callbacks[co] == nil) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t14_effect_state_matrix.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t14_effect_state_matrix.luau new file mode 100644 index 0000000..4fdd354 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t14_effect_state_matrix.luau @@ -0,0 +1,99 @@ +--!nocheck +-- 레인 A: EffectHandle 상태 기계 — 모든 진입점 × 모든 상태의 전이를 실제 값으로 찍는다. +-- 열: installed / cleanup 보유 / .Subscribed / 강한 레지스트리 / 약한 레지스트리 / canExecute / running / pending +local q = require("./core10") +local function row(label, e, extra) print(("%-46s | %s%s"):format(label, q.effectState(e), if extra then " " .. extra else "")) end +local function try(f) local ok, err = pcall(f); return ok, err end + +local S = q.Source(0) +local runs, cleans = 0, 0 +local function mk(...) return q.Effect(function(self) runs += 1; return function() cleans += 1 end end, ...) end + +print("== (1) 생성자 → Subscribe → Unsubscribe → Subscribe(재구독) → WeakUnsubscribe(교차 error) ==") +local e = mk(S) +row("생성 직후", e, ("runs=%d cleans=%d"):format(runs, cleans)) +S:Set(1) +row("미구독 상태 Set(1) — 버려져야", e, ("runs=%d"):format(runs)) +e:Subscribe() +row("Subscribe (Refresh 캐치업 → Rerun 1회)", e, ("runs=%d cleans=%d"):format(runs, cleans)) +S:Set(2) +row("구독 중 Set(2)", e, ("runs=%d cleans=%d"):format(runs, cleans)) +e:Unsubscribe() +row("Unsubscribe (cleanup 1회, installed=false)", e, ("runs=%d cleans=%d"):format(runs, cleans)) +e:Subscribe() +row("재Subscribe (not installed → Rerun)", e, ("runs=%d cleans=%d"):format(runs, cleans)) +print(" Subscribe 두 번:", select(2, try(function() e:Subscribe() end))) +print(" 강구독에 WeakUnsubscribe:", select(2, try(function() e:WeakUnsubscribe() end))) +e:Unsubscribe() + +print("\n== (2) WeakSubscribe → WeakUnsubscribe(관대, cleanup 미소진) → 재WeakSubscribe ==") +local e2 = mk(S) +e2:WeakSubscribe() +row("WeakSubscribe", e2, ("runs=%d cleans=%d"):format(runs, cleans)) +e2:WeakUnsubscribe() +row("WeakUnsubscribe — cleanup 유지, installed 유지", e2, ("runs=%d cleans=%d"):format(runs, cleans)) +e2:WeakUnsubscribe() +row("WeakUnsubscribe 두 번 — 통과(H-133)", e2) +S:Set(3) +row("약해제 상태 Set(3) — 버려져야", e2, ("runs=%d"):format(runs)) +e2:WeakSubscribe() +row("재WeakSubscribe (dep 변했음 → Rerun)", e2, ("runs=%d cleans=%d"):format(runs, cleans)) +local e2b = mk() +e2b:WeakSubscribe(); e2b:WeakUnsubscribe(); local r0 = runs; e2b:WeakSubscribe() +row("deps 없음: Weak해제→재구독 (변화 없음 → no-op)", e2b, ("runs 변화=%d (기대 0)"):format(runs - r0)) +print(" 약구독에 Unsubscribe:", select(2, try(function() e2:Unsubscribe() end))) + +print("\n== (3) leaf: bindLifetime → unbindLifetime(포탈) → 재bind → destroy ==") +local inst, inst2 = q.newInst("inst"), q.newInst("inst2") +local e3 = mk(S) +q.bindLifetime(inst, e3) +row("bindLifetime (Refresh 캐치업: dep 변함 → Rerun)", e3, ("runs=%d cleans=%d"):format(runs, cleans)) +print(" leaf에 Subscribe:", select(2, try(function() e3:Subscribe() end))) +print(" leaf에 WeakSubscribe:", select(2, try(function() e3:WeakSubscribe() end))) +print(" leaf에 Unsubscribe:", select(2, try(function() e3:Unsubscribe() end))) +print(" leaf에 WeakUnsubscribe(관대):", select(2, try(function() e3:WeakUnsubscribe() end))) +row(" ↑ 그 뒤 상태 (installed/cleanup 그대로여야)", e3) +print(" leaf를 다른 inst에 bind:", select(2, try(function() q.bindLifetime(inst2, e3) end))) +q.unbindLifetime(e3) +row("unbindLifetime — cleanup 미소진, installed 유지", e3, ("cleans=%d"):format(cleans)) +S:Set(4) +row("언바인드 상태 Set(4) — 버려져야", e3, ("runs=%d"):format(runs)) +q.bindLifetime(inst2, e3) +row("재bind(포탈) — Refresh true → Rerun", e3, ("runs=%d cleans=%d"):format(runs, cleans)) +q.destroyInst(inst2) +row("inst2 destroy — Destroying → cleanup 1회", e3, ("cleans=%d"):format(cleans)) +S:Set(5) +row("파괴 뒤 Set(5) — 버려져야", e3, ("runs=%d"):format(runs)) +local inst3 = q.newInst("inst3") +q.bindLifetime(inst3, e3) +row("파괴 뒤 재bind — not installed → Rerun (H-65)", e3, ("runs=%d cleans=%d"):format(runs, cleans)) +q.unbindLifetime(e3) +e3:Subscribe() +row("unbind 뒤 Subscribe로 전환 (허용)", e3, ("runs=%d cleans=%d"):format(runs, cleans)) +e3:Unsubscribe() + +print("\n== (4) fn 안 자기 해제 (H-143) ==") +local e4runs, e4cleans = 0, 0 +local S4 = q.Source(0) +local e4; e4 = q.Effect(function(self) + e4runs += 1 + if e4runs == 2 then self:Unsubscribe() end + return function() e4cleans += 1 end +end, S4) +e4:Subscribe() +S4:Set(6) +row("Set → fn 안 Unsubscribe → 반환 cleanup 즉시 소진", e4, ("e4cleans=%d (기대 2: 직전 소진 1 + 즉시 소진 1) runs=%d"):format(e4cleans, e4runs)) +S4:Set(7) +row("죽은 뒤 Set(7) — 버려져야", e4, ("runs=%d"):format(e4runs)) + +print("\n== (5) fn 안 self:Rerun() (재진입 지연) ==") +local n5 = 0 +local e5 = q.Effect(function(self) n5 += 1; if n5 == 1 then self:Rerun() end end) +row("생성자 안 자기 Rerun → pending으로 한 번 더", e5, ("n5=%d (기대 2)"):format(n5)) + +print("\n== (6) fn 안 dep Set (재진입) ==") +local n6, S6 = 0, q.Source(0) +local e6 = q.Effect(function(self) n6 += 1; if n6 == 2 then S6:Set(99) end end, S6) +e6:Subscribe(); S6:Set(1) +row("구독 중 Set → fn이 dep Set → pending 재실행", e6, ("n6=%d (기대 3)"):format(n6)) +e6:Unsubscribe() diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t15_rerun_dead_handle.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t15_rerun_dead_handle.luau new file mode 100644 index 0000000..4818bc5 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t15_rerun_dead_handle.luau @@ -0,0 +1,53 @@ +--!nocheck +-- H-147 재검토: 진입 시점에 이미 죽은 핸들에서 Rerun()이 불리면. +local q = require("./core10") +local S = q.Source(0) +local function row(l, e, x) print(("%-52s | %s %s"):format(l, q.effectState(e), x or "")) end + +print("== 케이스 (1): 직전 cleanup이 self:Unsubscribe()를 부른다 ==") +local runs1, cleans1 = 0, 0 +local e1; e1 = q.Effect(function(self) + runs1 += 1 + return function() cleans1 += 1; if cleans1 == 1 then self:Unsubscribe() end end +end, S) +e1:Subscribe() +S:Set(1) -- Rerun: 루프 머리 _consumeCleanup → cleanup이 Unsubscribe → wasAlive=false → fn 실행(!) +row("Set 후", e1, ("runs=%d (죽은 뒤 fn이 돌았으면 2) cleans=%d"):format(runs1, cleans1)) +S:Set(2) +row("다음 Set — 버려짐", e1, ("runs=%d"):format(runs1)) +e1:Subscribe() -- 재구독: _installed가 true라 resubscribeTail이 Rerun을 생략(Refresh는 true) +row("재Subscribe — Refresh true라 Rerun은 돈다", e1, ("runs=%d cleans=%d"):format(runs1, cleans1)) +e1:Unsubscribe() +row("Unsubscribe — 고아였던 cleanup이 여기서 소진되는가", e1, ("cleans=%d"):format(cleans1)) + +print("\n== 케이스 (2): Unsubscribe 뒤 타이머의 self:Rerun() ==") +local runs2, cleans2 = 0, 0 +local e2 = q.Effect(function(self) runs2 += 1; return function() cleans2 += 1 end end, S) +e2:Subscribe(); e2:Unsubscribe() +row("Unsubscribe 직후", e2, ("runs=%d cleans=%d"):format(runs2, cleans2)) +e2:Rerun() -- 타이머 흉내 +row("죽은 핸들 Rerun() — fn 실행 + cleanup 저장", e2, ("runs=%d cleans=%d"):format(runs2, cleans2)) +collectgarbage() -- 아무도 안 잡으면 cleanup은 영영 안 불린다(사용자가 e2를 잡고 있는 한 남아 있다) +e2:Subscribe() +row("재Subscribe — installed=true, Refresh false → Rerun 생략", e2, ("runs=%d cleans=%d"):format(runs2, cleans2)) +e2:Unsubscribe() +row("Unsubscribe — 죽은 실행의 cleanup이 소진됨", e2, ("cleans=%d (소진됐으면 2)"):format(cleans2)) + +print("\n== 케이스 (3): leaf 파괴 뒤 타이머의 self:Rerun() ==") +local inst = q.newInst("inst") +local runs3, cleans3 = 0, 0 +local e3 = q.Effect(function(self) runs3 += 1; return function() cleans3 += 1 end end, S) +q.bindLifetime(inst, e3) +q.destroyInst(inst) +row("destroy 직후", e3, ("runs=%d cleans=%d"):format(runs3, cleans3)) +e3:Rerun() +row("죽은 leaf 핸들 Rerun()", e3, ("runs=%d cleans=%d BindData(gcconn dead)=%s"):format(runs3, cleans3, tostring(q.BindData[e3] ~= nil))) +local inst2 = q.newInst("inst2") +q.bindLifetime(inst2, e3) +row("재bind — installed=true라 Refresh 없으면 Rerun 생략", e3, ("runs=%d cleans=%d"):format(runs3, cleans3)) +q.destroyInst(inst2) +row("inst2 destroy — 그 cleanup 소진", e3, ("cleans=%d"):format(cleans3)) + +print("\n== 케이스 (0) 대조군: 생성자 최초 Rerun은 죽은 게 아니라 '산 적 없음' ==") +local e0 = q.Effect(function() return function() end end) +row("생성 직후 (installed=true여야 — H-143 회귀 대조)", e0) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t16_gate_resub_refresh.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t16_gate_resub_refresh.luau new file mode 100644 index 0000000..8c68509 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t16_gate_resub_refresh.luau @@ -0,0 +1,100 @@ +--!nocheck +-- 레인 A: state:Gate 유보 × Effect 재구독/재바인드 × EpochMap:Refresh / Peek / Update 조합 +local q = require("./core10") +local function runs(e) return e._n end +local function mkEffect(...) + local e; e = q.Effect(function(self) self._n = (self._n or 0) + 1 end, ...) + return e +end + +print("== (1) 게이트 유보 중 재구독: Refresh가 유보된 변경을 먼저 삼킨다 ==") +local A = q.Source(1) +local b = q.Blocker() +local gated = A:Block(b) +local e = mkEffect(gated) +e:Subscribe() +print("초기 runs", runs(e), "(기대 1)") +b:On() +A:Set(2) -- gated가 유보. Effect엔 아무것도 안 온다 +print("블록 중 A:Set → runs", runs(e), "(기대 1) | gated withheld:", next(gated._withheld) ~= nil) +e:Unsubscribe(); e:Subscribe() -- 재구독 꼬리: Refresh()가 A의 새 리비전을 본다 → Rerun +print("재구독 후 runs", runs(e), "(기대 2 — 게이트가 잡고 있는데도 캐치업이 fn을 돌렸다)") +print(" fn이 읽는 gated:Get() =", gated:Get(), "(게이트는 값은 안 가림)") +b:Off() -- 유보 풀림 → batch {A} → Effect Update(A) → 이미 최신 → 접힘 +print("Off 후 runs", runs(e), "(기대 2 — 접힘, 중복 없음)") + +print("\n== (2) 재바인드(포탈) 캐치업도 게이트를 우회한다 ==") +local A2 = q.Source(1); local b2 = q.Blocker(); local g2 = A2:Block(b2) +local e2 = mkEffect(g2) +local inst = q.newInst("inst") +q.bindLifetime(inst, e2) +b2:On(); A2:Set(2) +q.unbindLifetime(e2) +local inst2 = q.newInst("inst2") +q.bindLifetime(inst2, e2) +print("블록 중 언바인드→재바인드 후 runs", runs(e2), "(기대 2 — 캐치업이 게이트를 우회)") +b2:Off() +print("Off 후 runs", runs(e2), "(기대 2)") + +print("\n== (3) emit(false) 버리기: Effect가 캐치업으로 이미 돌았다면? ==") +local A3 = q.Source(1); local g3 = A3:Gate(function(emit) + return function() end -- 항상 유보(정책이 emit을 안 부름) +end) +local flush3 = nil +-- 정책 밖에서 emit 핸들을 얻으려고 Gate를 다시 감싼다 +local A3b = q.Source(1); local disc +local g3b = A3b:Gate(function(emit) disc = emit; return function() end end) +local e3 = mkEffect(g3b); e3:Subscribe() +A3b:Set(2) +e3:Unsubscribe(); e3:Subscribe() +print("유보 중 재구독 runs", runs(e3), "(기대 2)") +print("emit(false) 반환:", disc(false), "(버릴 게 있었으니 true)") +A3b:Set(3) +print("버린 뒤 새 Set → runs", runs(e3), "(기대 2 — 여전히 유보 중)") +print("emit() 반환:", disc(), "→ runs", runs(e3), "(기대 3)") + +print("\n== (4) 다이아몬드: A→gated, A→plain, Effect(fn, gated, plain) — 비게이트 경로가 게이트를 새게 한다 ==") +local A4 = q.Source(1); local b4 = q.Blocker() +local g4 = A4:Block(b4) +local p4 = A4:Compute(function(s) return s:Get() end) +local e4 = mkEffect(g4, p4); e4:Subscribe() +b4:On(); A4:Set(2) +print("블록 중 A:Set → runs", runs(e4), "(기대 2 — plain 경로가 Effect를 깨웠다; 게이트는 무의미)") +b4:Off() +print("Off 후 runs", runs(e4), "(기대 2 — 접힘)") + +print("\n== (5) 게이트 위의 다이아몬드: A→B→G, A→C→G, 유보 중 두 번 도착 → 정책 두 번, 집합 하나 ==") +local A5 = q.Source(1) +local B5 = A5:Compute(function(s) return s:Get() end) +local C5 = A5:Compute(function(s) return s:Get() end) +local M5 = B5:With(C5) -- fan-in +local policyCalls = 0 +local g5 = M5:Gate(function(emit) return function() policyCalls += 1 end end) +local o5 = g5:Observer(function() end); o5:Subscribe() +A5:Set(2) +local cnt = 0; for _ in pairs(g5._withheld) do cnt += 1 end +print("A:Set 한 번 → 정책 호출", policyCalls, "(다이아몬드가 M5에서 접히면 1) | withheld 크기", cnt) + +print("\n== (6) 게이트 → 게이트 (unfold) 유보 후 각각 풀기 ==") +local A6 = q.Source(1); local b6a, b6b = q.Blocker(), q.Blocker() +local g6a = A6:Block(b6a); local g6b = g6a:Block(b6b) +local fires = 0 +local o6 = g6b:Observer(function(_, _, from) if from ~= nil then fires += 1 end end); o6:Subscribe() +b6a:On(); b6b:On(); A6:Set(2); A6:Set(3) +b6a:Off() +print("안쪽 게이트 풀림 → 바깥 withheld에 합류, 발화", fires, "(기대 0)") +b6b:Off() +print("바깥 풀림 → 발화", fires, "(기대 1)") +A6:Set(4) +print("둘 다 열린 뒤 Set → 발화", fires, "(기대 2)") + +print("\n== (7) 게이트 유보 중 하류 Get()으로 앞당겨 읽기 → 풀릴 때 규칙 2(통지만) ==") +local A7 = q.Source(1); local b7 = q.Blocker(); local g7 = A7:Block(b7) +local d7 = g7:Compute(function(s) return s:Get() * 10 end) +local n7 = 0 +local o7 = d7:Observer(function() n7 += 1 end); o7:Subscribe() +b7:On(); A7:Set(2) +print("유보 중 d7:Get() =", d7:Get(), "(기대 20 — 값은 안 가림) recomputes", d7.recomputes) +local rc = d7.recomputes +b7:Off() +print("Off 후 Observer 발화", n7, "(기대 1 — 통지는 온다) | 재계산 추가", d7.recomputes - rc, "(기대 0 — 규칙 2)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t17_hold_gc.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t17_hold_gc.luau new file mode 100644 index 0000000..f3944d4 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t17_hold_gc.luau @@ -0,0 +1,69 @@ +--!nocheck +-- 레인 A: `_hold` 불변식(하류→상류 강함, 상류→하류 weak) 실측 — 중간 State가 수거되지 않는가 + 음성 대조군 +local q = require("./core10") +local A = q.Source(1) +local fired = 0 +local inst = q.newInst("inst") +do + -- A → B → C → Observer(leaf) — B/C를 아무도 로컬로 안 든다 + local o = A:Compute(function(s) return s:Get() + 1 end) + :Compute(function(s) return s:Get() * 2 end) + :Observer(function(t) fired += 1; t:Get() end) + q.bindLifetime(inst, o) +end +collectgarbage(); collectgarbage() +local before = fired +A:Set(5) +print("(양성) 체인 GC 뒤 A:Set → Observer 발화", fired - before, "(기대 1 — 중간 State가 _hold로 살아남음)") + +-- 음성 대조군: 말단이 아무 데도 안 묶이면 통째로 수거된다 +local weakProbe = setmetatable({}, {__mode = "k"}) +do + local mid = A:Compute(function(s) return s:Get() end) + weakProbe[mid] = true + local o2 = mid:Observer(function() end) -- 미바인드·미구독 + weakProbe[o2] = true +end +collectgarbage(); collectgarbage() +local alive = 0; for _ in pairs(weakProbe) do alive += 1 end +print("(음성) 말단 미바인드 체인 GC 뒤 생존 객체", alive, "(기대 0 — 상류→하류가 weak라 수거)") + +-- 상류(A)의 구독자 집합이 수거된 자식을 자동으로 잊는가 +local subs = 0; for _ in pairs(A._subs) do subs += 1 end +print("A._subs 크기", subs, "(기대 1 — 살아있는 leaf 체인의 B만)") + +-- Effect: deps 강한 맵 + 내부 Observer; Effect가 leaf에 묶여 있으면 dep 체인 생존 +local fired2 = 0 +do + local e = q.Effect(function() fired2 += 1 end, + A:Compute(function(s) return s:Get() end):Compute(function(s) return s:Get() end)) + q.bindLifetime(inst, e) +end +collectgarbage(); collectgarbage() +local b2 = fired2 +A:Set(6) +print("(양성) Effect deps 체인 GC 뒤 A:Set → fn", fired2 - b2, "(기대 1)") + +-- Effect를 약하게만 구독하고 참조를 놓으면 통째로 수거(문서화된 UB — cleanup 안 불림) +local cleans = 0 +do + local e = q.Effect(function() return function() cleans += 1 end end, A) + e:WeakSubscribe() + weakProbe[e] = true +end +collectgarbage(); collectgarbage() +alive = 0; for _ in pairs(weakProbe) do alive += 1 end +print("(문서화된 UB) WeakSubscribe만 한 Effect 참조 놓기 → 생존", alive, "cleanup 호출", cleans, "(기대 0, 0)") + +-- 중간 GateNode도 _hold로 산다 +local fired3 = 0 +do + local b = q.Blocker() + local o = A:Block(b):Compute(function(s) return s:Get() end):Observer(function() fired3 += 1 end) + q.bindLifetime(inst, o) + -- b(Blocker)는 여기서 놓는다 — 게이트 노드는 살아야 하고, Blocker의 handles는 weak +end +collectgarbage(); collectgarbage() +local b3 = fired3 +A:Set(7) +print("(양성) A→Gate→Compute→Observer, Blocker 참조 놓음 → 발화", fired3 - b3, "(기대 1 — 통과 모드)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t18_blocker_vestigial_and_set_same.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t18_blocker_vestigial_and_set_same.luau new file mode 100644 index 0000000..204c485 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t18_blocker_vestigial_and_set_same.luau @@ -0,0 +1,37 @@ +--!nocheck +-- 레인 A: (a) Effect 생성자의 사적 `_blocker`가 실제로 무엇을 막는가 — fire 가드 판정을 전부 기록 +-- (b) Source:Set 동일값 emit(H-68) × state:Block × Effect/Observer 발화 수 +local q = require("./core10") +local trace = {} +q.effectTrace = function(e, why) trace[why] = (trace[why] or 0) + 1 end + +local A = q.Source(1) +local r = q.Ref(0) +local B = A:Compute(function(s) return s:Get() end) +local e = q.Effect(function() end, A, B, r) -- Source + State + Ref dep +print("생성자 동안 fire 가드 판정:", "canExecute 드롭 =", trace["drop:canExecute"] or 0, "| blocker 드롭 =", trace["drop:blocker"] or 0) +e:Subscribe() +A:Set(2); r:Set(1) +print("구독 뒤 발화 중 blocker 드롭 =", trace["drop:blocker"] or 0, "(생성자 밖에선 blocker가 항상 Off)") +print("→ `_blocker`가 실제로 거른 발화 =", trace["drop:blocker"] or 0, "(0이면 canExecute 가드가 전부 먼저 걸렀다는 뜻)") +e:Unsubscribe() +q.effectTrace = nil + +print("\n== 동일값 Set × Block × 발화 수 ==") +local S = q.Source(5) +local n = 0 +local o = S:Observer(function() n += 1 end); o:Subscribe() +S:Set(5); S:Set(5) +print("같은 값 Set 두 번 → Observer 발화", n, "(기대 2 — H-68)") +local b = q.Blocker(); local g = S:Block(b) +local m = 0 +local o2 = g:Observer(function() m += 1 end); o2:Subscribe() +b:On(); S:Set(5); S:Set(5); S:Set(5) +local cnt = 0; for _ in pairs(g._withheld) do cnt += 1 end +print("블록 중 같은 값 Set 세 번 → withheld 크기", cnt, "(기대 1 — 집합)") +b:Off() +print("Off → gated Observer 발화", m, "(기대 1)") +local e2n = 0 +local e2 = q.Effect(function() e2n += 1 end, g); e2:Subscribe() +b:On(); S:Set(5); S:Set(5); b:Off() +print("Effect(gated): 블록 중 두 번 Set → Off → fn", e2n, "(기대 2 = 설치 1 + 배치 1)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t19_observer_level2.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t19_observer_level2.luau new file mode 100644 index 0000000..99feebc --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t19_observer_level2.luau @@ -0,0 +1,24 @@ +--!nocheck +-- H-149 재검토: Observer:Subscribe의 콜론 위임 때문에 error level 2가 어느 줄을 가리키는가 +local q = require("./core10") +local S = q.Source(0) +local inst = q.newInst("inst") +local o = S:Observer(function() end) +q.bindLifetime(inst, o) +local ok, err = pcall(function() + o:Subscribe() -- ← 사용자 호출부 (이 파일의 줄) +end) +print("Observer(leaf):Subscribe →", err) +local o2 = S:Observer(function() end) +q.bindLifetime(inst, o2) +local ok2, err2 = pcall(function() + o2:WeakSubscribe() -- ← 사용자 호출부 +end) +print("Observer(leaf):WeakSubscribe →", err2) +-- 대조: EffectHandle은 인라인이라 사용자 줄을 가리킨다 +local e = q.Effect(function() end) +q.bindLifetime(inst, e) +local ok3, err3 = pcall(function() + e:Subscribe() -- ← 사용자 호출부 +end) +print("Effect(leaf):Subscribe →", err3) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t1_diamond.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t1_diamond.luau new file mode 100644 index 0000000..3cc6745 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t1_diamond.luau @@ -0,0 +1,15 @@ +--!nocheck +local q = require("./core") +-- A -> B -> D, A -> C -> D, Observer on D +local A = q.Source(1) +local B = q.State(function(self, prev, a) return a:Get() * 10 end, {A}, "B") +local C = q.State(function(self, prev, a) return a:Get() * 100 end, {A}, "C") +local D = q.State(function(self, prev, b, c) return b:Get() + c:Get() end, {B, C}, "D") +local seen = {} +local O = q.Observer(D, function() table.insert(seen, D:Get()) end, "O") +print("초기 D:Get() =", D:Get()) +A:Set(2) +print("Observer 발화 횟수:", O.fires, "| 관측값:", table.concat(seen, ",")) +print("D 재계산 횟수:", D.recomputes, "| B:", B.recomputes, "| C:", C.recomputes) +A:Set(3) +print("2회차 후 발화:", O.fires, "| 관측값:", table.concat(seen, ",")) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t22_fn_destroys_inst.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t22_fn_destroys_inst.luau new file mode 100644 index 0000000..a1f12cc --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t22_fn_destroys_inst.luau @@ -0,0 +1,17 @@ +--!nocheck +-- 레인 A: leaf Effect의 fn이 자기 inst를 동기 파괴(Immediate 모드 흉내) → H-143 판정이 잡는가 +local q = require("./core10") +local inst = q.newInst("inst") +local S = q.Source(0) +local cleans, runs = 0, 0 +local e = q.Effect(function(self) + runs += 1 + if runs == 2 then q.destroyInst(inst) end -- 두 번째 실행에서 자기 inst 파괴 + return function() cleans += 1 end +end, S) +q.bindLifetime(inst, e) +print("bind 후", q.effectState(e), "runs", runs, "cleans", cleans) +S:Set(1) +print("fn 안 destroy 후", q.effectState(e), "runs", runs, "cleans", cleans, "(기대 cleans=2: 루프 머리 소진 1 + 즉시 소진 1)") +S:Set(2) +print("파괴 뒤 Set", "runs", runs, "(기대 2)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t23_store_reserved_runtime.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t23_store_reserved_runtime.luau new file mode 100644 index 0000000..0c9fb0d --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t23_store_reserved_runtime.luau @@ -0,0 +1,49 @@ +--!nocheck +-- 레인 A: Store 구현 스케치의 "그림자 테이블"이 store 자신인지 별도인지에 따라 `Of("Of")` 런타임 거동이 어떻게 갈리는가 +-- (타입 함수는 정적 이름만 잡고, 동적 `Of(name)`은 이름이 타입에 안 실린다 — store-plan.md 스스로 적어둔 갭) +local function Source(v) return { _v = v, Get = function(s) return s._v end, Set = function(s, x) s._v = x end, __isSource = true } end +local function isSource(x) return type(x) == "table" and x.__isSource == true end + +-- 구현 (I): 그림자 = store 자신(메타테이블 __index에 메소드) — "store.key는 평범한 레코드 필드"에 맞는 모양 +local StoreMT_I = {}; StoreMT_I.__index = StoreMT_I +function StoreMT_I:Of(name) + local src = self[name] -- 스케치 그대로 `shadow[name]` + if src == nil then src = Source(nil); self[name] = src end + return src +end +function StoreMT_I:Names() local t = {}; for k in pairs(self) do t[#t+1] = k end; return t end +local function StoreI(defaults) + for k, v in pairs(defaults or {}) do if not isSource(v) then error(("quad.Store: defaults.%s is not a Source"):format(k), 2) end end + return setmetatable(table.clone(defaults or {}), StoreMT_I) +end +-- 구현 (II): 그림자 별도 + store 프록시(__index → shadow) — "그림자 테이블"이라는 단어에 맞는 모양 +local function StoreII(defaults) + local shadow = table.clone(defaults or {}) + local methods = {} + function methods:Of(name) + local src = shadow[name] + if src == nil then src = Source(nil); shadow[name] = src end + return src + end + function methods:Names() local t = {}; for k in pairs(shadow) do t[#t+1] = k end; return t end + return setmetatable({}, { __index = function(_, k) local m = methods[k]; if m ~= nil then return m end; return shadow[k] end }) +end + +for label, ctor in pairs({ ["(I) store==shadow"] = StoreI, ["(II) proxy→shadow"] = StoreII }) do + print("== " .. label .. " ==") + local s = ctor({ hp = Source(1) }) + local ok, err = pcall(function() + local x = s:Of("Of") -- 동적 예약 이름 + print(" Of('Of') 반환 타입:", type(x), isSource(x) and "Source" or "NOT a Source") + local y = s:Of("hp2") -- 이후 정상 Of가 아직 되는가 + print(" 이후 Of('hp2'):", if isSource(y) then "OK" else "BROKEN") + end) + if not ok then print(" error:", err) end + local ok2, err2 = pcall(function() return s:Names() end) + print(" Names():", ok2 and table.concat(err2, ",") or ("error " .. tostring(err2))) +end +-- defaults에 예약 이름(타입은 잡지만 --!nocheck/동적이면 통과) +print("== defaults = { Of = Source(1) } ==") +local s3 = StoreI({ Of = Source(1) }) +print(" isSource 검증은 통과 → s3:Of 는", type(s3.Of), "(메소드가 가려졌다)") +print(" s3:Of('x') →", select(2, pcall(function() return s3:Of("x") end))) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t24_gate_brand_missing.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t24_gate_brand_missing.luau new file mode 100644 index 0000000..1d7791e --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t24_gate_brand_missing.luau @@ -0,0 +1,22 @@ +--!nocheck +-- H-152 재현: `GateNode`가 `StateBrand`(여기선 메타테이블 판별)에 등록되지 않으면 `_emitDown`의 +-- `isState(sub)` 분기가 거짓 → `canExecute(sub)`로 떨어져(게이트는 bind/Subscribe된 적 없음) 조용히 건너뜀. +-- 값(`:Get()`)은 멀쩡하고 통지만 죽는다 — "값은 맞는데 아무도 안 깨어나는" 모드. +local q = require("./core10") +local function scenario(label, dropBrand) + local s = q.Source(1) + local b = q.Blocker() + local g = s:Block(b) -- GateNode + if dropBrand then + local mt = getmetatable(g); local copy = table.clone(mt); copy.__index = mt.__index + setmetatable(g, copy) -- 같은 메소드, 다른 identity → isState(g) == false + end + local fires = 0 + local o = g:Observer(function() fires += 1 end); o:Subscribe() + fires = 0 + s:Set(2); s:Set(3) + print(("%s: isState(g)=%s | Observer 발화 %d (기대 2) | g:Get()=%d (값은 항상 최신)"):format( + label, tostring(q.isState(g)), fires, g:Get())) +end +scenario("브랜드 등록됨 ", false) +scenario("브랜드 누락(재현)", true) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t2_error.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t2_error.luau new file mode 100644 index 0000000..7dbd502 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t2_error.luau @@ -0,0 +1,26 @@ +--!nocheck +local q = require("./core") +local A = q.Source(1) +local B = q.State(function(self, prev, a) return a:Get() end, {A}, "B") +local C = q.State(function(self, prev, a) return a:Get() end, {A}, "C") +-- B 아래에 터지는 Observer, C 아래에 멀쩡한 Observer +local bad = q.Observer(B, function() error("사용자 콜백 실패") end, "O_bad") +local goodSeen = {} +local good = q.Observer(C, function() table.insert(goodSeen, C:Get()) end, "O_good") + +print("-- A:Set(2) (전파 중 O_bad가 error) --") +local ok, err = pcall(function() A:Set(2) end) +print("Set이 던졌는가:", not ok, "|", err) +print("O_bad 발화:", bad.fires, "| O_good 발화:", good.fires, "| 관측:", table.concat(goodSeen, ",")) +print("C의 값은 자가치유되는가: C:Get() =", C:Get(), "(정상)") +print("하지만 O_good은 이 변경에 대해 영원히 안 울었다:", good.fires) + +print("-- 이후 A:Set(3)은 정상인가 --") +local ok2 = pcall(function() A:Set(3) end) +print("두 번째 Set도 같은 자리에서 던짐:", not ok2, "| O_good 발화:", good.fires) + +print("-- O_bad를 제거하고(구독 해제 없이 새 파동) --") +B.subs[bad] = nil +A:Set(4) +print("O_good 발화:", good.fires, "| 관측:", table.concat(goodSeen, ",")) +print("→ 2와 3에 대한 통지는 영영 오지 않았다. 마지막 값만 보임") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t3_gate_error.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t3_gate_error.luau new file mode 100644 index 0000000..0beac1d --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t3_gate_error.luau @@ -0,0 +1,47 @@ +--!nocheck +local q = require("./core") + +print("=== (a) blocker:Off() 순회 중 한 핸들이 error ===") +local A = q.Source(1) +local B = q.Source(1) +local b = q.Blocker() +local g1 = q.Gate(A, function(emit) return b:Policy(emit) end, "g1") +local g2 = q.Gate(B, function(emit) return b:Policy(emit) end, "g2") +local seen1, seen2 = {}, {} +local o1 = q.Observer(g1, function() error("g1 하류에서 실패") end, "o1") +local o2 = q.Observer(g2, function() table.insert(seen2, g2:Get()) end, "o2") +b:On() +A:Set(2); B:Set(2) +print("보류 중 — g1.withheld 비었나:", next(g1.withheld) == nil, "| g2.withheld 비었나:", next(g2.withheld) == nil) +local ok, err = pcall(function() b:Off() end) +print("Off()가 던졌는가:", not ok, "|", err) +print("blocker.IsBlocked:", b.IsBlocked, "(이미 false — Off는 상태부터 바꾼다)") +print("g2가 아직 붙들고 있나(비었으면 flush됨):", next(g2.withheld) == nil, "| o2 발화:", o2.fires) +print("→ g2는 IsBlocked=false 인데도 밀린 통지를 못 내보냄. b:Off()를 다시 불러도:") +B.subs[g2] = B.subs[g2] -- no-op +local ok2 = pcall(function() b:Off() end) +print(" 두 번째 Off도 같은 자리에서 던짐:", not ok2, "| o2 발화:", o2.fires) + +print() +print("=== (b) flush 도중 error → 떼어낸 배치가 소멸 ===") +local S = q.Source(1) +local bl = q.Blocker() +local g = q.Gate(S, function(emit) return bl:Policy(emit) end, "g") +local boom = true +local seen = {} +local o = q.Observer(g, function() + if boom then error("하류 실패") end + table.insert(seen, g:Get()) +end, "o") +bl:On() +S:Set(2) +print("보류됨. withheld 비었나:", next(g.withheld) == nil) +pcall(function() bl:Off() end) +print("flush 진입 후 withheld 비었나(스왑됨):", next(g.withheld) == nil, "| flush 횟수:", g.flushes) +print("gate.emitEpochMap은 이미 Sync됨 → 같은 리비전은 이제 규칙 3으로 삼켜진다") +boom = false +-- 같은 리비전이 다른 경로로 다시 도착하는 상황을 재현 +g:_receive(S) +print("같은 리비전 재도착 후 o 발화:", o.fires, "| 관측:", table.concat(seen, ",")) +print("→ 배치는 사라졌고 재도착도 삼켜져, 그 변경에 대한 통지가 영영 안 나감") +print(" (값은 살아있다: g:Get() =", g:Get(), ")") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t4_throttle.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t4_throttle.luau new file mode 100644 index 0000000..65a7079 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t4_throttle.luau @@ -0,0 +1,84 @@ +--!nocheck +local q = require("./core") + +-- 가짜 시계/타이머 (luau CLI엔 task가 없다 — 주입 op setTimeout/clearTimeout 자리) +local now, timers, seq = 0, {}, 0 +local function setTimeout(fn, d) seq += 1; timers[seq] = {at = now + d, fn = fn}; return seq end +local function clearTimeout(t) if t then timers[t] = nil end end +local function advance(to) + while true do + local best, bestId + for id, t in timers do + if t.at <= to and (best == nil or t.at < best.at or (t.at == best.at and id < bestId)) then best, bestId = t, id end + end + if not best then break end + timers[bestId] = nil + now = best.at + best.fn() + end + now = to +end + +-- 확정된 재작성 방향(gate-plan.md 5번) 그대로: 정책은 emit을 안 쥐고 Blocker만 조종한다. +-- variant "nopending": 문서가 확정한 대로 pending을 정책에서 없앤 형태 +-- ("보류된 게 있는가는 Blocker의 HasBlockedEmit이 이미 들고 있다") +-- variant "localpending": 정책이 자기 pending 플래그를 따로 드는 형태 +local function Throttle(state, Time, variant) + return q.Gate(state, function(emit) + local b = q.Blocker() + local pass = b:Policy(emit) + local window, pending = nil, false + local openWindow, onWindowEnd + function onWindowEnd() + window = nil + if variant == "nopending" then + -- HasBlockedEmit을 읽을 수 없으므로 무조건 푼다 → 빈 배치면 no-op(8번) + b:Off() -- 보류분 방출(있으면) + b:On() -- 다음 창을 위해 다시 막음 + openWindow() -- "통과했으니 창을 다시 엶" — pending 여부를 알 수 없어 항상 + else + if pending then + pending = false + b:Off(); b:On() + openWindow() + end -- pending 없으면 창을 안 열고 idle 복귀 + end + end + function openWindow() window = setTimeout(onWindowEnd, Time) end + return function() -- 상류 emit 도착 + if window == nil then + -- 창 밖(idle) → leading 즉시 통과 + b:Off() -- 안 막힌 상태로 만들고 + pass() -- 통과 + b:On() -- 창 동안 막음 + openWindow() + else + pending = true + pass() -- 막혀 있으므로 보류됨 + end + end + end, "throttle") +end + +local function run(variant) + print(("=== variant = %s ==="):format(variant)) + now = 0; timers = {} + local S = q.Source(0) + local g = Throttle(S, 1.0, variant) + local hits = {} + local o = q.Observer(g, function() table.insert(hits, ("t=%.2f v=%d"):format(now, g:Get())) end) + S:Set(1) -- t=0.0 leading 통과 기대 + advance(0.1); S:Set(2) + advance(1.0) -- 창 끝 → trailing 통과 기대 + advance(3.0) -- 조용 → idle 복귀 기대 + print(" 타이머 살아있는 개수(idle이면 0):", (function() local n=0 for _ in timers do n+=1 end return n end)()) + S:Set(3) -- t=3.0 다시 leading 즉시 통과 기대 + print(" 통과:", table.concat(hits, " | ")) + advance(5.0) + print(" 최종 통과:", table.concat(hits, " | ")) + print(" t=5까지 살아있는 타이머:", (function() local n=0 for _ in timers do n+=1 end return n end)()) +end + +run("localpending") +print() +run("nopending") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t5_recompute_race.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t5_recompute_race.luau new file mode 100644 index 0000000..d2892be --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t5_recompute_race.luau @@ -0,0 +1,21 @@ +--!nocheck +local q = require("./core") +-- D = Compute(A) 인데, fn 안에서 다른 코드가 A를 바꾼다 +local A = q.Source(1) +local log = {} +local D = q.State(function(self, prev, a) + local v = a:Get() + table.insert(log, "fn이 읽은 A = " .. v) + if v == 1 then A:Set(99) end -- 재계산 도중 상류가 바뀜(부작용 있는 Compute) + return v * 2 +end, {A}, "D") + +print("D:Get() =", D:Get(), "(A=1로 계산됨)") +print("A의 실제 값:", A:Get()) +print("다시 D:Get() =", D:Get(), " ← 기대값 198") +print("D 재계산 횟수:", D.recomputes) +print("로그:", table.concat(log, " / ")) +print() +print("-- 이후 A를 또 바꾸면 회복되는가 --") +A:Set(5) +print("D:Get() =", D:Get(), "(회복됨)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t5b_fix.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t5b_fix.luau new file mode 100644 index 0000000..2641359 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t5b_fix.luau @@ -0,0 +1,20 @@ +--!nocheck +local q = require("./core") +-- 갈래 (b): rawInvalid를 fn 호출 *전에* 내리고, 도중에 다시 세워졌으면 그대로 둔다 +local State = getmetatable(q.State(function() end, {})) +State._recompute = function(self) + self.recomputes += 1 + self.rawInvalid = false -- ← 먼저 내린다 + self.cache = self.fn(self, self.cache, table.unpack(self.deps)) + for _, d in self.deps do d:_track(self.valueEpochMap) end + -- rawInvalid가 fn 도중 다시 true가 됐으면 그대로 둔다(덮어쓰지 않는다) +end +local A = q.Source(1) +local D = q.State(function(self, prev, a) + local v = a:Get() + if v == 1 then A:Set(99) end + return v * 2 +end, {A}, "D") +print("D:Get() =", D:Get()) +print("다시 D:Get() =", D:Get(), " ← 198이면 고쳐진 것") +print("재계산 횟수:", D.recomputes) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t6_effect_gate.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t6_effect_gate.luau new file mode 100644 index 0000000..ba4a4ac --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t6_effect_gate.luau @@ -0,0 +1,28 @@ +--!nocheck +local q = require("./core") +-- A ──> gated(Block) ──┐ +-- └──> plain ─────────┴──> Effect(fn, gated, plain) +local A = q.Source(1) +local plain = q.State(function(self, prev, a) return a:Get() end, {A}, "plain") +local b = q.Blocker() +local gated = q.Gate(A, function(emit) return b:Policy(emit) end, "gated") + +-- effect-plan.md의 확정 의사코드 그대로: 공용 EpochMap 하나, true일 때만 Rerun +local epochs = q.EpochMap() +local runs = {} +local function onDep(self, from) + if epochs:Update(from) then + table.insert(runs, ("run(gated=%s, plain=%s)"):format(tostring(gated:Get()), tostring(plain:Get()))) + end +end +local o1 = q.Observer(gated, onDep, "dep_gated") +local o2 = q.Observer(plain, onDep, "dep_plain") + +b:On() +print("blocker ON — 이 구간의 변경은 gated 쪽에서 유보되어야 한다") +A:Set(2) +print("fn 실행 기록:", table.concat(runs, " | ")) +print("gated가 아직 붙들고 있나:", next(gated.withheld) ~= nil) +b:Off() +print("Off() 이후 fn 실행 기록:", table.concat(runs, " | ")) +print("gated observer 발화 횟수:", o1.fires, "| plain observer:", o2.fires) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t7_poisoned.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t7_poisoned.luau new file mode 100644 index 0000000..ed6a86d --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t7_poisoned.luau @@ -0,0 +1,40 @@ +--!nocheck +local q = require("./core") +-- dispatch-core-plan.md "배치 등록을 안전하게 만드는 Blocker 게이팅" 그대로 +local blockers = {} -- Relate(ownerKey) 자리 +local function getBlocker(owner) + if not blockers[owner] then blockers[owner] = q.Blocker() end + return blockers[owner] +end +local recomputes = 0 +local function gatedRecompute(owner) + if getBlocker(owner):IsOn() then return end -- 배치 중이면 스킵 + recomputes += 1 +end +local function drive(owner, positions) + local bl = getBlocker(owner) + bl:On() + for _, p in positions do + p() -- 각 position 처리(사용자 코드가 여기서 돈다) + gatedRecompute(owner) -- setLength가 트리거하는 자리 + end + bl:OffWithoutEmit() -- 4번: 배치 끝 + recomputes += 1 -- "그 직후 딱 한 번" 명시적 recompute +end + +local inst = {} -- ownerKey +print("-- 정상 배치 --") +drive(inst, {function() end, function() end}) +print("recompute 횟수:", recomputes, "| blocker.IsOn():", getBlocker(inst):IsOn()) + +print("-- 두 번째 배치에서 사용자 코드가 error --") +local ok = pcall(drive, inst, {function() end, function() error("컴포넌트 실패") end}) +print("drive가 던졌는가:", not ok, "| blocker.IsOn():", getBlocker(inst):IsOn(), "← 영구 On") + +print("-- 이후 런타임 :Add() 등이 부르는 setLength --") +local before = recomputes +gatedRecompute(inst); gatedRecompute(inst); gatedRecompute(inst) +print("recompute가 몇 번 돌았나:", recomputes - before, "(0이면 그 owner는 영영 재계산 안 됨)") +print("-- 새 배치를 열어도 --") +pcall(drive, inst, {function() end}) +print("blocker.IsOn():", getBlocker(inst):IsOn(), "| 총 recompute:", recomputes) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t8_reentrant.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t8_reentrant.luau new file mode 100644 index 0000000..37c4817 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t8_reentrant.luau @@ -0,0 +1,17 @@ +--!nocheck +local q = require("./core") +local A = q.Source(1) +local B = q.State(function(self, prev, a) return a:Get() * 10 end, {A}, "B") +local C = q.State(function(self, prev, a) return a:Get() * 100 end, {A}, "C") +local D = q.State(function(self, prev, b, c) return b:Get() + c:Get() end, {B, C}, "D") +local seen, depth = {}, 0 +local O = q.Observer(D, function() + table.insert(seen, D:Get()) + depth += 1 + if depth == 1 then A:Set(3) end -- 전파 도중 동기 재진입 Set +end, "O") +D:Get() +A:Set(2) +print("관측 순서:", table.concat(seen, ",")) +print("Observer 발화:", O.fires, "| D 재계산:", D.recomputes) +print("최종 D:Get() =", D:Get(), "(A=3이므로 330이어야 함)") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/t9_gate_chain.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/t9_gate_chain.luau new file mode 100644 index 0000000..c8506bf --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/t9_gate_chain.luau @@ -0,0 +1,27 @@ +--!nocheck +local q = require("./core") +-- gate-plan.md 4번: "하류가 또 다른 게이트면 받은 집합을 풀어 자기 withheld에 합친다. +-- 게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이 먼저 풀리든 +-- 정보가 안 샌다" +local function run(order) + local A, B = q.Source(1), q.Source(1) + local w = q.State(function(self, prev, a, b) return a:Get() + b:Get() end, {A, B}, "w") + local b1, b2 = q.Blocker(), q.Blocker() + local g1 = q.Gate(w, function(emit) return b1:Policy(emit) end, "g1") + local g2 = q.Gate(g1, function(emit) return b2:Policy(emit) end, "g2") + local hits = {} + local o = q.Observer(g2, function(self, from) + local n = 0; for _ in from do n += 1 end + table.insert(hits, ("v=%d, batch=%d"):format(g2:Get(), n)) + end, "o") + b1:On(); b2:On() + A:Set(2); B:Set(3) + local function w1() return next(g1.withheld) ~= nil end + local function w2() return next(g2.withheld) ~= nil end + print(("[%s] 유보 중 g1=%s g2=%s | 발화 %d"):format(order, tostring(w1()), tostring(w2()), o.fires)) + if order == "상류 먼저" then b1:Off(); print(" b1:Off 후 g1=" .. tostring(w1()) .. " g2=" .. tostring(w2()) .. " 발화=" .. o.fires); b2:Off() + else b2:Off(); print(" b2:Off 후 g1=" .. tostring(w1()) .. " g2=" .. tostring(w2()) .. " 발화=" .. o.fires); b1:Off() end + print((" 최종 발화 %d | %s"):format(o.fires, table.concat(hits, " ; "))) +end +run("상류 먼저") +run("하류 먼저") diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty10_contrast.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty10_contrast.luau new file mode 100644 index 0000000..18fccdb --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty10_contrast.luau @@ -0,0 +1,20 @@ +--!strict +export type StateData = { Get: (self: StateData) -> T } +export type SourceData = { Get: (self: SourceData) -> T, Revision: number } +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData, prev: U?) -> U) -> U, + ComputeN: (self: StateData, fn: (self: StateData, prev: U?, D...) -> U, D...) -> U, +} +local s: State = nil :: any +local a: SourceData = nil :: any + +-- (A) deps 0개 — typing-limits ②쪼개기가 확인한 "무주석 통과" +local r1 = s:Compute(function(self) return self:Get() * 2 end) +local n1: number = r1 + +-- (B) deps 1개 + 무주석 +local r2 = s:ComputeN(function(self, prev, d1) return self:Get() + d1:Get() end, a) + +-- (C) deps 1개 + dep만 주석 +local r3 = s:ComputeN(function(self, prev, d1: SourceData) return self:Get() + d1:Get() end, a) +local n3: number = r3 diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty11_store_final.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty11_store_final.luau new file mode 100644 index 0000000..d0e44ff --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty11_store_final.luau @@ -0,0 +1,57 @@ +--!strict +-- 레인 A/D: 최종형 Store — §1② 쪼개기 선언(재귀 `Compute -> State` 포함)의 `Source` 필드 + +-- `CheckReservedKeys>` 팬텀 필드 + 빈 `{}` + `Of<>` — luau-analyze로 한 번에. +-- 기대: (A)(B)(C)(E) 진단 0, (D)(F)(G) 정확히 걸림, (H) 예약 키 print 진단. +export type StateData = { Get: (self: StateData) -> T } +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} +export type Source = State & { Set: (self: Source, v: T) -> Source, Revision: number } + +type function CheckReservedKeys(keys: type) + local list = if keys:is("union") then keys:components() else { keys } + for _, k in list do + if k:is("singleton") then + local v = k:value() + if v == "Of" or v == "Names" or v == "__reservedCheck" then + print(`quad.Store: "{v}" is a reserved key`) + return types.never + end + end + end + return types.singleton(true) +end +type Store = T & { + Of: (self: any, name: string) -> Source, + Names: (self: any) -> { string }, + __reservedCheck: CheckReservedKeys>, +} +local function Store(defaults: T?): Store return (nil :: any) end +local function Source(v: T): Source return (nil :: any) end + +-- (A) 키 있는 최종형 +local s = Store({ hp = Source(100), name = Source("x") }) +local a1: Source = s.hp +local a2: number = s.hp:Get() +s.hp:Set(5) +-- (B) 무주석 콜백 파라미터 추론 생존 +local b1 = s.hp:Compute(function(x) return x:Get() * 2 end) +-- (C) Of<> +local c1: Source = s:Of("dyn") +local c2: Source = s:Of("dyn2") +local c3: Source = s:Of<>("z") +local c4: Source = s:Of<>("z2") -- 음성: 명시 인스턴스화면 걸려야 +-- (D) 음성: 틀린 타입 +local d1: Source = s.hp +local d2: string = s.hp:Get() +-- (E) 빈 Store +local empty = Store({} :: {}) +local e1: { string } = empty:Names() +local e2: Source = empty:Of("dyn") +-- (F) 선언 안 된 키 +local f1 = s.nope +-- (G) Compute 반환 타입 명시 주석 구멍(§1) — 이 줄은 조용히 통과할 것(기대: 진단 없음 — 알려진 한계) +local g1: State = s.hp:Compute(function(x) return x:Get() * 2 end) +-- (H) 예약 키 충돌 — 타입 함수 진단 +local bad = Store({ Of = Source(1) }) +local h1 = bad.Of diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty1_apply_call.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty1_apply_call.luau new file mode 100644 index 0000000..f339f1b --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty1_apply_call.luau @@ -0,0 +1,28 @@ +--!strict +-- base/source-state-plan.md: state:Apply(factory) — `factory: (State) -> U): U` +-- base/debounce-throttle-plan.md 5-4: Debounce{...}가 돌려주는 것은 __call 테이블 +-- (setmetatable({}, {__call = ..., __index = {Flush=..., Cancel=...}})) +-- base/gate-plan.md 2번: "__call 테이블이 Luau에서 (State) -> U 함수 타입 자리에 +-- 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력)" — 확인 안 하기로 접었음 + +export type StateData = { Get: (self: StateData) -> T } +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + Apply: (self: State, factory: (State) -> U) -> U, +} + +local s: State = nil :: any + +-- (A) 평범한 함수 팩토리 — 양성 대조군 +local a = s:Apply(function(self: State): State return self end) + +-- (B) __call 테이블 팩토리 — 5-4절이 확정한 모양 +type GateFactory = typeof(setmetatable( + {} :: { Flush: () -> (), Cancel: () -> () }, + {} :: { __call: (any, State) -> State } +)) +local Debounced: GateFactory = nil :: any +local b = s:Apply(Debounced) + +-- (C) 직접 호출은 되는가 +local c = Debounced(s) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty1b.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty1b.luau new file mode 100644 index 0000000..c3d256f --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty1b.luau @@ -0,0 +1,23 @@ +--!strict +export type StateData = { Get: (self: StateData) -> T } +export type State = StateData & { + Apply: (self: State, factory: (State) -> U) -> U, +} +local s: State = nil :: any + +type GateFactory = typeof(setmetatable( + {} :: { Flush: () -> (), Cancel: () -> () }, + {} :: { __call: (any, State) -> State } +)) +local Debounced: GateFactory = nil :: any + +-- (A) 제네릭이 아닌 평범한 함수 파라미터 자리에도 안 들어가는가? +local function takesFn(f: (State) -> State) return f(s) end +local a = takesFn(Debounced) + +-- (B) 유니온으로 열면 되는가 +local function takesEither(f: ((State) -> State) | GateFactory) + return (f :: any)(s) +end +local b = takesEither(Debounced) +local b2 = takesEither(function(x: State) return x end) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty2_effect_deps.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty2_effect_deps.luau new file mode 100644 index 0000000..e5d6f3a --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty2_effect_deps.luau @@ -0,0 +1,27 @@ +--!strict +-- base/effect-plan.md: Effect(fn, ...deps) — deps는 State/Source(→Observer)와 +-- Ref(→:Callback)가 섞인다. fn엔 deps가 안 넘어간다(H-14). +export type StateData = { Get: (self: StateData) -> T } +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} +export type Ref = { Value: T?, Set: (self: Ref, v: T) -> (), Callback: (self: Ref, fn: (T) -> ()) -> () } +export type EffectHandle = { Rerun: (self: EffectHandle) -> (), Subscribe: (self: EffectHandle) -> () } + +type Dep = State | Ref +local function Effect(fn: (self: EffectHandle) -> (() -> ())?, ...: Dep): EffectHandle + return nil :: any +end + +local n: State = nil :: any +local b: State = nil :: any +local r: Ref = nil :: any + +-- (A) State 하나 +local e1 = Effect(function(self) return nil end, n) +-- (B) 이형 State 둘 +local e2 = Effect(function(self) return nil end, n, b) +-- (C) State + Ref 섞기 — effect-plan.md가 확정한 대표 용례 +local e3 = Effect(function(self) return nil end, n, r) +-- (D) 음성 대조군 — 아무 테이블이나 넣으면 걸려야 정상 +local e4 = Effect(function(self) return nil end, {foo = 1}) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty3_epoch_effectfn.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty3_epoch_effectfn.luau new file mode 100644 index 0000000..b0acea9 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty3_epoch_effectfn.luau @@ -0,0 +1,46 @@ +--!strict +-- (A) base/state-epoch-plan.md §2: "Source가 이 인터페이스를 구조적으로 만족한다" +-- "Revision은 공개 필드다. 비공개면 구조적 만족이 타입 레벨에서 성립하지 않는다" +export type Epoch = { Revision: number } +export type EpochSet = { [Epoch]: true } + +export type StateData = { Get: (self: StateData) -> T } +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} +export type SourceData = { Get: (self: SourceData) -> T, Revision: number } +export type Source = SourceData & { + Set: (self: SourceData, v: T) -> SourceData, + Emit: (self: SourceData) -> (), + Compute: (self: SourceData, fn: (self: SourceData) -> U) -> State, +} + +local src: Source = nil :: any +local st: State = nil :: any + +local function takesEpoch(e: Epoch): number return e.Revision end +local r1 = takesEpoch(src) -- 성립해야 함 +-- local r2 = takesEpoch(st) -- 음성 대조군(주석 해제하면 걸려야 정상) + +-- EpochMap 표면 (§3) +export type EpochMap = { + Update: (self: EpochMap, from: Epoch | EpochSet) -> boolean, + Refresh: (self: EpochMap) -> boolean, + Sync: (self: EpochMap, from: Epoch | EpochSet) -> (), + TrackFrom: (self: EpochMap, other: EpochMap) -> (), +} +local m: EpochMap = nil :: any +local u1 = m:Update(src) -- Epoch 하나 +local set: EpochSet = { [src :: Epoch] = true } +local u2 = m:Update(set) -- EpochSet +-- 게이트가 실제로 만드는 모양: {[Source]: true}를 EpochSet 자리에 +local rawSet: { [Source]: true } = { [src] = true } +local u3 = m:Update(rawSet) + +-- (B) base/effect-plan.md: fn 시그니처는 fn(self: EffectHandle) -> (() -> ())? +export type EffectHandle = { Rerun: (self: EffectHandle) -> () } +local function Effect(fn: (self: EffectHandle) -> (() -> ())?): EffectHandle return nil :: any end + +local e1 = Effect(function(self) return function() end end) -- cleanup 있음 +local e2 = Effect(function(self) return nil end) -- 명시적 nil +local e3 = Effect(function(self) end) -- 아무것도 안 돌려줌 (가장 흔한 모양) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty4_effect_ret.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty4_effect_ret.luau new file mode 100644 index 0000000..3000068 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty4_effect_ret.luau @@ -0,0 +1,23 @@ +--!strict +export type EffectHandle = { Rerun: (self: EffectHandle) -> () } + +-- (i) 확정된 시그니처 +local function EffectA(fn: (self: EffectHandle) -> (() -> ())?) end +EffectA(function(self) end) -- 흔한 모양 +EffectA(function(self) return nil end) -- 명시적 nil +EffectA(function(self) return function() end end) -- cleanup + +-- (ii) 조건 분기가 있는 경우 — 한쪽만 반환 +EffectA(function(self) + if math.random() > 0.5 then return function() end end +end) + +-- (iii) 가변 반환 팩으로 열기 +local function EffectB(fn: (self: EffectHandle) -> ...(() -> ())) end +EffectB(function(self) end) +EffectB(function(self) return function() end end) + +-- (iv) 유니온 함수 타입 +local function EffectC(fn: ((self: EffectHandle) -> ()) | ((self: EffectHandle) -> (() -> ()))) end +EffectC(function(self) end) +EffectC(function(self) return function() end end) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty5_updatefn.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty5_updatefn.luau new file mode 100644 index 0000000..517b904 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty5_updatefn.luau @@ -0,0 +1,31 @@ +--!strict +-- base/slot-plan.md 확정: updateFn(item, index, offset, prev, userdata) -> (T|nil, UD?) +type Element = { tag: string } +export type SourceN = { Get: (self: SourceN) -> number } + +local function List( + updateFn: (item: any, index: number, offset: SourceN, prev: T?, userdata: UD?) -> (T?, UD?) +) end + +-- (A) 값 하나만 반환하는 가장 흔한 모양 +List(function(item, index, offset, prev, ud) + return { tag = "x" } :: Element +end) + +-- (B) 두 값 반환 +List(function(item, index, offset, prev, ud) + return { tag = "x" } :: Element, 42 +end) + +-- (C) nil만 반환(요소 버리기 — 확정된 정상 경로) +List(function(item, index, offset, prev, ud) + return nil +end) + +-- (D) 조건 분기 — 한쪽만 반환 +List(function(item, index, offset, prev, ud) + if index > 1 then return { tag = "x" } :: Element end +end) + +-- (E) 아무것도 안 반환 +List(function(item, index, offset, prev, ud) end) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty6_multiret.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty6_multiret.luau new file mode 100644 index 0000000..5312837 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty6_multiret.luau @@ -0,0 +1,16 @@ +--!strict +type El = { tag: string } +-- 비제네릭으로 좁혀서 "선언된 다중 반환보다 적게 반환"만 본다 +local function take(fn: (n: number) -> (El?, number?)) end + +take(function(n) return { tag = "a" } end) -- 1개만 반환 +take(function(n) return nil end) -- nil 하나 +take(function(n) end) -- 아무것도 안 반환 +take(function(n) return { tag = "a" }, 1 end)-- 정확히 2개 +take(function(n) return nil, nil end) -- 명시적 2개 + +-- 대조: 단일 옵셔널 반환 +local function take2(fn: (n: number) -> El?) end +take2(function(n) return { tag = "a" } end) +take2(function(n) return nil end) +take2(function(n) end) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty7_fix.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty7_fix.luau new file mode 100644 index 0000000..b2e4b51 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty7_fix.luau @@ -0,0 +1,12 @@ +--!strict +type El = { tag: string } +type Fn2 = (n: number) -> (El?, number?) +type Fn1 = (n: number) -> El? +type Fn0 = (n: number) -> () +local function takeU(fn: Fn2 | Fn1 | Fn0) end +takeU(function(n) return { tag = "a" } end) +takeU(function(n) return nil end) +takeU(function(n) end) +takeU(function(n) return { tag = "a" }, 1 end) +-- 음성 대조군: 엉뚱한 타입을 돌려주면 걸려야 정상 +takeU(function(n) return 42 end) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty8_gate_apply.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty8_gate_apply.luau new file mode 100644 index 0000000..9328dc0 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty8_gate_apply.luau @@ -0,0 +1,41 @@ +--!strict +export type StateData = { Get: (self: StateData) -> T } +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + Apply: (self: State, factory: (State) -> U) -> U, + Gate: (self: State, setup: (emit: () -> ()) -> (() -> ())) -> State, + Block: (self: State, blocker: Blocker) -> State, +} +export type SourceData = { Get: (self: SourceData) -> T, Revision: number } +export type Source = SourceData & { + Set: (self: SourceData, v: T) -> SourceData, + Apply: (self: Source, factory: (State) -> U) -> U, + Gate: (self: Source, setup: (emit: () -> ()) -> (() -> ())) -> State, +} +export type Blocker = { + On: (self: Blocker) -> Blocker, + Off: (self: Blocker) -> Blocker, + OffWithoutEmit: (self: Blocker) -> Blocker, + IsOn: (self: Blocker) -> boolean, + IsBlocked: boolean, + Policy: (self: Blocker, emit: () -> ()) -> (() -> ()), +} + +local st: State = nil :: any +local src: Source = nil :: any +local b: Blocker = nil :: any + +-- (A) gate-plan.md 5번의 확정 형태 +local g1 = st:Gate(function(emit) return b:Policy(emit) end) +-- (B) tween-plan.md의 관용구 — Source에 State용 팩토리를 붙인다 +local Animate: (self: State) -> State = nil :: any +local a1 = src:Apply(Animate) +local a2 = st:Apply(Animate) +-- (C) Debounce 정책 모양 — 자기 Blocker를 사적으로 하나 갖는 setup +local d = st:Gate(function(emit) + local mine: Blocker = nil :: any + local pass = mine:Policy(emit) + return function() pass() end +end) +-- (D) 음성 대조군 — gate-plan.md가 "문법 오류"라고 정정한 형태 +local bad = st:Gate(b.Policy) diff --git a/.claude/audit/handtrace-round10-reference-impl/spikes/ty9_mixed_deps.luau b/.claude/audit/handtrace-round10-reference-impl/spikes/ty9_mixed_deps.luau new file mode 100644 index 0000000..c636ad0 --- /dev/null +++ b/.claude/audit/handtrace-round10-reference-impl/spikes/ty9_mixed_deps.luau @@ -0,0 +1,28 @@ +--!strict +-- 7라운드 부록이 확인한 이형 팩은 StateData/StateData처럼 +-- "같은 종류, 다른 타입 인자"였다. 실제 코드는 Source와 State가 섞인다. +export type StateData = { Get: (self: StateData) -> T } +export type SourceData = { Get: (self: SourceData) -> T, Revision: number } +export type State = StateData & { + ComputeN: (self: StateData, fn: (self: StateData, prev: U?, D...) -> U, D...) -> U, +} + +local s: State = nil :: any +local a: SourceData = nil :: any -- store.a (Source) +local b: StateData = nil :: any -- 파생 State + +local r = s:ComputeN(function(self, prev, d1: SourceData, d2: StateData) + return if d2:Get() then d1:Get() else 0 +end, a, b) +local rn: number = r + +-- 무주석 콜백에서도 되는가(부록이 확인한 것과 같은 조건) +local r2 = s:ComputeN(function(self, prev, d1, d2) + return d1:Get() + (if d2:Get() then 1 else 0) +end, a, b) +local rn2: number = r2 + +-- 음성 대조군: 순서를 바꿔 넘기면 걸려야 정상 +local r3 = s:ComputeN(function(self, prev, d1: SourceData, d2: StateData) + return 0 +end, b, a) diff --git a/.claude/qa-request/pre-implementation-handtrace-round10.md b/.claude/qa-request/pre-implementation-handtrace-round10.md index 0f876c0..33229ab 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round10.md +++ b/.claude/qa-request/pre-implementation-handtrace-round10.md @@ -8,7 +8,7 @@ > 새로 만들고 `base/`에 반영한다 — **이 파일은 발견 당시의 기록**이라 각 항목의 > "갈래"는 선택 전 목록이니 반영 뒤엔 그대로 믿지 말 것. > -> 상태: **[2026-08-28 기준] 탐사 진행 중 / 회신 대기.** `H-147`~`H-149`는 +> 상태: **[2026-08-28 기준] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) / 회신 대기.** `H-147`~`H-149`는 > `H-143`~`H-146` 반영분에 `/code-review high`가 낸 10건 중 새 메커니즘·기존 > 결정 변경이라 문항으로 올린 셋(나머지 일곱은 반영 — `-round9-followup.md`의 > 마지막 code-review 절). @@ -17,9 +17,17 @@ | 번호 | 심각도 | 한 줄 | 주 대상 | 성격 | 실측 | |---|---|---|---|---|---| -| `H-147` | 🟡 | `Rerun` 꼬리 `wasAlive`가 루프 머리 `_consumeCleanup()` **뒤**에 잡히고 공개 `Rerun()`에 게이트가 없어 **진입 시점에 이미 죽은 핸들**(직전 cleanup이 `self:Unsubscribe()` / 해제 뒤 타이머의 `self:Rerun()`)은 else 분기 → 고아 cleanup + `_installed = true` | `effect-plan.md` `Rerun` | `/code-review` 발견, 처방은 규칙 또는 새 플래그 | — | +| `H-147` | 🟡 | `Rerun` 꼬리 `wasAlive`가 루프 머리 `_consumeCleanup()` **뒤**에 잡히고 공개 `Rerun()`에 게이트가 없어 **진입 시점에 이미 죽은 핸들**(직전 cleanup이 `self:Unsubscribe()` / 해제 뒤 타이머의 `self:Rerun()`)은 else 분기 → 고아 cleanup + `_installed = true` | `effect-plan.md` `Rerun` | `/code-review` 발견, 처방은 규칙 또는 새 플래그 | `t15`(재현 — §5) | | `H-148` | 🟡 | `H-146`의 "`Parent` 거부는 **전용 문구**"가 새 메커니즘 — `isHandlable` 거부는 `Dispatch.process`의 **일반** 매치 실패 문구로 떨어지고 그 자리에 특수 분기는 두지 않기로 확정돼 있다. 사용자 회신에 문구 언급 없음(에이전트 선택이었음) | `bind-system-plan.md` `H-142` 항목, `ROADMAP.md` M5 | `/code-review` 발견, 처방은 새 핸들러 또는 철회 | — | -| `H-149` | 🟡 | `Observer:Subscribe`가 `self:WeakSubscribe()`로 위임하므로 거기 붙인 `error(…, 2)`가 `o:Subscribe()` 경로에선 사용자 호출부가 아니라 `Observer:Subscribe` 본문을 가리킴(`H-104` level 계약 위반). `EffectHandle`은 (b)로 인라인해 문제 없음 | `lifecycle-pattern.md` Observer 네 진입점 | `/code-review` 발견, 처방은 기존 결정("위임하므로 게이트 한 번") 변경 | — | +| `H-149` | 🟡 | `Observer:Subscribe`가 `self:WeakSubscribe()`로 위임하므로 거기 붙인 `error(…, 2)`가 `o:Subscribe()` 경로에선 사용자 호출부가 아니라 `Observer:Subscribe` 본문을 가리킴(`H-104` level 계약 위반). `EffectHandle`은 (b)로 인라인해 문제 없음 | `lifecycle-pattern.md` Observer 네 진입점 | `/code-review` 발견, 처방은 기존 결정("위임하므로 게이트 한 번") 변경 | `t19`(재현 — §5) | +| `H-150` | 🟡 | `Effect._blocker`는 **아무것도 억제하지 않는 죽은 부품**이다 — `fire`의 첫 줄 `canExecute(self)`가 생성자 구간(핸들이 아직 bind/Subscribe 안 됨)의 등록 즉시 1회를 전부 먼저 떨어뜨리고, 생성자 뒤로는 `_blocker`가 다시 `On()`되는 자리가 없다. 그런데 `effect-plan.md`는 억제 주체를 `_blocker`로 서술하고 `gate-plan.md` 7번은 아직 *"자기 내부 플래그로"*(폐기된 `_installing`)라 적혀 있다 | `effect-plan.md` 생성자 / `gate-plan.md` 7번 | 실측 발견, 처방은 부품 제거 또는 서술 정정(사용자 지시로 들어온 부품이라 문항) | `t18` | +| `H-151` | 🟡 | 게이트가 **두 경로에서 우회된다** — (1) `Effect`의 재바인드/재구독 캐치업 `_epochs:Refresh()`는 원천 `Epoch`의 `.Revision`을 직접 읽으므로 중간 `GateNode`가 유보 중이어도 `Rerun`이 돈다, (2) 게이트 없는 형제 dep이 emit하면 `fn`이 돌며 게이트된 dep의 최신값을 `:Get()`으로 그냥 본다. 둘 다 "값은 안 가린다" 계약의 따름정리지만 어디에도 적혀 있지 않다 | `gate-plan.md` / `effect-plan.md` `_bindDestroying` | 실측 발견, 처방은 문서화(권고) 또는 새 메커니즘 | `t16` | +| `H-152` | 🟡 | `gate-plan.md`의 `GateNode` 조립 절(필드 전부를 한 곳에 모은 절)에 **`StateBrand` 등록이 없다.** `_emitDown`은 `isState(sub)`로만 자식 노드를 가르므로 등록을 빠뜨리면 게이트는 `canExecute(sub)`(bind된 적 없음 → 거짓)로 떨어져 **조용히 영영 못 받는다** — `:Get()`은 멀쩡하고 통지만 죽는 모드. `brand-plan.md` 스스로 *"어느 브랜드에 등록하는 걸 빠뜨렸나"*를 조용한 버그로 경고한 그 자리 | `gate-plan.md` `GateNode` 조립 / `source-state-plan.md` `_emitDown` | 명시 누락(갈래 없음 — 한 줄 추가) | `t24`(재현) | +| `H-153` | 🟡 | Store 예약 이름(`Of`/`Names`/`__reservedCheck`)의 **런타임 갭** — `store:Of("Of")`는 구현 모양에 따라 메소드 함수를 그대로 돌려주거나(그림자=store 자신) 이후 `s:Of`를 가린다(프록시); `Store({ Of = Source(1) })`는 `isSource` 화이트리스트를 **통과**한 뒤 `s:Of(...)`가 *attempt to call a table value*로 죽는다. 타입 함수는 정적 키만 보고 동적 `Of(name)`과 `--!nocheck`엔 손이 안 닿는다(store-plan 자신이 `__reservedCheck`에 대해 절반은 인정) | `store-plan.md` 구현 스케치 / `Of` | 실측 발견, 처방은 **작은 런타임 가드**(새 검사라 문항) | `t23` | +| `H-154` | 🟡 | `InstanceChildHandler`의 retractor에 **같은 값 dedup이 없다** — `state`이 같은 Frame을 다시 emit하면(spurious) `Parent = nil` → `Parent = inst`가 실제로 일어나고 `recompute(inst)`가 2회 돈다. 같은 자리의 `SlotHandler`(`slotValue == nextValue → return`)·`RefLeafHandler`(`old ~= v`)·Leaf(`old ~= v`)는 전부 dedup을 두고 있어 이 핸들러만 예외 | `dispatch-core-plan.md` `H-134` 문단 / `ROADMAP.md` M5 | 실측 발견, 처방은 한 줄 가드(동형 선례 있음, dedup 정책이라 문항) | `d23` | +| `H-155` | 🟢 | `ROADMAP.md` 체크박스 넷이 `base/`보다 낡았다 — M6 *"`recompute`는 `sourceList[i]`가 `nil`이어도 `None`처럼 skip"*(4라운드 `C-6`가 **즉시 error**로 뒤집음), M6 *"`destroySlotTree`가 자식 소유권 반납"*(`C-4`가 되돌림), M6 *"`self._mounted` 확인 후 즉시 활성화"*(6라운드 `H-2`가 `_physicalTarget`으로), M11 3-상태 슬롯 *"`RobloxTween \| true \| nil` … 엔진 객체=활성 트윈"*(tween-plan이 `{Tween, Value}` 테이블로 정정) | `ROADMAP.md` M6/M11 | stale(색인 레이어) | — | +| `H-156` | 🟢 | `debounce-throttle-plan.md` 7절의 `H-32` 문단이 *"`pending`을 없애고 Blocker의 `HasBlockedEmit`을 쓰면 … `OffWithoutEmit()`은 보류분을 버리면서 상태도 같이 비운다"*로 남아 있다 — 같은 절 배너가 7라운드 `H-86`(`HasBlockedEmit`은 정책이 읽을 수 없음)·`H-55`(`OffWithoutEmit`만으론 집합이 안 빔, `emit(false)` 필요)로 정확히 그 두 문장을 뒤집어놓고 본문은 안 고쳤다 | `debounce-throttle-plan.md` 7절 | 배너만 달고 본문 미수정 | — | +| `H-157` | 🟢 | `store-plan.md`의 *"빈 Store(`Store<<{}>>()`)는 아직 실측 안 됐다"*는 이제 실측됐다 — 최종형(§1 쪼개기 `Source` + `CheckReservedKeys>`)에 `Store({} :: {})`를 넣으면 `keyof<{}>`가 에러가 아니라 빈 유니온으로 풀려 **진단 0**, `empty:Names()`/`empty:Of(...)`도 정상 | `store-plan.md` 빈 Store 문단 | 실측 완료(서술 갱신만) | `ty11` | ## 상세 @@ -76,6 +84,258 @@ dot 호출 로컬 헬퍼로 빼도 됨(가상 디스패치 없음) / (b) 내부 문서화. **권고 (a)** — "본문을 섞지 않는다"(2026-08-27 원칙)와 결이 같다. +### `H-150` 🟡 — `Effect._blocker`는 죽은 부품이다 (억제는 `canExecute`가 이미 한다) + +**트레이스** — `base/effect-plan.md`의 "의사코드 — 생성자" 절 그대로: +`self._blocker:On()` → dep마다 `d:Observer(onStateFire)`(등록 즉시 1회 → `fire`) / +`d:WeakCallback(onRefFire)`(등록 즉시 1회 → `fire`) → `self._blocker:OffWithoutEmit()` → +`self:Rerun()`. `fire`의 순서는 `canExecute(self)` → `_blocker:IsOn()` → `Update`. +생성자 안의 `self`는 아직 `bindLifetime`도 `Subscribe`도 안 된 핸들이라 +`isBoundAlive(self)`가 거짓 → **`canExecute`가 첫 줄에서 전부 떨어뜨린다.** +`_blocker:IsOn()` 줄엔 한 번도 도달하지 않는다. 생성자 뒤로 `_blocker:On()`을 +다시 부르는 자리는 코퍼스 어디에도 없다(`_bindDestroying`/`Rerun`/네 진입점 전부 +확인) — 즉 이 부품은 **어떤 실행 경로에서도 판정에 참여하지 않는다.** +실측(`t18`): `fire` 가드 판정을 훅으로 기록하니 dep 3개짜리 Effect의 생성자에서 +`drop:canExecute` 3건, `drop:blocker` **0건**. 바인드 뒤 `Set`은 정상 발화. + +**왜 문제인가** — 동작은 안 깨진다. 문제는 **서술**이다: +- `effect-plan.md`는 *"억제는 사적 `Blocker` 하나가 전담한다"*, `_installing` + 플래그가 *"생성자 구간만 덮어 바인드 구간을 놓쳤다"*고 근거를 적어뒀는데, + 실제 차단자는 `canExecute`다. 다음 구현자가 이 근거를 믿고 `_blocker`를 + 건드리거나(예: 바인드 구간에도 `On()`을 걸어 "일관되게") 반대로 `canExecute` + 줄을 옮기면(예: `Update`를 먼저 해서 리비전을 앞당겨 기록하려고) 그때 처음으로 + `_blocker`가 진짜 역할을 갖게 되거나 캐치업이 어긋난다 — 사냥 목록의 *"두 뜻이 + 같다"*형(문서가 두 장치를 같은 것처럼 서술) 자리다. +- `base/gate-plan.md` 7번은 더 낡았다 — *"`Effect`가 **자기 내부 플래그로** 설치 + 중 발화를 누르고"*는 7라운드 `H-58`이 폐기한 `_installing` 서술이다(같은 + 문서의 "확정 구조" 절과 `effect-plan.md`는 이미 `Blocker`로 바뀌어 있다). + +**새 메커니즘 여부** — 없음. 제거 쪽이면 필드 하나·줄 셋이 준다. + +**갈래** +- (a) **`_blocker`를 제거**하고 생성자 주석을 *"등록 즉시 1회는 `canExecute`가 + 막는다(생성자 안에선 핸들이 아직 bound가 아니므로)"*로 정정. `gate-plan.md` + 7번도 같이 정정. +- (b) **유지**(belt-and-braces — 나중에 생성자 안에서 bound가 되는 경로가 + 생기면 그때 의미를 가짐) + 문서를 *"실제 차단자는 `canExecute`, `_blocker`는 + 예비"*로 정정. `gate-plan.md` 7번 정정은 동일. +- (c) 유지 + `fire`의 순서를 `_blocker:IsOn()` → `canExecute`로 뒤집어 `_blocker`가 + 실제로 먼저 판정하게(문서와 코드를 맞추는 반대 방향). + +**권고: (a).** 근거 — `conventions.md`의 "설계 원칙" 절(가상의 미래 요구까지 +방어하지 않는다). (c)는 순서만 바꿀 뿐 `canExecute`가 거짓인 구간과 `_blocker`가 +켜진 구간이 완전히 겹쳐 여전히 판정 차이가 0이다. 단 **이 부품은 7라운드 `H-58`에서 +사용자가 `Blocker`를 쓰라고 직접 지시해 들어온 것**이라(그 인용은 +`qa-request/pre-implementation-handtrace-round7-followup.md`의 `H-58`) 탐사자가 +임의로 빼지 않고 문항으로 올린다 — 그 지시의 전제("등록 즉시 1회가 `Rerun`에 +닿는다")가 `canExecute` 첫 줄 때문에 성립하지 않는다는 게 이 발견의 요지다. + +**채택 시 고칠 자리** — `base/effect-plan.md` 생성자 의사코드·"확정 구조" 절· +`H-58` 정정 문단(`_blocker`/"전담" 서술), `base/gate-plan.md` 7번, +`ROADMAP.md` M2의 `Effect` 항목에 `_blocker`가 있으면 같이. + +### `H-151` 🟡 — 게이트는 `Effect` 캐치업과 형제 dep 경로에서 우회된다 + +**트레이스 (1) — 재바인드 캐치업.** `g = s:Block(b)`, `e = Effect(fn, g)`, +`bindLifetime(inst, e)`. `b:On()` → `s:Set(2)` → `g:_receive` → 유보(`fn` 안 돎, +맞다). 이제 `e`를 unbind → 다른 inst에 rebind(포탈) → `_bindDestroying` → +`self._epochs:Refresh()`. `_epochs`의 키는 **원천 `Epoch`**(`s`)다 — 생성자가 +`TrackFrom(g.valueEpochMap)`으로 `g`가 아는 원천을 그대로 복사했고, `Refresh`는 +그 원천들의 `.Revision`을 **직접** 읽는다. `s.Revision`은 이미 바뀌었으므로 +`depsChanged = true` → **`Rerun` — 게이트가 아직 닫혀 있는데 `fn`이 돈다.** +실측(`t16` 1·2): 게이트 유보 중 재바인드/재구독 → `fn` 1회 실행, 이후 `b:Off()` +flush에서 다시 1회(리비전이 이미 `Refresh`로 갱신됐는데도 flush 배치가 같은 +원천을 싣고 오면 `Update`가 변화 없음으로 접어야 하나 — 실측에선 접혔다, 즉 +두 번째는 안 돈다. 정확한 횟수는 §7). + +**트레이스 (2) — 형제 dep.** `e = Effect(fn, g, u)`(`u`는 게이트 없는 Source). +`b:On()` → `s:Set(2)`(유보) → `u:Set(9)` → `u` 경로로 `fire` → `Update(u)` 참 → +`Rerun` → `fn` 안에서 `g:Get()`은 **최신값 2**를 준다(게이트는 값을 안 가린다, +`base/gate-plan.md` 확정). 즉 게이트로 "이 값의 변경은 나중에 보겠다"고 했는데 +형제가 깨우면 그 값이 그대로 보인다. 실측(`t16` 4). + +**왜 문제인가** — 둘 다 코드가 틀린 게 아니라 **계약이 안 적혀 있다.** +`gate-plan.md`는 *정책이 미루는 건 다운스트림 통지뿐*이라고만 하고, "통지가 +emit이 아닌 경로(캐치업·형제)로 오면 어떻게 되는가"는 어디에도 없다. +`Debounce`/`Throttle` 사용자는 (1)에서 포탈 한 번에 창을 건너뛰는 실행을 보고, +(2)에서 debounce된 값이 형제 emit마다 새는 걸 본다 — 둘 다 "버그 리포트"로 +들어올 모양이다. + +**새 메커니즘 여부** — 문서화 갈래는 없음. 막는 갈래는 **새 메커니즘**이다: +(1)을 막으려면 `Refresh`가 원천 `.Revision`이 아니라 **dep 노드의 +`emitEpochMap`**을 보고 비교해야 하는데, `EpochMap:Refresh`는 키가 `Epoch`인 +맵을 라이브로 다시 읽는 연산이라 노드를 거치는 형태가 아니다(`EpochMap` +계약 변경). (2)를 막으려면 `fn` 실행 중 게이트된 dep의 `:Get()`이 유보 전 +값을 돌려줘야 하는데 그건 `debounce-throttle-plan.md` 4절이 **철회한 (B) +value-hold**다. + +**갈래** +- (a) **문서화** — `gate-plan.md`에 *"게이트는 emit 경로만 미룬다. `Effect`의 + 재바인드/재구독 캐치업(`Refresh`)과 게이트 없는 형제 dep의 emit은 게이트를 + 거치지 않고, 그때 `:Get()`은 최신값을 준다"*를 계약으로 명시. + `debounce-throttle-plan.md` 11절(다른 결정과의 상호작용)의 `Effect` 항목에도 + 같은 문장. +- (b) (1)만 막기 — `Effect`가 State dep에 대해선 `Refresh` 대신 dep의 + `emitEpochMap`을 비교(새 메커니즘, `EpochMap` 계약 변경). +- (c) 둘 다 막기 — value-hold 재개방(이미 기각된 안). + +**권고: (a).** 근거 — (b)(c)는 각각 `state-epoch-plan.md`의 `Refresh` 계약과 +2026-08-19 `(A) emit-gate` 확정을 되짚어야 하고, 실사용 빈도(게이트된 dep을 +가진 Effect가 포탈되는 경우)가 낮다. (a)는 이미 성립하는 사실을 적는 것뿐이다. + +**채택 시 고칠 자리** — `base/gate-plan.md`(계약 문장), +`base/debounce-throttle-plan.md` 11절, `base/effect-plan.md` `_bindDestroying` +캐치업 주석("dep이 변했으면"의 뜻이 원천 기준임을 한 줄). + +### `H-152` 🟡 — `GateNode` 조립 절에 `StateBrand` 등록이 없다 + +**트레이스** — `base/gate-plan.md`의 "`GateNode` 조립" 절은 *"세 문서에 나뉘어 +있어 한 곳에서 순서를 볼 수 없었다 … 구현자가 조립을 잘못할 여지를 없애려고 +여기 모은다"*며 필드 여섯과 `_receive`/`_flush`를 적는데, **브랜드 등록이 없다.** +`base/source-state-plan.md`의 `_emitDown`은 `if isState(sub) then sub:_receive(from) +elseif canExecute(sub) then …`로 자식 노드를 **`isState`로만** 가른다. +`base/brand-plan.md`는 *"각 타입은 자기 브랜드에만 등록하고, 포함 관계는 +predicate 한 곳에 쓴다"*라 `isState`가 참이려면 `GateNode`가 `StateBrand`에 +등록돼야 한다. 빠뜨리면: 상류 `Set` → `_emitDown` → `isState(gate)` 거짓 → +`canExecute(gate)`(게이트는 bind/Subscribe된 적 없음) 거짓 → **조용히 건너뜀**. +`gate:Get()`은 `_hold`를 타고 올라가 최신값을 주므로 값 검사로는 안 잡힌다 — +통지만 죽는다. 실측(`t24`): 같은 메소드·다른 identity로 브랜드만 떼면 하류 +Observer 발화 2 → **0**, `Get()`은 3으로 정상. + +**왜 지금 잡나** — `GateNode`는 `ComputeNode`와 달리 State 생성자를 안 지나고 +`Gate(self, setup)`가 직접 조립한다(위 절이 그 조립을 정의한다). 생성자가 +공통이면 등록도 공통 자리에 있겠지만, 여기선 조립 절이 곧 생성자라 그 절에 +없으면 어디에도 없다. `ROADMAP.md` M2 `GateNode` 항목에도 브랜드 언급이 없다. +사냥 목록 *"의사코드 순서 ≠ 산문"*의 변형 — 산문("게이티드 State 노드")은 +State라 하고 의사코드는 State가 되는 줄이 없다. + +**새 메커니즘 여부** — 없음(기존 `StateBrand:add(node)` 한 줄). + +**갈래** — 없음. 조립 절 필드 목록 첫 줄(또는 `Gate(self, setup)` 본문)에 +`StateBrand` 등록을 명시. 같은 절에 *"`isState(gate)`가 참이어야 `_emitDown`이 +`_receive`로 보낸다"*를 근거로 한 줄. + +**채택 시 고칠 자리** — `base/gate-plan.md` 조립 절, `ROADMAP.md` M2 `GateNode` +체크박스. 참고 구현 `core10.luau`는 `isState`에 `GateNode` 메타테이블을 **직접 +넣어서** 이 문제를 우회하고 있었다(README의 "옮기며 고친 것" 참고). + +### `H-153` 🟡 — Store 예약 이름의 런타임 갭 (`Of("Of")` / `defaults.Of`) + +**트레이스** — `base/store-plan.md`의 구현 스케치: 생성자는 +`table.clone(defaults or {})` + `isSource` 순회, `store:Of(name)`은 그림자 테이블에 +`Source`를 lazy 생성. 스케치가 *"그림자 테이블"*이라고만 해서 **store 자신인지 +별도 테이블인지**가 안 정해져 있다(같은 문서가 `store.key`는 *"평범한 레코드 +필드"*라 하므로 store 자신일 가능성이 크다). 두 모양을 다 옮겨 실측(`t23`): + +| | (I) 그림자 = store 자신(`__index` 메소드) | (II) 별도 그림자 + 프록시 | +|---|---|---| +| `s:Of("Of")` | **메소드 함수를 그대로 반환**(`Source` 아님), 이후 `Of` 정상 | `Source`를 만들어 그림자에 넣음 → 이후 `s:Of`가 **그 Source에 가려져** `attempt to call a table value` | +| `s:Names()` | 정상 | `Of`가 사용자 키로 섞여 나옴 | +| `Store({ Of = Source(1) })` | `isSource` 통과 → `s:Of(...)`가 **`attempt to call a table value`** | (동일) | + +타입 쪽 `CheckReservedKeys>`는 (H)처럼 **정적 키**만 잡는다(`ty11`에서 +정확히 잡힘). `Of(name)`의 `name`은 타입에 안 실리고, `--!nocheck`/동적 코드는 +`isSource` 화이트리스트(`H-122`)가 있어도 예약 이름을 안 보므로 통과한다. +`store-plan.md`는 `__reservedCheck`에 대해선 *"동적 키는 이름이 타입에 안 +실리므로 못 막는다"*고 이미 인정하는데, 같은 문장이 `Of`/`Names` 자신에게도 +성립한다는 건 안 적었다. + +**왜 문제인가** — 죽긴 죽는데 **자리가 멀다**: `defaults` 예약 이름은 생성 +시점이 아니라 첫 `s:Of(...)`에서, 메시지도 예약 이름과 무관한 *call a table +value*다. `H-122`가 `isSource` 화이트리스트를 둔 이유("조용히 받고 첫 `Get`에서 +엉뚱한 에러로 죽는다")와 정확히 같은 모양의 구멍이 한 칸 옆에 남아 있다. + +**새 메커니즘 여부** — 작은 런타임 검사(예약 이름 셋과 비교) — 새 개념은 아니나 +새 검사이므로 문항. `archive/`·"검토 후 안 만들기로 한 것"류에서 런타임 예약 이름 +가드를 기각한 기록은 **없다**(`archive/store-value-field-redesign-withdrawn.md`는 +타입 함수 범위만 다룸). + +**갈래** +- (a) **런타임 가드** — 생성자 `isSource` 순회에 `if RESERVED[k] then error(..., 2)`, + `Of(name)`에 같은 검사(둘 다 `level 2`, 영어 메시지 — `architecture.md` error + 계약). 생성 시 1회 + `Of` 호출당 테이블 조회 1회라 hot path 아님. +- (b) 문서화만 — *"예약 이름을 동적으로 쓰면 UB"*. +- (c) 그림자를 (II) 별도 테이블로 못박고 `Of`만 가드(부분). + +**권고: (a).** 근거 — `H-122`와 같은 자리·같은 논거(화이트리스트 검증은 이미 +있고 비교 대상을 셋 늘리는 것뿐), fail-fast 톤(`Slot` CRUD·`KeyGone`의 선례). +부수로 스케치의 "그림자 = store 자신인가"도 같이 못박을 것(권고: (I) — `store.key` +레코드 필드 계약과 맞고 `Names()`가 메소드를 안 세려면 메소드는 `__index`에). + +**채택 시 고칠 자리** — `base/store-plan.md` 구현 스케치·`Of` 절· +`__reservedCheck` 주석 (2), `ROADMAP.md` M2 Store 항목. + +### `H-154` 🟡 — `InstanceChildHandler`는 같은 Frame 재발행에 물리 detach/attach를 한다 + +**트레이스** — `base/dispatch-core-plan.md`의 `H-134` 문단(그리고 `ROADMAP.md` +M5): `process` = `setOffsetSource(None)` → `v.Parent = inst` → `setLength(1)`, +retractor = `v.Parent = nil` → `setOffsetSource(None)` → `setLength(0)`. 하강 +diff의 (A) 분기는 같은 핸들러면 **무조건** `retractor(v)` → `process`를 다시 +부르므로(`Dispatch 체인` 절), `state`이 **같은 Frame**을 다시 emit하면: +`A.Parent = nil` → `recompute(inst)` → `A.Parent = inst` → `recompute(inst)`. +실측(`d23`, round7 `chain.luau` 위에 그대로 옮김): 같은 값 재발행 1회에 `Parent` +대입 2회 + `recompute` 2회. 대조군 `SlotHandler`는 `slotValue == nextValue`면 +얼리리턴, `RefLeafHandler`/Leaf는 `old ~= v` dedup — `dispatch-core-plan.md`의 +"Handler 작성 체크리스트" 4번이 *"`Ref`의 spurious 재바인딩 dedup"*을 예로 든다. + +**왜 문제인가** — Roblox에서 `Parent = nil` → `Parent = inst`는 `ChildRemoved`/ +`ChildAdded`/`AncestryChanged`를 실제로 쏘고 레이아웃을 한 프레임 흔들 수 있다. +spurious 재발행은 `Source:Set`이 같은 값도 emit한다는 확정(`t18`로 재확인) +때문에 드물지 않다 — `store.child:Set(store.child:Get())` 한 줄이면 난다. + +**새 메커니즘 여부** — 없음(선례와 같은 한 줄). + +**갈래** +- (a) retractor 첫 줄에 `if nextValue == v then return end`(`SlotHandler`와 동형) — + 단 그러면 `process`가 다시 `setOffsetSource(None)`/`Parent = inst`/`setLength(1)`을 + 하므로 부기는 멱등하고 `Parent` 대입은 같은 값 재대입(엔진 no-op). +- (b) 그대로 둔다 — "dedup은 성능 최적화라 핸들러마다 선택"으로 문서화. + +**권고: (a).** 근거 — 같은 값 재발행의 dedup은 `Slot`/`Ref`/Leaf에서 이미 채택된 +정책이고, 이 핸들러만 빠진 건 `H-134`가 늦게 생겨서다(설계 차이가 아님). + +**채택 시 고칠 자리** — `base/dispatch-core-plan.md` `H-134` 문단, `ROADMAP.md` M5 +`InstanceChildHandler` 항목. + +### `H-155` 🟢 — `ROADMAP.md` 체크박스 넷이 `base/`보다 낡았다 + +넷 다 `base/`가 소스이고 ROADMAP만 안 따라온 것(색인 레이어 stale, 사냥 목록 +"배너만 달고 본문 안 고침"의 ROADMAP판): + +1. M6 *"`recompute`는 `sourceList[i]`가 `nil`이어도 `None`처럼 skip(방어)"* — + `base/slot-plan.md`는 4라운드 `C-6`로 **즉시 `error`**(부기가 깨졌다는 신호). +2. M6 *"`destroySlotTree`가 자식 소유권 반납 + …"* — `slot-plan.md`는 `C-4`로 그 + 수정을 **되돌렸다**(`State` 재설정 표의 정정 문단이 명시). +3. M6 *"`self._mounted` 확인 후 즉시 활성화"* — 6라운드 `H-2`로 판정 기준이 + `_physicalTarget`. +4. M11 *"3-상태 릴레이션 슬롯(`RobloxTween | true | nil` … 엔진 객체=활성 트윈)"* + — `base/tween-plan.md`의 "3-상태 저장" 절은 `{Tween, Value}` **테이블** + (`Tween.Finish`가 목표값을 알아야 해서). + +갈래 없음 — ROADMAP 넷을 `base/` 문장으로 교체. + +### `H-156` 🟢 — `debounce-throttle-plan.md` 7절 `H-32` 문단이 배너와 모순 + +같은 절의 무효화 배너는 7라운드 `H-86`(*"읽는 통로는 `HasBlockedEmit`이 아니라 +`emit`의 반환값"*)·`H-55`(*"`b:OffWithoutEmit()`만으로는 집합이 안 비므로 +`emit(false)`가 필요"*)를 반영했는데, 바로 아래 `H-32` 문단은 여전히 +*"`pending`을 없애고 Blocker의 `HasBlockedEmit`을 쓰면 … `Trailing = false`는 +`OffWithoutEmit()`으로 표현되고, 그건 보류분을 버리면서 상태도 같이 비운다"*. +두 문장 다 배너가 뒤집은 것이다. 갈래 없음 — 그 문단을 *"`emit()`의 반환값으로 +읽고 `emit(false)`로 버린다"*로 정정. + +### `H-157` 🟢 — 빈 Store는 실측됐다 + +`base/store-plan.md`의 *"빈 Store는 아직 실측 안 됐다 … `keyof<{}>`가 빈 +유니온이 되는지 에러가 되는지"*: `ty11`(최종형 — `StateData`/`State` +쪼개기, `Source` 필드, `CheckReservedKeys>`, 재귀 `Compute -> State` +포함)에 `local empty = Store({} :: {})`를 넣으면 **진단 0**(`keyof<{}>` → +`never`가 아니라 빈 유니온으로 `components()`가 비어 검사가 그냥 통과), +`empty:Names(): {string}`/`empty:Of("dyn"): Source` 정상. 같은 파일에서 +키 있는 양성(A·B·C·E) 0건, 음성(D·F·`c4`) 정확히 그 줄, 예약 키(H) 진단 1건, +§1 구멍(G)은 알려진 대로 조용히 통과. 갈래 없음 — 문단을 "실측 완료"로 갱신, +`luau-test/STATUS.md`의 `16`/`21` 재작성 시 대조군 메모는 그대로 유효. + ## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용) | 문항 | 무엇 | 선택지 | 권고 | @@ -83,17 +343,142 @@ dot 호출 로컬 헬퍼로 빼도 됨(가상 디스패치 없음) / (b) 내부 | **`H-147`** | 죽은 핸들에서 `Rerun()` | (a) UB로 문서화(cleanup 안 자기 해제는 지원 목록 밖, 타이머 취소는 cleanup의 일) / (b) `_everAlive` 플래그 + 진입 게이트 / (c) `wasAlive`를 소진 앞에서 | **(a)** — 새 상태 없이 기존 UB 계약과 같은 결 | | **`H-148`** | `Parent` 거부 문구 | (a) 전용 문구 철회, 일반 매치 실패 그대로 / (b) `Parent` 가드 핸들러(동적 경로 가드형) / (c) 일반 문구에 키 이름 | **(a)** — 회신 취지(새 API 없음) 그대로, 필요하면 M5에서 (b) | | **`H-149`** | Observer `Subscribe` 위임과 `level 2` | (a) `Subscribe`에 게이트·등록 인라인(Effect와 동형) / (b) `level 3` 위임 / (c) 문서화만 | **(a)** — 본문 안 섞기 원칙과 동형 | +| **`H-150`** | `Effect._blocker`(죽은 부품) | (a) 제거 + "억제는 `canExecute`"로 서술 정정 / (b) 유지 + 서술 정정("실제 차단자는 `canExecute`, `_blocker`는 예비") / (c) 유지 + `fire` 순서를 `IsOn` → `canExecute`로 | **(a)** — 판정 차이 0인 부품, `conventions.md` 설계 원칙. 단 `H-58` 사용자 지시로 들어온 부품이라 문항 | +| **`H-151`** | 게이트 우회(캐치업 `Refresh` / 형제 dep) | (a) 계약으로 문서화(게이트는 emit 경로만 미룬다) / (b) 캐치업을 dep `emitEpochMap` 기준으로(새 메커니즘) / (c) value-hold 재개방(기각안) | **(a)** — 이미 성립하는 사실, (b)(c)는 확정 둘을 되짚음 | +| **`H-153`** | Store 예약 이름 런타임 가드 | (a) 생성자 순회 + `Of(name)`에 예약 이름 검사(`error(…, 2)`) / (b) 문서화만(UB) / (c) 그림자 (II) 고정 + `Of`만 가드 | **(a)** — `H-122` 화이트리스트와 같은 자리·논거. 부수: 그림자 = store 자신(I)로 못박기 | +| **`H-154`** | `InstanceChildHandler` spurious dedup | (a) retractor `if nextValue == v then return end`(`SlotHandler` 동형) / (b) 그대로(정책 문서화) | **(a)** — `Slot`/`Ref`/Leaf가 이미 채택한 정책 | -(탐사자의 문항은 아래에 이어 붙는다.) +갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷), +`H-156`(`H-32` 문단), `H-157`(실측 완료 표기). ## §5 이상 없다고 확인한 것 -(탐사자가 채운다.) +전부 `audit/handtrace-round10-reference-impl/spikes/`의 참고 구현(`core10.luau`/ +`dispatch10.luau`, `H-107`~`H-149` 계약으로 갱신) 위에서 값으로 확인한 것. 스파이크 +이름은 §7. + +- **레인 A — `EffectHandle` 상태 전이표(`t14`).** 네 진입점 × {미바인드/바인드/ + 파괴 뒤/구독만} × `Rerun` 꼬리 × `_bindDestroying`: (1) 미바인드 `Subscribe` → + `fire`가 살아나고 `Unsubscribe` → 죽음, (2) 바인드 중 `WeakUnsubscribe`는 관대 + (`H-133`), `Unsubscribe`는 엄격 error, (3) 파괴 뒤 `Subscribe`(재구독)는 + `resubscribeTail` 캐치업으로 재설치(`H-144` (b)), (4) 파괴 → 재바인드도 재설치, + `_installed`/`_cleanup` 값이 전이마다 문서 표와 일치. `wasAlive and not canExecute` + 즉시 소진(`H-143`)은 `fn` 안에서 자기 inst를 동기 파괴하는 `t22`에서 cleanup이 + 그 자리에서 1회 돎. +- **`H-147` 재현(`t15`)** — 죽은 핸들에서 `Rerun()`: 케이스 1(직전 cleanup이 + `self:Unsubscribe()`) → `fn`이 돌고 고아 cleanup + `_installed = true`; + 케이스 2(해제 뒤 타이머의 `Rerun`) 동일; 케이스 3(파괴된 inst에 bound였던 핸들) + 은 `BindData`에 죽은 gcconn이 남아 있어 `wasAlive`가 참 → 즉시 소진으로 구분됨. + 고아 cleanup은 나중에 재구독/재바인드가 오면 그때 소진된다(영구 누수는 아님) — + §4 `H-147`의 (a)를 뒷받침. +- **`H-149` 재현(`t19`)** — `o:Subscribe()`의 `error(…, 2)`가 `core10.luau` 내부 + 줄을 가리킴, `o:WeakSubscribe()`/`Effect` 진입점은 사용자 줄. (a) 인라인 처방으로 + 사용자 줄이 됨(핸들 쪽 대조). +- **`state:Gate` × 재구독 × `EpochMap` 셋(`t16`)** — 7 시나리오: 유보 중 `Peek`은 + `emitEpochMap`을 안 덮음, flush가 배치를 unfold해 하류 `Update`가 한 번에 접음, + 게이트-게이트 중첩에서 중복 원천이 집합으로 접힘, `emit(false)`가 집합을 비움, + `OffWithoutEmit` 반복에 집합이 단조 증가하지 않음(`H-55`/`H-67`). 우회 둘만 + `H-151`로 올렸다. +- **`_hold` 불변식(`t17`)** — 하류가 상류를 강하게 잡고(`s → c1 → c2`, 하류만 + 참조해도 상류 생존) 상류는 하류를 weak로만(하류 참조를 놓으면 GC, 상류는 살아 + 있음). 양성/음성 대조. +- **`Source:Set` 같은 값(`t18`)** — 같은 값도 `Revision`을 올리고 emit(확정대로), + `Blocker` 게이팅 중엔 흡수 집합에 1건으로 접힘. +- **Store 최종형 타입(`ty11`, `luau-analyze`)** — `H-157` 참고. `Of<>` 명시 + 인스턴스화 양성/음성(`c3`/`c4`) 정확. +- **레인 C — 9라운드 결정 재실행.** `H-124`(되감기 판정을 `lengthList[i]` 읽기 + 앞으로): `d10`/`d11`/`d13`/`d14` 네 케이스 전부 `dispatch10` 위에서 통과(옛 + `dispatch9`와 같은 결과). `H-136`(`reconcile` 배치 `ownsGate`): `d21` — 리스트 + 사이클당 `recompute(L)` 정확히 1회, 물리 op이 최소(리오더 `{b,a,c}`가 + move 1 + insert 1), 중첩 Slot 요소의 `Length` 전파·리오더 뒤 `Offset`/ + `getOffsetAt` 값 정확. `H-145`(`bk.indexOfElement` weak-key): `d20` — 옛 Slot을 + 언마운트하고 참조를 놓으면 GC 뒤 키가 사라짐, 마운트 중인 키는 `_elements`가 + 잡아 유지. **단** Roblox `Instance`가 weak 키일 때의 거동은 CLI에서 실측 + 불가(§6). +- **레인 B — `:List` × `Detach` × `KeyGone` × 재등장 × 파괴(`d22`)** — 사라진 키에 + `Detach` → `_detached` 보유·`Length` 감소·물리 extract 1회, 재등장 시 `prev`로 + 재마운트(새로 만들지 않음, `indexOfElement` 갱신), `KeyGone`에 새 값 반환은 + error, `destroySlotTree`가 `_detached`까지 dispose, 파괴된 Slot 재마운트 error + (`_destroyed`, 9라운드 Q2). +- **레인 B — 읽기로만 확인(값 트레이스 없음)**: `SlotHandler` 재발행/교체/A→B→A + 왕복의 `claimOwnerAt`·`unmountSlotTree`·재마운트 경로, `activateList`의 + `_detachCleanup` 바인드/언바인드 짝, `settle` 여덟 분기(`Owned` × `Detach` × + `KeyGone`), `rawMove` 규약 4의 `setLength` 재등록, `H-142` `Parent` 금지 + + `H-146` 루트 예외, `H-138` 숏핸드 우선순위(`StoreBind` → 숏핸드 → `Property`), + `Tag` 참조 카운트, `Attribute` 그룹 전용 키 + `groupClaimKeys` 순서, `Modifier` + flatten 역순, `Tween` 3-상태, `OnChange` `v == nil` 얼리리턴, 훅 슈가 `guard`. + 모순은 위 `H-154`~`H-156`뿐. +- **레인 D** — `./scripts/test.sh` **ALL PASS**(`smoke.init`/`mock`/`plugin`, + `luau-analyze` 클린). 커밋된 M1 코드와 `base/`(`RunInit` 멱등·`AddPlugin` mutate· + `@self` require)에 어긋남 없음. ## §6 남은 의심 / 못 본 것 -(탐사자가 채운다.) +**값으로 안 돌려본 것(읽기만)** — 레인 B 중 `New`/`drive` 파이프라인(`H-139` +의사코드 자체), `Dispatch.process` (A)/(B) 분기 위의 `InstanceChildHandler` 이외 +말단(`Tag`/`Attribute`/`OnChange`/`PropertyHandler`+`Tween`), `Modifier` flatten +(`luau-test/done/17`이 별도로 있음), `rawMove`/`rawSwap`(base에 의사코드가 없어 +`H-29` 규약을 탐사자가 옮긴 것이라 그 자체가 검증 대상이 아님), +`collectLeaves`/`Extract`/`Splice`의 `native*` 인자, `dispose` 가드, +`Debounce`/`Throttle`(7절이 무효화 배너 아래라 옮길 의사코드가 없음), +`Fallback`/`Traceback`(백로그 `H-26` 그대로), `AttributeGroupHandler` 부분 실패 +경로, `PostRef` pre-pass. + +**로컬에서 실측 불가** — (1) `bk.indexOfElement`의 weak 키가 Roblox `Instance`일 +때: `Instance`는 userdata라 weak 키 수거 조건이 Luau 테이블과 다르고 +(`Destroy`된 뒤에도 Lua 쪽 참조가 있으면 남음), gcconn 자기순환이 그 참조를 들고 +있다 — `d20`은 테이블 키로만 확인했다. (2) `Workspace.SignalBehavior = Deferred` +× `Destroying` cleanup × "지연 창 안에서 재바인드": `_bindDestroying`의 +`_unbindDestroying()` 선행이 옛 연결을 끊지만, Deferred 큐에 이미 들어간 옛 +`Destroying` 콜백이 새 연결 뒤에 도는 순서는 `ref-plan.md`의 실측 계획에만 +있고 여기선 볼 수 없다. (3) `Parent = nil` → `Parent = inst`(`H-154`)가 실제로 +`ChildAdded`/`AncestryChanged`를 몇 번 쏘는지. + +**남은 의심** — (a) `H-151` (1)에서 캐치업 `Rerun` 뒤 게이트 flush가 같은 원천을 +싣고 올 때 `Update`가 접는 건 실측됐지만, **`Ref` dep**(리비전이 `Set`마다 +`bnot`으로 감기는 `Epoch`)에서 캐치업과 flush 사이에 `Set`이 한 번 더 오면 +접히지 않고 두 번 돈다 — 정상 동작(두 번 바뀌었으니)이지만 `Refresh`가 "변화 +있음"만 주고 횟수를 안 주는 게 맞는지는 안 따졌다. (b) `Effect(fn)`(dep 0개)의 +`_epochs`가 빈 맵이라 `Refresh()`가 항상 거짓 → 재바인드 캐치업이 `not +_installed`로만 갈리는데, 그 경우 "파괴로 소진 → 재바인드 → 재설치"가 맞는 동작인지 +(`OnDestroyed` 슈가가 이 경로로 두 번째 inst에도 cleanup을 달게 됨 — 아마 의도). +(c) `H-148`은 새 증거 없음. (d) 9라운드 §6이 남긴 `H-117`(`Of` 무주석 → +`Source`)은 `ty11`에서 주석 있는 경우만 봤다. (e) `luau-test/STATUS.md`의 +`rewrite-required/` 스파이크는 이번에 다시 안 돌렸다(상태는 그 파일이 소스). ## §7 레인 C 실행 기록 -(탐사자가 채운다 — `audit/handtrace-round10-reference-impl/`.) +폴더 `audit/handtrace-round10-reference-impl/`(구성·갱신 내역·옮기며 고친 것은 +그 `README.md`가 소스). 참고 구현 `core10.luau`(반응형 코어 — `EpochMap:Peek`, +Observer 3-인자, `Ref` `fn(value, ref)` + `WeakCallback`, `Effect` 생성자 +`_blocker`/`fire`/`_bindDestroying`/`Rerun` `H-143` 꼬리/네 진입점 + `resubscribeTail`, +`GateNode` unfold + `emit(commit) -> boolean`, `Blocker:Policy`) / +`dispatch10.luau`(부기 — weak-키 `indexOfElement`, `H-124` `recompute`, 5-인자 +`setLength`, Slot 생성자의 `Length`/`Offset`/`_baseObserver`/`_destroyed`, +`materializeSlotTree`/`mountSlotTree`, `raw*`, `:List`/`reconcile` `ownsGate`, +`mountTop` retractor). 전부 `luau <파일>`로 종료 0(2026-08-28 재실행), `ty11`은 +`luau-analyze`. + +| 스파이크 | 무엇 | 결과 | +|---|---|---| +| `t14_effect_state_matrix` | `EffectHandle` 네 진입점 × 상태 전이표 | 문서 표와 일치(§5) | +| `t15_rerun_dead_handle` | `H-147` 케이스 1·2·3 + 대조군 | 재현(§5) | +| `t16_gate_resub_refresh` | 게이트 × 재구독/재바인드 × `Refresh`/`Peek`/`Update` 7건 | 5건 확정대로, 2건 → `H-151` | +| `t17_hold_gc` | `_hold` 불변식 양성/음성 | 확정대로 | +| `t18_blocker_vestigial_and_set_same` | `Effect._blocker` 판정 횟수 / `Source:Set` 같은 값 | `drop:blocker` 0 → `H-150`; 같은 값 emit 확정대로 | +| `t19_observer_level2` | `H-149` blame 줄 | 재현 | +| `t22_fn_destroys_inst` | `fn` 안에서 자기 inst 동기 파괴(`H-143`) | cleanup 즉시 1회 | +| `t23_store_reserved_runtime` | 그림자 (I)/(II) × `Of("Of")` × `defaults.Of` | → `H-153` | +| `t24_gate_brand_missing` | `GateNode` 브랜드 누락 재현 | 발화 2 → 0 → `H-152` | +| `ty11_store_final` | 최종형 Store 타입 A~H + 빈 Store + `Of<>` | 양성 0건·음성 정확 → `H-157` | +| `d10`/`d11`/`d13`/`d14` `_r10` | `H-124` 되감기 네 케이스(round9 스파이크를 `dispatch10`으로) | 전부 통과 | +| `d20_indexOfElement_weak` | `H-145` weak 키 GC | 확정대로(테이블 키 한정) | +| `d21_reconcile_batch` | `H-136` `reconcile` 배치 + 중첩 Slot 리오더 | 사이클당 `recompute(L)` 1 | +| `d22_detach_keygone` | `Detach`/`KeyGone`/재등장/파괴 | 확정대로 | +| `d23_instancechild_spurious` | `InstanceChildHandler` 같은 값 재발행 | `Parent` 2회 → `H-154` | + +옮기며 발견한 것(발견 번호 없음, README에 기록): round7/ref9 참고 구현의 +`_recompute`가 `fn`의 첫 인자로 **결과 노드**를 넘기고 있었다(base는 리시버의 +lazy 핸들) — 그 스파이크들은 첫 인자를 안 써서 무해했지만 `t16`/`t17`은 그 +자리에서 무한 재귀했다. 그리고 `core10.luau`의 `isState`는 `GateNode`를 직접 +포함하는데, 그게 곧 `H-152`다.