audit: 7라운드 4·5·6차 패스의 참조 구현·스파이크를 저장소로 (재현 가능하게)
지금까지 세션 스크래치패드에만 있던 것을
.claude/audit/handtrace-round7-reference-impl/로 옮긴다. 다음 세션이
발견 52건을 판정하려면 이게 있어야 한다.
구성:
- spikes/core.luau — 반응형 코어(state-epoch §2~§5, 전파 모델,
gate 4·8번, Blocker, H-23 스냅샷)
- spikes/dispatch.luau — Length/Offset 부기(getOffsetAt 접두합 캐시,
recompute, setLength/setOffsetSource, 배치 게이팅)
- spikes/chain.luau — Dispatch 체인(하강 diff, retractFrom)
- spikes/*.luau — 시나리오 29개(런타임 18 + 타입 11)
- RUN-runtime.txt / RUN-typecheck.txt — 2026-08-25 실행 결과 스냅샷
- README.md — 원문 대조표 + 스파이크→발견 대조표
⚠️ README가 가장 중요하다: 이 참조 구현은 base/의 확정 의사코드를 손으로
옮긴 **전사물**이라 그 자체가 틀렸을 수 있다. 그래서 "재실행은 검증이
아니다"를 못박고, (a) 어느 파일이 어느 절을 옮긴 것인지 대조표와
(b) 문서와 **의도적으로** 다른 곳 3개(H-72 때문에 임시로 둔 PeekDiffers,
H-102의 A/B 대조를 위한 위치 박스, 생명주기 게이팅 부재=H-97)를 같이
싣는다. 그 셋 외의 모든 차이는 전사 오류 후보다.
부수로 round7 문서 머리에 이 폴더 포인터와 같은 경고를 달았고,
.claude/README.md의 audit/ 행에도 등재했다.
Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
ec88253ad2
commit
6f939a8d2d
37 changed files with 1534 additions and 1 deletions
|
|
@ -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/` 로그와의 중복 방지) |
|
| `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라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 |
|
| `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` |
|
| `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<T>` 레코드 필드 합성) 복구도 이 조사 중 완료 — 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<<T,U>>(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<T>` 레코드 필드 합성) 복구도 이 조사 중 완료 — 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<<T,U>>(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은 판단 대상 |
|
| `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/`의 기계 점검과 짝을 이루는 의미론적 점검 계층 |
|
| `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건이 감사로 잡힘) |
|
| `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건이 감사로 잡힘) |
|
||||||
|
|
|
||||||
115
.claude/audit/handtrace-round7-reference-impl/README.md
Normal file
115
.claude/audit/handtrace-round7-reference-impl/README.md
Normal file
|
|
@ -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<T>) -> U` 자리에 안 들어간다(제네릭·비제네릭 양쪽). 직접 호출은 통과 |
|
||||||
|
| `ty4_effect_ret.luau` | `H-95` | `Effect`의 `fn`이 아무것도 안 돌려주면 에러. 가변 반환 팩·유니온은 통과 |
|
||||||
|
| `ty5_updatefn.luau` / `ty6_multiret.luau` | `H-95` | 선언된 다중 반환보다 적게 반환하면 에러(값 하나만/`nil`만/없음 전부) |
|
||||||
|
| `ty7_fix.luau` | `H-95` 갈래 | 함수 타입 유니온이 네 모양을 다 받고 엉뚱한 반환은 여전히 잡는다 |
|
||||||
|
| `ty9_mixed_deps.luau` / `ty10_contrast.luau` | `H-96` | deps 0개는 무주석 통과, deps가 붙으면 무주석이 깨진다. dep 주석 시 `Source`/`State` 혼합도 정확히 좁혀진다 |
|
||||||
|
| `ty3_epoch_effectfn.luau` | `H-100` · (대조군) | `Source`가 `Epoch`를 구조적으로 만족한다(성립). `{[Source<T>]: true}`는 `{[Epoch]: true}` 자리에 안 들어간다 |
|
||||||
|
| `ty2_effect_deps.luau` | (대조군) | `State<any> | Ref<any>` 가변인자로 이형 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/`로 갈 수 있고, 그건 그때 판단할 일이다.
|
||||||
123
.claude/audit/handtrace-round7-reference-impl/RUN-runtime.txt
Normal file
123
.claude/audit/handtrace-round7-reference-impl/RUN-runtime.txt
Normal file
|
|
@ -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
|
||||||
|
|
@ -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: <U>(t2, t1) -> U, Compute: <U>({ 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: <U>(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<any> & { Compute: <U>(StateData<any>, (StateData<any>) -> U) -> *error-type* }) | Ref<any>'
|
||||||
|
but got
|
||||||
|
'{ foo: number }'
|
||||||
|
=== ty3_epoch_effectfn ===
|
||||||
|
./ty3_epoch_effectfn.luau(38,21): TypeError: Expected this to be
|
||||||
|
'Epoch | EpochSet'
|
||||||
|
but got
|
||||||
|
'{ [SourceData<number> & { Compute: <U>(SourceData<number>, (SourceData<number>) -> U) -> StateData<U> & { Compute: <U>(StateData<U>, (StateData<U>) -> U) -> *error-type* }, Emit: (SourceData<number>) -> (), Set: (SourceData<number>, number) -> SourceData<number> }]: 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<number>';
|
||||||
|
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<boolean>';
|
||||||
|
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<number>'
|
||||||
|
but got
|
||||||
|
'StateData<boolean>';
|
||||||
|
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<boolean>'
|
||||||
|
but got
|
||||||
|
'SourceData<number>';
|
||||||
|
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<number>';
|
||||||
|
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`
|
||||||
|
|
@ -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
|
||||||
209
.claude/audit/handtrace-round7-reference-impl/spikes/core.luau
Normal file
209
.claude/audit/handtrace-round7-reference-impl/spikes/core.luau
Normal file
|
|
@ -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
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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, "| 그러나 다시는 안 울린다")
|
||||||
|
|
@ -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, ","))
|
||||||
|
|
@ -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에 대한 통지는 영영 오지 않았다. 마지막 값만 보임")
|
||||||
|
|
@ -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(), ")")
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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(), "(회복됨)")
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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이어야 함)")
|
||||||
|
|
@ -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("하류 먼저")
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
--!strict
|
||||||
|
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||||
|
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
|
||||||
|
export type State<T> = StateData<T> & {
|
||||||
|
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>, prev: U?) -> U) -> U,
|
||||||
|
ComputeN: <U, D...>(self: StateData<T>, fn: (self: StateData<T>, prev: U?, D...) -> U, D...) -> U,
|
||||||
|
}
|
||||||
|
local s: State<number> = nil :: any
|
||||||
|
local a: SourceData<number> = 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<number>) return self:Get() + d1:Get() end, a)
|
||||||
|
local n3: number = r3
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
--!strict
|
||||||
|
-- base/source-state-plan.md: state:Apply(factory) — `factory: (State<T>) -> U): U`
|
||||||
|
-- base/debounce-throttle-plan.md 5-4: Debounce{...}가 돌려주는 것은 __call 테이블
|
||||||
|
-- (setmetatable({}, {__call = ..., __index = {Flush=..., Cancel=...}}))
|
||||||
|
-- base/gate-plan.md 2번: "__call 테이블이 Luau에서 (State<T>) -> U 함수 타입 자리에
|
||||||
|
-- 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력)" — 확인 안 하기로 접었음
|
||||||
|
|
||||||
|
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||||
|
export type State<T> = StateData<T> & {
|
||||||
|
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
|
||||||
|
Apply: <U>(self: State<T>, factory: (State<T>) -> U) -> U,
|
||||||
|
}
|
||||||
|
|
||||||
|
local s: State<number> = nil :: any
|
||||||
|
|
||||||
|
-- (A) 평범한 함수 팩토리 — 양성 대조군
|
||||||
|
local a = s:Apply(function(self: State<number>): State<number> return self end)
|
||||||
|
|
||||||
|
-- (B) __call 테이블 팩토리 — 5-4절이 확정한 모양
|
||||||
|
type GateFactory = typeof(setmetatable(
|
||||||
|
{} :: { Flush: () -> (), Cancel: () -> () },
|
||||||
|
{} :: { __call: (any, State<number>) -> State<number> }
|
||||||
|
))
|
||||||
|
local Debounced: GateFactory = nil :: any
|
||||||
|
local b = s:Apply(Debounced)
|
||||||
|
|
||||||
|
-- (C) 직접 호출은 되는가
|
||||||
|
local c = Debounced(s)
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
--!strict
|
||||||
|
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||||
|
export type State<T> = StateData<T> & {
|
||||||
|
Apply: <U>(self: State<T>, factory: (State<T>) -> U) -> U,
|
||||||
|
}
|
||||||
|
local s: State<number> = nil :: any
|
||||||
|
|
||||||
|
type GateFactory = typeof(setmetatable(
|
||||||
|
{} :: { Flush: () -> (), Cancel: () -> () },
|
||||||
|
{} :: { __call: (any, State<number>) -> State<number> }
|
||||||
|
))
|
||||||
|
local Debounced: GateFactory = nil :: any
|
||||||
|
|
||||||
|
-- (A) 제네릭이 아닌 평범한 함수 파라미터 자리에도 안 들어가는가?
|
||||||
|
local function takesFn(f: (State<number>) -> State<number>) return f(s) end
|
||||||
|
local a = takesFn(Debounced)
|
||||||
|
|
||||||
|
-- (B) 유니온으로 열면 되는가
|
||||||
|
local function takesEither(f: ((State<number>) -> State<number>) | GateFactory)
|
||||||
|
return (f :: any)(s)
|
||||||
|
end
|
||||||
|
local b = takesEither(Debounced)
|
||||||
|
local b2 = takesEither(function(x: State<number>) return x end)
|
||||||
|
|
@ -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<T> = { Get: (self: StateData<T>) -> T }
|
||||||
|
export type State<T> = StateData<T> & {
|
||||||
|
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
|
||||||
|
}
|
||||||
|
export type Ref<T> = { Value: T?, Set: (self: Ref<T>, v: T) -> (), Callback: (self: Ref<T>, fn: (T) -> ()) -> () }
|
||||||
|
export type EffectHandle = { Rerun: (self: EffectHandle) -> (), Subscribe: (self: EffectHandle) -> () }
|
||||||
|
|
||||||
|
type Dep = State<any> | Ref<any>
|
||||||
|
local function Effect(fn: (self: EffectHandle) -> (() -> ())?, ...: Dep): EffectHandle
|
||||||
|
return nil :: any
|
||||||
|
end
|
||||||
|
|
||||||
|
local n: State<number> = nil :: any
|
||||||
|
local b: State<boolean> = nil :: any
|
||||||
|
local r: Ref<Instance> = 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})
|
||||||
|
|
@ -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<T> = { Get: (self: StateData<T>) -> T }
|
||||||
|
export type State<T> = StateData<T> & {
|
||||||
|
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
|
||||||
|
}
|
||||||
|
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
|
||||||
|
export type Source<T> = SourceData<T> & {
|
||||||
|
Set: (self: SourceData<T>, v: T) -> SourceData<T>,
|
||||||
|
Emit: (self: SourceData<T>) -> (),
|
||||||
|
Compute: <U>(self: SourceData<T>, fn: (self: SourceData<T>) -> U) -> State<U>,
|
||||||
|
}
|
||||||
|
|
||||||
|
local src: Source<number> = nil :: any
|
||||||
|
local st: State<number> = 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<number>]: true}를 EpochSet 자리에
|
||||||
|
local rawSet: { [Source<number>]: 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) -- 아무것도 안 돌려줌 (가장 흔한 모양)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
--!strict
|
||||||
|
-- base/slot-plan.md 확정: updateFn<UD>(item, index, offset, prev, userdata) -> (T|nil, UD?)
|
||||||
|
type Element = { tag: string }
|
||||||
|
export type SourceN = { Get: (self: SourceN) -> number }
|
||||||
|
|
||||||
|
local function List<T, UD>(
|
||||||
|
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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
--!strict
|
||||||
|
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||||
|
export type State<T> = StateData<T> & {
|
||||||
|
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
|
||||||
|
Apply: <U>(self: State<T>, factory: (State<T>) -> U) -> U,
|
||||||
|
Gate: (self: State<T>, setup: (emit: () -> ()) -> (() -> ())) -> State<T>,
|
||||||
|
Block: (self: State<T>, blocker: Blocker) -> State<T>,
|
||||||
|
}
|
||||||
|
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
|
||||||
|
export type Source<T> = SourceData<T> & {
|
||||||
|
Set: (self: SourceData<T>, v: T) -> SourceData<T>,
|
||||||
|
Apply: <U>(self: Source<T>, factory: (State<T>) -> U) -> U,
|
||||||
|
Gate: (self: Source<T>, setup: (emit: () -> ()) -> (() -> ())) -> State<T>,
|
||||||
|
}
|
||||||
|
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<number> = nil :: any
|
||||||
|
local src: Source<number> = 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<number>) -> State<number> = 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)
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
--!strict
|
||||||
|
-- 7라운드 부록이 확인한 이형 팩은 StateData<number>/StateData<boolean>처럼
|
||||||
|
-- "같은 종류, 다른 타입 인자"였다. 실제 코드는 Source와 State가 섞인다.
|
||||||
|
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||||
|
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
|
||||||
|
export type State<T> = StateData<T> & {
|
||||||
|
ComputeN: <U, D...>(self: StateData<T>, fn: (self: StateData<T>, prev: U?, D...) -> U, D...) -> U,
|
||||||
|
}
|
||||||
|
|
||||||
|
local s: State<string> = nil :: any
|
||||||
|
local a: SourceData<number> = nil :: any -- store.a (Source)
|
||||||
|
local b: StateData<boolean> = nil :: any -- 파생 State
|
||||||
|
|
||||||
|
local r = s:ComputeN(function(self, prev, d1: SourceData<number>, d2: StateData<boolean>)
|
||||||
|
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<number>, d2: StateData<boolean>)
|
||||||
|
return 0
|
||||||
|
end, b, a)
|
||||||
|
|
@ -14,6 +14,13 @@
|
||||||
감사**(`H-101`~`H-106`, 2026-08-25 추가). 발견 번호는 패스를 가로질러
|
감사**(`H-101`~`H-106`, 2026-08-25 추가). 발견 번호는 패스를 가로질러
|
||||||
이어서 매긴다(6라운드와 같은 방식).
|
이어서 매긴다(6라운드와 같은 방식).
|
||||||
|
|
||||||
|
**재현 코드**: 4·5·6차 패스가 쓴 참조 구현과 스파이크는
|
||||||
|
`.claude/audit/handtrace-round7-reference-impl/`에 전량 있다(1~3차는 최소
|
||||||
|
재현을 각 항목 안에 인라인으로 실었다). **⚠️ 그 폴더의 참조 구현은 확정
|
||||||
|
의사코드의 *전사물*이라 그 자체가 틀렸을 수 있다** — 발견을 판정할 때
|
||||||
|
재실행은 검증이 아니고, 그 README의 원문 대조표를 따라 줄 단위로 대조해야
|
||||||
|
한다.
|
||||||
|
|
||||||
**상태**: **[2026-08-25] 발견 보고 — 아무것도 반영하지 않았다.** 판정은
|
**상태**: **[2026-08-25] 발견 보고 — 아무것도 반영하지 않았다.** 판정은
|
||||||
사용자가 이 목록을 보고 한다. 6라운드까지의 결정은 뒤집지 않는 것을
|
사용자가 이 목록을 보고 한다. 6라운드까지의 결정은 뒤집지 않는 것을
|
||||||
기본으로 했고, 뒤집어야 한다고 보는 항목은 **그 근거의 어느 추론이
|
기본으로 했고, 뒤집어야 한다고 보는 항목은 **그 근거의 어느 추론이
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue