diff --git a/.claude/README.md b/.claude/README.md index 3c4c3b8..39f1106 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -28,7 +28,7 @@ | `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 호출 여전히 실패라 순손해로 채택 안 함) | +| `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개**를 같이 싣는다. 이 폴더에서 나온 발견을 판정하려면 재실행이 아니라 원문 의사코드와 줄 단위 대조가 필요하다) | | `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-round7-reference-impl/README.md b/.claude/audit/handtrace-round7-reference-impl/README.md new file mode 100644 index 0000000..001b8da --- /dev/null +++ b/.claude/audit/handtrace-round7-reference-impl/README.md @@ -0,0 +1,115 @@ +# 7라운드 손 트레이싱 — 참조 구현과 스파이크 (2026-08-25 실측) + +**무엇인가**: `.claude/qa-request/pre-implementation-handtrace-round7.md`의 +**4·5·6차 패스**가 발견을 재현하는 데 쓴 코드 전량. `audit/`의 다른 폴더와 +같은 성격이고(계획이 아니라 **실측 결과 기록**), `type-recursion-issue/`처럼 +**스크립트를 같이 두는 구성**이다 — 판정이 "문서대로 짠 것을 돌려본 결과"라 +개별 파일을 직접 돌려야 재현된다. + +**⚠️⚠️ 이 코드는 문서가 아니라 문서의 *전사물*이다.** `spikes/core.luau` · +`spikes/dispatch.luau` · `spikes/chain.luau`는 `base/`의 확정 의사코드를 +**손으로 옮긴 것**이고, 옮기는 과정에서 틀렸을 수 있다. 그래서 여기서 나온 +발견을 검증할 때 **재실행은 검증이 아니다** — 재실행은 "전사물이 그렇게 +동작한다"만 말해준다. **반드시 아래 "대조표"를 따라 원문 의사코드와 줄 단위로 +대조할 것.** 대조에서 전사 오류가 나오면 그 발견은 quad의 결함이 아니라 이 +폴더의 결함이다. + +**환경**: `luau` / `luau-analyze` +(`~/.local/share/mise/installs/luau/latest`). 저장소 루트에서 +`cd .claude/audit/handtrace-round7-reference-impl/spikes && luau <파일>`. +런타임 스파이크는 `luau`, `ty*`는 `luau-analyze`로 돌린다. + +**실행 결과 스냅샷**: `RUN-runtime.txt`(런타임 18개), `RUN-typecheck.txt` +(타입 11개). 2026-08-25 실행분 그대로이고, 재실행이 이와 달라지면 그 자체가 +조사 대상이다. + +--- + +## 참조 구현 3개 — 원문 대조표 + +각 파일이 어느 절을 옮긴 것인지. **검증 패스는 이 표의 왼쪽(전사물)과 +오른쪽(원문)을 대조해야 한다.** + +| 파일 | 옮긴 대상 | +|---|---| +| `spikes/core.luau` | `base/state-epoch-plan.md` §2(`Epoch`/리비전 갱신) · §3(`EpochMap`의 `Update`/`Refresh`/`Sync`/`TrackFrom`) · §4(수신 규칙 1~3, 재계산 판정, 재계산 후 처리) · §5(emit 페이로드) / `base/source-state-plan.md`의 전파 모델과 `:With`/`:Compute` 노드 / `base/gate-plan.md` 4·8번(`withheld`, flush 시 스왑, 빈 배치 무통지, 게이트-게이트 unfold) / `base/blocker-plan.md`(`On`/`Off`/`OffWithoutEmit`/`IsOn`/`Policy`) / `ROADMAP.md` M2의 `H-23`(구독자 스냅샷) | +| `spikes/dispatch.luau` | `base/dispatch-core-plan.md`의 `Dispatch.getOffsetAt`(접두합 캐시) · `recompute` · `Dispatch.setLength` · `Dispatch.setOffsetSource` · 배치 게이팅(`getBlocker`) | +| `spikes/chain.luau` | `base/dispatch-core-plan.md`의 `Dispatch.process`(하강 diff (A)/(B) 분기) · `Dispatch.retractFrom` | + +### 문서와 **의도적으로 다른** 곳 (전사 오류가 아님) + +대조할 때 이 셋은 차이로 세지 말 것. 그 외의 모든 차이는 전사 오류 후보다. + +1. **`core.luau`의 `EpochMap:PeekDiffers`** — 문서에 없는 연산이다. + `GateNode`가 §4의 규칙 1~3을 돌려면 `emitEpochMap`을 **갱신하지 않고 + 비교만** 해야 하는데 그 연산이 표면에 없다는 게 **`H-72` 그 자체**라, + 게이트를 돌려보려면 임시로 하나 둘 수밖에 없었다. 이 함수를 쓰는 자리마다 + 주석으로 표시해뒀다. +2. **`dispatch.luau`의 `local box = { pos = i }`** — 확정 의사코드는 + `gatedRecompute`가 `i`를 직접 캡처한다. 여기선 `box.pos`를 읽는데, + **`box.pos`를 아무도 안 고치면 동작이 완전히 동일**하고(초기값이 `i`), + `d7_splice_fix.luau`가 A/B 대조를 하려고 그 필드를 고친다. 즉 기본 동작은 + 문서 그대로고, 이 우회가 없으면 `H-102`의 대조군을 만들 수 없다. +3. **주입 op·`bindLifetime`/`canExecute`가 없다** — 순수 `luau`엔 엔진이 + 없으므로 생명주기 게이팅을 뺐다(그 공백 자체가 `H-97`이다). 그래서 이 + 전사물은 **"구독자가 살아있는가" 판정을 하지 않는다** — 그 판정이 결과를 + 바꿀 수 있는 발견(`H-98` 등)은 그 점을 감안해 읽을 것. + +--- + +## 스파이크 → 발견 대조표 + +**"기대"는 그 발견이 주장하는 결과**다. 재실행 결과가 이와 다르면 발견 쪽을 +의심할 것. + +### 4차 패스 — 반응형 코어와 예외 경로 + +| 파일 | 발견 | 무엇을 보여주는가 | +|---|---|---| +| `t1_diamond.luau` | (대조군) | 다이아몬드에서 Observer가 **1회**만 울고 섞인 값이 안 나온다 — 전사물이 §1의 glitch 해소를 재현한다는 증거 | +| `t8_reentrant.luau` | (대조군) | 전파 도중 동기 `:Set()` 재진입이 안전하다(`220,330`) | +| `t9_gate_chain.luau` | (대조군) | 게이트 2겹이 어느 순서로 풀려도 1회 발화·배치 2개 | +| `t5_recompute_race.luau` | `H-85` | 재계산 도중 도착한 무효화를 꼬리의 `rawInvalid = false`가 지운다(`2`가 영구히 남음) | +| `t5b_fix.luau` | `H-85` 갈래 (a) | `rawInvalid`를 `fn` 앞으로 옮기면 `198`로 고쳐진다 | +| `t4_throttle.luau` | `H-86` | 정책이 보류분을 못 읽으면 leading이 사라지고(`t=4.00`) 타이머가 안 끝난다. `localpending` 변형은 1-1절 그림과 일치 | +| `t7_poisoned.luau` | `H-87` | 배치 중 error → `Blocker`가 영구 On → 이후 `recompute` 0회 | +| `t2_error.luau` | `H-88` | 전파 중 error → 뒤 구독자 영구 침묵(값만 자가치유) | +| `t3_gate_error.luau` | `H-89` | flush 중 error → 배치 소멸, 재도착도 규칙 3으로 삼켜짐 / `Off()` 순회 중 error → 뒤 게이트가 안 풀림 | +| `t6_effect_gate.luau` | `H-90` | 공용 `EpochMap`이 루트로 접어 게이팅이 무력화(`막혀 있는데 이미 돌았다`) | +| `t10_subscribe_gc.luau` | `H-98` | 중간 State가 수거되면 `:Subscribe()`한 Observer가 살아있는 채로 영영 안 울린다 | + +### 5차 패스 — 타입 (`luau-analyze`) + +| 파일 | 발견 | 무엇을 보여주는가 | +|---|---|---| +| `ty1_apply_call.luau` / `ty1b.luau` | `H-94` | `__call` 테이블이 `(State) -> U` 자리에 안 들어간다(제네릭·비제네릭 양쪽). 직접 호출은 통과 | +| `ty4_effect_ret.luau` | `H-95` | `Effect`의 `fn`이 아무것도 안 돌려주면 에러. 가변 반환 팩·유니온은 통과 | +| `ty5_updatefn.luau` / `ty6_multiret.luau` | `H-95` | 선언된 다중 반환보다 적게 반환하면 에러(값 하나만/`nil`만/없음 전부) | +| `ty7_fix.luau` | `H-95` 갈래 | 함수 타입 유니온이 네 모양을 다 받고 엉뚱한 반환은 여전히 잡는다 | +| `ty9_mixed_deps.luau` / `ty10_contrast.luau` | `H-96` | deps 0개는 무주석 통과, deps가 붙으면 무주석이 깨진다. dep 주석 시 `Source`/`State` 혼합도 정확히 좁혀진다 | +| `ty3_epoch_effectfn.luau` | `H-100` · (대조군) | `Source`가 `Epoch`를 구조적으로 만족한다(성립). `{[Source]: true}`는 `{[Epoch]: true}` 자리에 안 들어간다 | +| `ty2_effect_deps.luau` | (대조군) | `State | Ref` 가변인자로 이형 deps가 표현된다(`Instance` 미정의 진단 1건은 Roblox 정의가 없어서 나는 것) | +| `ty8_gate_apply.luau` | (대조군) | `state:Gate(function(emit) return b:Policy(emit) end)`가 성립하고, `gate-plan.md`가 정정한 오답 `b.Policy`는 타입이 잡는다 | + +### 6차 패스 — Length/Offset 부기와 error 계약 + +| 파일 | 발견 | 무엇을 보여주는가 | +|---|---|---| +| `d1_basic.luau` / `d2_slots.luau` | (대조군) | 접두합 캐시와 형제 offset 전파가 정확하다(0,1,2 / 1→4→1). 배치 게이팅으로 `recompute` 1회 | +| `d4_reentrant.luau` | `H-101` | `recompute` 재진입 → 꼬리가 `Length`를 낡은 합계로 덮어씀(2 vs 실제 3) | +| `d7_splice_fix.luau` | `H-102` | splice 후 캡처 인덱스가 낡아 뒤 형제 offset이 1로 남음. 위치를 재조정하면 5 | +| `d5_chain_error.luau` | `H-103` | `process`가 던지면 `NOOP` 슬롯이 남아 claim이 영구히 잠기고 명시적 철거로도 회수 안 됨 | +| `e1_errlevel.luau` | `H-104` | `error(msg)`와 `error(msg, 2)`가 가리키는 줄이 다르다 | +| `d9_hole2.luau` | `H-106` | 부기 구멍이 `C-6`의 메시지가 아니라 `getOffsetAt`의 익명 산술 에러로 먼저 터진다 | + +--- + +## 여기 없는 것 + +- **1~3차 패스의 재현 코드는 없다.** 1차는 문서 대 문서라 코드가 없고, + 2·3차는 최소 재현을 그때그때 짜서 돌린 것이라 그 문서 본문에 인라인으로 + 들어 있다(`H-71`의 `Relate` 누수, `H-77`, `H-73`~`H-76`, `H-78`~`H-83`). + 그쪽을 재검증하려면 그 항목 안의 코드 블록을 쓰면 된다. +- **`luau-test/`로 승격한 것은 없다.** 여기 있는 건 "설계가 이렇게 동작하는지" + 본 것이지 "M0/M2가 통과해야 할 스파이크"가 아니다. 발견이 확정 처리되면 + 그중 일부가 `luau-test/`로 갈 수 있고, 그건 그때 판단할 일이다. diff --git a/.claude/audit/handtrace-round7-reference-impl/RUN-runtime.txt b/.claude/audit/handtrace-round7-reference-impl/RUN-runtime.txt new file mode 100644 index 0000000..873446b --- /dev/null +++ b/.claude/audit/handtrace-round7-reference-impl/RUN-runtime.txt @@ -0,0 +1,123 @@ +=== t1_diamond === +초기 D:Get() = 110 +Observer 발화 횟수: 1 | 관측값: 220 +D 재계산 횟수: 2 | B: 2 | C: 2 +2회차 후 발화: 2 | 관측값: 220,330 +=== t2_error === +-- A:Set(2) (전파 중 O_bad가 error) -- +Set이 던졌는가: true | ./t2_error.luau:7: 사용자 콜백 실패 +O_bad 발화: 1 | O_good 발화: 0 | 관측: +C의 값은 자가치유되는가: C:Get() = 2 (정상) +하지만 O_good은 이 변경에 대해 영원히 안 울었다: 0 +-- 이후 A:Set(3)은 정상인가 -- +두 번째 Set도 같은 자리에서 던짐: true | O_good 발화: 0 +-- O_bad를 제거하고(구독 해제 없이 새 파동) -- +O_good 발화: 1 | 관측: 4 +→ 2와 3에 대한 통지는 영영 오지 않았다. 마지막 값만 보임 +=== t3_gate_error === +=== (a) blocker:Off() 순회 중 한 핸들이 error === +보류 중 — g1.withheld 비었나: false | g2.withheld 비었나: false +Off()가 던졌는가: true | ./t3_gate_error.luau:11: g1 하류에서 실패 +blocker.IsBlocked: false (이미 false — Off는 상태부터 바꾼다) +g2가 아직 붙들고 있나(비었으면 flush됨): false | o2 발화: 0 +→ g2는 IsBlocked=false 인데도 밀린 통지를 못 내보냄. b:Off()를 다시 불러도: + 두 번째 Off도 같은 자리에서 던짐: false | o2 발화: 1 + +=== (b) flush 도중 error → 떼어낸 배치가 소멸 === +보류됨. withheld 비었나: false +flush 진입 후 withheld 비었나(스왑됨): true | flush 횟수: 1 +gate.emitEpochMap은 이미 Sync됨 → 같은 리비전은 이제 규칙 3으로 삼켜진다 +같은 리비전 재도착 후 o 발화: 1 | 관측: +→ 배치는 사라졌고 재도착도 삼켜져, 그 변경에 대한 통지가 영영 안 나감 + (값은 살아있다: g:Get() = 2 ) +=== t4_throttle === +=== variant = localpending === + 타이머 살아있는 개수(idle이면 0): 0 + 통과: t=0.00 v=1 | t=1.00 v=2 | t=3.00 v=3 + 최종 통과: t=0.00 v=1 | t=1.00 v=2 | t=3.00 v=3 + t=5까지 살아있는 타이머: 0 + +=== variant = nopending === + 타이머 살아있는 개수(idle이면 0): 1 + 통과: t=0.00 v=1 | t=1.00 v=2 + 최종 통과: t=0.00 v=1 | t=1.00 v=2 | t=4.00 v=3 + t=5까지 살아있는 타이머: 1 +=== t5_recompute_race === +D:Get() = 2 (A=1로 계산됨) +A의 실제 값: 99 +다시 D:Get() = 2 ← 기대값 198 +D 재계산 횟수: 1 +로그: fn이 읽은 A = 1 + +-- 이후 A를 또 바꾸면 회복되는가 -- +D:Get() = 10 (회복됨) +=== t5b_fix === +D:Get() = 2 +다시 D:Get() = 198 ← 198이면 고쳐진 것 +재계산 횟수: 2 +=== t6_effect_gate === +blocker ON — 이 구간의 변경은 gated 쪽에서 유보되어야 한다 +fn 실행 기록: run(gated=2, plain=2) +gated가 아직 붙들고 있나: true +Off() 이후 fn 실행 기록: run(gated=2, plain=2) +gated observer 발화 횟수: 1 | plain observer: 1 +=== t7_poisoned === +-- 정상 배치 -- +recompute 횟수: 1 | blocker.IsOn(): false +-- 두 번째 배치에서 사용자 코드가 error -- +drive가 던졌는가: true | blocker.IsOn(): true ← 영구 On +-- 이후 런타임 :Add() 등이 부르는 setLength -- +recompute가 몇 번 돌았나: 0 (0이면 그 owner는 영영 재계산 안 됨) +-- 새 배치를 열어도 -- +blocker.IsOn(): false | 총 recompute: 2 +=== t8_reentrant === +관측 순서: 220,330 +Observer 발화: 2 | D 재계산: 3 +최종 D:Get() = 330 (A=3이므로 330이어야 함) +=== t9_gate_chain === +[상류 먼저] 유보 중 g1=true g2=false | 발화 0 + b1:Off 후 g1=false g2=true 발화=0 + 최종 발화 1 | v=5, batch=2 +[하류 먼저] 유보 중 g1=true g2=false | 발화 0 + b2:Off 후 g1=true g2=false 발화=0 + 최종 발화 1 | v=5, batch=2 +=== t10_subscribe_gc === +Observer 발화: 0 ← 공개 계약("예외 없이 그냥 계속 돎")대로면 1 +A의 구독자 수: 0 ← 중간 State가 수거됐으면 0 +Observer는 레지스트리에 살아있다: true | 그러나 다시는 안 울린다 +=== d1_basic === +offsets: 0 1 2 | 다음 자리: 3 +recompute 횟수: 1 +=== d2_slots === +초기: s1.Offset = 0 | s2.Offset = 1 | recompute: 1 +-- 런타임에 Slot1이 3개가 됨 (s1.Length:Set(3)) -- +s1.Offset = 0 | s2.Offset = 4 (기대 4) | recompute: 2 +-- Slot2도 2개가 됨 -- +s2.Offset = 4 (기대 4) | 4번 자리 offset = 6 (기대 6) +-- Slot1이 다시 0개 -- +s2.Offset = 1 (기대 1) | 4번 자리 = 3 (기대 3) +=== d4_reentrant === +초기 owner.Length = 0 | s2.Offset = 0 +-- s1.Length:Set(2) -- +owner.Length = 2 | 실제 합계 = 3 | N = 3 | recompute: 3 +s2.Offset = 2 (기대 2) | 3번 자리 offset = 2 (기대 2) + ❌ owner.Length가 실제 합계와 다르다 +=== d5_chain_error === +-- 백엔드 미주입 상태에서 Attribute 한 번 -- +던졌는가: true | ./d5_chain_error.luau:8: quad: setAttribute op이 주입되지 않았습니다(백엔드 팩토리 미설치) +claim 남았는가: true +체인 슬롯: 있음(retractor=function: 0x000055f521ddcb70) +-- 이제 백엔드를 붙이고 정상적으로 다시 시도 -- +성공했는가: false | ./d5_chain_error.luau:16: attribute "Score" is already bound by another owner +-- 명시적 철거로 회수되는가 -- +철거 후 claim 남았는가: true +철거 후 재시도 성공?: false | ./d5_chain_error.luau:16: attribute "Score" is already bound by another owner +=== d7_splice_fix === +[확정 설계 그대로] s3.Offset = 1 (기대 5) | invalidAfter = 2 +[위치 재조정함] s3.Offset = 5 (기대 5) | invalidAfter = 2 +=== d9_hole2 === +setOffsetSource에서 터지는가: true +메시지: ./dispatch.luau:46: attempt to perform arithmetic (add) on number and nil +=== e1_errlevel === +level 기본(1): ./e1_errlevel.luau:5: Slot:Add — index가 범위 밖(1..3): 9 +level 2 : ./e1_errlevel.luau:12: Slot:Add — index가 범위 밖(1..3): 9 diff --git a/.claude/audit/handtrace-round7-reference-impl/RUN-typecheck.txt b/.claude/audit/handtrace-round7-reference-impl/RUN-typecheck.txt new file mode 100644 index 0000000..5f591f8 --- /dev/null +++ b/.claude/audit/handtrace-round7-reference-impl/RUN-typecheck.txt @@ -0,0 +1,94 @@ +=== ty1_apply_call === +./ty1_apply_call.luau(25,19): TypeError: Expected this to be + 't1 where t1 = (t2) -> U ; t2 = { Apply: (t2, t1) -> U, Compute: ({ Get: (*CYCLE*) -> number }, ({ Get: (*CYCLE*) -> number }) -> U) -> *error-type* } & { Get: (*CYCLE*) -> number }' +but got + 'GateFactory' +=== ty1b === +./ty1b.luau(16,19): TypeError: Expected this to be + '(t1) -> t1 where t1 = { Apply: (t1, (t1) -> U) -> U } & { Get: (*CYCLE*) -> number }' +but got + 'GateFactory' +=== ty2_effect_deps === +./ty2_effect_deps.luau(18,14): TypeError: Unknown type 'Instance' +./ty2_effect_deps.luau(27,50): TypeError: Expected this to be + '(StateData & { Compute: (StateData, (StateData) -> U) -> *error-type* }) | Ref' +but got + '{ foo: number }' +=== ty3_epoch_effectfn === +./ty3_epoch_effectfn.luau(38,21): TypeError: Expected this to be + 'Epoch | EpochSet' +but got + '{ [SourceData & { Compute: (SourceData, (SourceData) -> U) -> StateData & { Compute: (StateData, (StateData) -> U) -> *error-type* }, Emit: (SourceData) -> (), Set: (SourceData, number) -> SourceData }]: true }' +./ty3_epoch_effectfn.luau(46,19): TypeError: Not all codepaths in this function return '(() -> ())?'. +=== ty4_effect_ret === +./ty4_effect_ret.luau(6,9): TypeError: Not all codepaths in this function return '(() -> ())?'. +./ty4_effect_ret.luau(13,1): TypeError: Not all codepaths in this function return '(() -> ())?'. +=== ty5_updatefn === +./ty5_updatefn.luau(11,6): TypeError: Expected this to be + '(any, number, SourceN, Element?, unknown?) -> (Element?, unknown?)' +but got + '(any, number, SourceN, unknown, unknown) -> Element'; +Expected the return types to be `Element?, unknown?`, but got `Element` +./ty5_updatefn.luau(21,6): TypeError: Expected this to be 'T?, UD?', but got 'nil' +./ty5_updatefn.luau(28,1): TypeError: Not all codepaths in this function return 'Element'. +./ty5_updatefn.luau(26,6): TypeError: Expected this to be + '(any, number, SourceN, Element?, unknown?) -> (Element?, unknown?)' +but got + '(any, number, SourceN, unknown, unknown) -> Element'; +Expected the return types to be `Element?, unknown?`, but got `Element` +./ty5_updatefn.luau(31,6): TypeError: Expected this to be 'T?, UD?', but got '()' +=== ty6_multiret === +./ty6_multiret.luau(6,18): TypeError: Expected this to be 'El?, number?', but got 'El?' +./ty6_multiret.luau(7,18): TypeError: Expected this to be 'El?, number?', but got 'El?' +./ty6_multiret.luau(8,6): TypeError: Not all codepaths in this function return 'El?, number?'. +./ty6_multiret.luau(16,7): TypeError: Not all codepaths in this function return 'El?'. +=== ty7_fix === +./ty7_fix.luau(12,7): TypeError: Expected this to be + '((number) -> ()) | ((number) -> (El?, number?)) | ((number) -> El?)' +but got + '(number) -> number' +=== ty8_gate_apply === +./ty8_gate_apply.luau(41,21): TypeError: Expected this to be + '(() -> ()) -> () -> ()' +but got + '(Blocker, () -> ()) -> () -> ()'; +Expected the 1st parameter to be a supertype of `() -> ()`, but got `Blocker` +=== ty9_mixed_deps === +./ty9_mixed_deps.luau(20,23): TypeError: Consider annotating the return with number +./ty9_mixed_deps.luau(22,6): TypeError: Expected this to be + 't1 where t1 = { read Get: (t1) -> (number, ...unknown) }' +but got + 'SourceData'; +this is because + * `Get` is a read-only property in the latter type, but the former type requires a read-write property + * the return types of property `Get` of the 1st parameter of property `Get` are `number` and the return type pack tail of property `Get` of the 1st parameter of property `Get` is `...unknown`, and `number` is not a supertype of `...unknown` +./ty9_mixed_deps.luau(22,9): TypeError: Expected this to be + 't1 where t1 = { read Get: (t1) -> (unknown, ...unknown) }' +but got + 'StateData'; +this is because + * `Get` is a read-only property in the latter type, but the former type requires a read-write property + * the return types of property `Get` of the 1st parameter of property `Get` are `boolean` and the return type pack tail of property `Get` of the 1st parameter of property `Get` is `...unknown`, and `boolean` is not a supertype of `...unknown` +./ty9_mixed_deps.luau(28,6): TypeError: Expected this to be + 'SourceData' +but got + 'StateData'; +this is because + * Expected the return type of property `Get` of the 1st parameter of property `Get` to be exactly `number`, but got `boolean` + * Expected the return type of property `Get` to be exactly `number`, but got `boolean` +./ty9_mixed_deps.luau(28,9): TypeError: Expected this to be + 'StateData' +but got + 'SourceData'; +this is because + * Expected the return type of property `Get` of the 1st parameter of property `Get` to be exactly `boolean`, but got `number` + * Expected the return type of property `Get` to be exactly `boolean`, but got `number` +=== ty10_contrast === +./ty10_contrast.luau(16,23): TypeError: Consider annotating the return with number +./ty10_contrast.luau(16,82): TypeError: Expected this to be + 't1 where t1 = { read Get: (t1) -> (number, ...unknown) }' +but got + 'SourceData'; +this is because + * `Get` is a read-only property in the latter type, but the former type requires a read-write property + * the return types of property `Get` of the 1st parameter of property `Get` are `number` and the return type pack tail of property `Get` of the 1st parameter of property `Get` is `...unknown`, and `number` is not a supertype of `...unknown` diff --git a/.claude/audit/handtrace-round7-reference-impl/spikes/chain.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/chain.luau new file mode 100644 index 0000000..9251dd2 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/core.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/core.luau new file mode 100644 index 0000000..451c5c3 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/d1_basic.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/d1_basic.luau new file mode 100644 index 0000000..5e6be7f --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/d2_slots.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/d2_slots.luau new file mode 100644 index 0000000..3cb59fa --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/d4_reentrant.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/d4_reentrant.luau new file mode 100644 index 0000000..7e792c3 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/d5_chain_error.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/d5_chain_error.luau new file mode 100644 index 0000000..a37d63e --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/d7_splice_fix.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/d7_splice_fix.luau new file mode 100644 index 0000000..06301a6 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/d9_hole2.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/d9_hole2.luau new file mode 100644 index 0000000..c86d114 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/dispatch.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/dispatch.luau new file mode 100644 index 0000000..b399e6f --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/e1_errlevel.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/e1_errlevel.luau new file mode 100644 index 0000000..18cd4c1 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t10_subscribe_gc.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t10_subscribe_gc.luau new file mode 100644 index 0000000..dcb4dcc --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t1_diamond.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t1_diamond.luau new file mode 100644 index 0000000..3cc6745 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t2_error.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t2_error.luau new file mode 100644 index 0000000..7dbd502 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t3_gate_error.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t3_gate_error.luau new file mode 100644 index 0000000..0beac1d --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t4_throttle.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t4_throttle.luau new file mode 100644 index 0000000..65a7079 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t5_recompute_race.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t5_recompute_race.luau new file mode 100644 index 0000000..d2892be --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t5b_fix.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t5b_fix.luau new file mode 100644 index 0000000..2641359 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t6_effect_gate.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t6_effect_gate.luau new file mode 100644 index 0000000..ba4a4ac --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t7_poisoned.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t7_poisoned.luau new file mode 100644 index 0000000..ed6a86d --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t8_reentrant.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t8_reentrant.luau new file mode 100644 index 0000000..37c4817 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/t9_gate_chain.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/t9_gate_chain.luau new file mode 100644 index 0000000..c8506bf --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty10_contrast.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty10_contrast.luau new file mode 100644 index 0000000..18fccdb --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty1_apply_call.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty1_apply_call.luau new file mode 100644 index 0000000..f339f1b --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty1b.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty1b.luau new file mode 100644 index 0000000..c3d256f --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty2_effect_deps.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty2_effect_deps.luau new file mode 100644 index 0000000..e5d6f3a --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty3_epoch_effectfn.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty3_epoch_effectfn.luau new file mode 100644 index 0000000..b0acea9 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty4_effect_ret.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty4_effect_ret.luau new file mode 100644 index 0000000..3000068 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty5_updatefn.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty5_updatefn.luau new file mode 100644 index 0000000..517b904 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty6_multiret.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty6_multiret.luau new file mode 100644 index 0000000..5312837 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty7_fix.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty7_fix.luau new file mode 100644 index 0000000..b2e4b51 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty8_gate_apply.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty8_gate_apply.luau new file mode 100644 index 0000000..9328dc0 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7-reference-impl/spikes/ty9_mixed_deps.luau b/.claude/audit/handtrace-round7-reference-impl/spikes/ty9_mixed_deps.luau new file mode 100644 index 0000000..c636ad0 --- /dev/null +++ b/.claude/audit/handtrace-round7-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-round7.md b/.claude/qa-request/pre-implementation-handtrace-round7.md index 8009a99..020d74b 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round7.md +++ b/.claude/qa-request/pre-implementation-handtrace-round7.md @@ -14,6 +14,13 @@ 감사**(`H-101`~`H-106`, 2026-08-25 추가). 발견 번호는 패스를 가로질러 이어서 매긴다(6라운드와 같은 방식). +**재현 코드**: 4·5·6차 패스가 쓴 참조 구현과 스파이크는 +`.claude/audit/handtrace-round7-reference-impl/`에 전량 있다(1~3차는 최소 +재현을 각 항목 안에 인라인으로 실었다). **⚠️ 그 폴더의 참조 구현은 확정 +의사코드의 *전사물*이라 그 자체가 틀렸을 수 있다** — 발견을 판정할 때 +재실행은 검증이 아니고, 그 README의 원문 대조표를 따라 줄 단위로 대조해야 +한다. + **상태**: **[2026-08-25] 발견 보고 — 아무것도 반영하지 않았다.** 판정은 사용자가 이 목록을 보고 한다. 6라운드까지의 결정은 뒤집지 않는 것을 기본으로 했고, 뒤집어야 한다고 보는 항목은 **그 근거의 어느 추론이