Compare commits

...

23 commits

Author SHA1 Message Date
f020f3f36f
docs: 인덱스 레이어에 후속 2(H-163/H-164, EmitReceive, _catchUp) 반영
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 16:00:36 +09:00
c8d4569819
docs: 세션 로그 후속 2 (H-163/H-164/EmitReceive/_catchUp)
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 15:56:23 +09:00
ab952eea24
qa: Observer:_catchUp() — 캐치업 발화 네 곳 단일화 (사용자 확정), Slot 꼬리는 bindLifetime의 _catchUp에 위임
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 15:55:51 +09:00
5e96cd6258
qa: 10라운드 후속 H-163/H-164 + EmitReceive — 전파 루프 계층 분리, Slot 재마운트 캐치업, emitFrom nil 계약
- EmitReceive: State:_emitDown은 sub:_receive(from)만, Observer:_receive가 canExecute 판정·_rerunRequired 홀드
  (사용자 지시 — 계층간 지식 분리), State:Observer(fn) 생성자 순서(플래그 참 → fn 1회 → 내림 → _subs)
- H-163 (a′): _listObserver 재마운트 바인드는 materializeSlotTree 꼬리(트리 확정 뒤)에서 재마운트일 때만,
  홀드가 있었으면 reconcile 1회 (감사가 잡은 첫 마운트 이중 bind·reconcile 미실행 결함 정정)
- H-164 (c): emitFrom == nil = 출처 없음(설치 또는 캐치업), from 보관 기각
- 감사 4→3→2→2(각도 교체, 마지막 둘은 표현), /code-review 8 반영분 포함

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 15:52:15 +09:00
ecc6b0e538
qa: 10라운드 후속 H-158~H-162 반영 — :Block 폐기(__apply), _rerunRequired 홀드(_installed 흡수), Claim M5, Void export
- H-158 state:Block → state:Apply(blocker), Blocker:__apply 메소드형(호출 규약 명시)
- H-159 사용자 제안 _rerunRequired 홀드: fire=Update→Rerun, rawRerun이 실행 불가 상태의 요청을 홀드,
  _installed 폐기, Observer 대칭(전파 루프 else + bind/subscribe 1회 발화, 생성자 순서 fn→_subs)
- H-160 홀드로 정정 + "error 나면 그 Effect는 죽는다" 계약 / H-161 Claim M5 스코프 / H-162 Void.luau 잎 모듈
- 감사 7→6→1→0, /code-review high 10건 중 8 반영, 둘(H-163 Slot 내부 Observer×홀드, H-164 emitFrom nil)은 문항으로

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 14:43:56 +09:00
ae34cfa316
qa: 10라운드 결정 반영 (H-147~H-158) — fn/cleanup 자기 구독 금지, Refresh 캐치업 폐기, 루트는 Claim으로
- H-147 (A): fn/cleanup은 자기 구독을 못 바꾼다 — rawRerun(force)/Rerun 분리, 진입 canExecute 게이트,
  네 진입점+_bindDestroying에 _running/_cleanupRunning 가드, H-143(원샷) 소멸, 자기 leaf 파괴 UB
- H-148: 루트는 밖에서 .Parent=가 아니라 quad가 Claim으로 소유 → research/existing-mount-plan.md 신설,
  H-146 예외·전용 문구 폐기, archive 부활 배너
- H-149 Observer 진입점 인라인 / H-150 Effect._blocker 제거 / H-151 _epochs는 emit 때만(게이트는 emit 경로만
  미룬다 계약) / H-152 GateNode StateBrand:register / H-153 Store 예약 이름 런타임 가드 + 그림자=store 자신 /
  H-154 InstanceChildHandler dedup / H-155~H-157 stale
- 감사 3→5→2→3→1→0, /code-review high 10건 중 7 반영, 셋(H-159~H-161)은 -round10.md §4 문항으로

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 13:00:49 +09:00
d2d67c7aeb
qa: 10라운드 광범위 탐사 완료 — 발견 H-150~H-157 (🔴 0 / 🟡 5 / 🟢 3), §4 배치 문항 7건 회신 대기
- 신선한 탐사자(fable) 단일 컨텍스트, 지시서 -round10-brief.md §2 레인 A·C 완료, B 부분, D ALL PASS
- audit/handtrace-round10-reference-impl/: round7 참조 구현을 현재 계약으로 갱신·재실행
  (부수: round7/ref9 _recompute 첫 인자 오류 발견·정정)
- base/·인덱스 레이어는 미변경 — 결정은 사용자 배치 회신 뒤 -round10-followup.md로

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 01:03:05 +09:00
0ec22fbe73
qa: 9라운드 후속 H-143~H-146 확정·반영 + 감사 8라운드·code-review — 잔여 셋은 10라운드 문항지로
- H-143 Rerun 꼬리: 실행 중 사망(wasAlive and not canExecute)이면 cleanup 즉시 소진
  (처음 쓴 not canExecute 판정은 생성자 최초 설치를 죽여 감사 2라운드가 정정)
- H-144 재구독 꼬리(Refresh 먼저) + 진입점은 EffectHandle 자기 것 (b)
  (Observer 함수 배정은 콜론 위임으로 꼬리 2회 — 감사 4라운드, luau 재현)
  → conventions.md 설계 원칙 신설: 하나의 무언가가 두 일을 하지 않는가
- H-145 bk.indexOfElement weak-key / H-146 루트 .Parent는 사용자 몫, Mount 없음
- 감사 1→1→1→1→1→1→1→0, /code-review high 10건 중 7 반영
- H-147~H-149는 qa-request/pre-implementation-handtrace-round10.md §4로 (배치 회신)
- round10 지시서(-brief.md) 신설, 광범위 탐사 예정

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 00:28:52 +09:00
7f5868302e
qa: 9라운드 Q4~Q10 + H-138/H-139/H-142 확정·반영, 감사 8라운드, code-review 반영 — H-143~H-146 판단 대기
Q4 EffectHandle 네 진입점 의사코드(Observer 것 재사용, Unsubscribe만 게이트 통과
뒤 cleanup) / Q5 M2 공통 기반에 Ref 최소형(+ Quad.Ref 필드) / Q6 WeakUnsubscribe
관대 명문화 / Q7 폐기 블록 archive/effect-internal-observer-cascade-reversed.md /
Q8 InstanceChildHandler 부기(Parent → setLength(1), retractor는 Parent=nil →
해제) / Q9 문항 전제 정정 — Tween 절 스케치 hint==nil 줄은 복사 오류, retractor는
function() end / Q10 :List reconcile 재실행도 배치 Blocker(ownsGate — 네스팅 불가) /
H-138 숏핸드 우선순위 > PropertyHandler, 충돌 방지는 UI 접두어 / H-139
New(name)(props)·Dispatch.drive 파이프라인 의사코드(bind-system-plan) — 쓰면서
빈 배열 파트 가드와 H-142 발견 / H-142 props에 Parent 금지(부모가 하는 일) —
순서 문제 소멸, D·Modifier 타입 제외 + PropertyHandler 거부(배선은 에이전트 선택으로
갈라 적음). H-129/H-131 정정.

감사 8라운드(5→3→4→5→3→1→3→1) — 1라운드가 drive 의사코드의 H-17 위반(post-pass
포함 전체 감쌈)을 잡음, 이후는 기록 문서 표기. /code-review high 10건 — 여섯
반영(그중 셋이 이 세션의 H-134 반영이 만든 것), 넷은 새 메커니즘이라 문항
H-143~H-146으로(question.md 최우선 절). doc-check ERROR 0.

Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
Co-authored-by: qwreey <me@qwreey.moe>
2026-08-27 15:52:48 +09:00
031495cc0b
qa: 9라운드 손 트레이싱 실행 + Q1~Q3 확정·반영 — 체크포인트 (Q4~Q10 다음 세션)
8라운드가 써둔 지시서(-round9-brief.md)대로 커밋 9dd8213의 델타를 재트레이싱해
발견 18건(H-124~H-141)을 냈고(qa-request/pre-implementation-handtrace-round9.md),
§4 문항 중 Q1~Q3를 사용자와 확정해 base/·ROADMAP에 반영했다. 결정의 소스는
-round9-followup.md(진행 표가 상태의 소스). 🔴 둘 다 luau 실측 재현.

Q1 H-124 — recompute가 lengthList[i]를 되감기 판정보다 먼저 읽어, offset:Set 안의
  사용자 코드가 커서 뒤 자리 수를 줄이면 sum += nil로 죽고 recomputeBlocker가
  영구 On. 되감기 판정을 앞으로(continue), 읽기·누적은 되감지 않을 때만.
Q2 H-125 — 재마운트 시 setOffsetSource가 slot.Offset을 바꾸는 순간 _baseObserver가
  unbind 상태라 두 필드가 0으로 안 내려가 옛 베이스의 offsetCache[1]을 씀.
  사용자 확정: Offset·_baseObserver를 Slot 생성자로(첫 마운트/재마운트 분기
  소멸), materializeSlotTree는 blocker:On → bindLifetime → setOffsetSource,
  파괴는 _destroyed 플래그 하나(핸들은 unbind만, mutate CRUD·:List·마운트 진입
  error, Owned=false는 안 섬, 이중 dispose no-op).
Q3 H-126/H-137/H-141 — element→index 맵이 Slot 층(slot._elemIndex)과 Dispatch
  층(bk.tokens/indexOfToken)에 두 벌 있었고, 후자의 token은 사용자가 정한 적
  없는 것(2026-08-25 /code-review가 발명해 사용자 인용문 옆에 앉아 있던 것).
  bk.indexOfElement 하나로 통일, setLength 5번째 인자 element, splice가 비운
  자리는 세 배열 전부 처리. H-137 소멸.

부수: H-140(ROADMAP의 폐기된 "해제 시 slot.Offset = nil" 잔존) 정정,
H-125 피해 범위를 "중첩 Slot의 Offset"으로 축소(유저 체인은 요소 인스턴스에
바인드돼 전파됨 — 실측), G각도로 "for d in seen do는 유효한 Luau가 아니다"가
거짓임을 확인, keyof<{}> 빈 Store 실측 클린.

conventions.md 신설: "/code-review(그리고 메인 세션)가 내놓는 새 필드·인자·
이름·메커니즘은 발견이지 결정이 아니다" — 이 세션에서 메인 세션도 같은 실수를
세 번 했다(subject 인자 / Observer 위치 필드 = 기각된 Effect userdata 재개방 /
조회 클로저).

검증: quad-doc-auditor 6라운드(확실 1→1→3→1→1→0, base/ 본문 결함은 1라운드
이후 0건 — 나머지는 인덱스 레이어·인용처), doc-check ERROR 0. /code-review high는
Q4~Q10 반영 뒤 한 번에(같은 파일을 또 건드려 diff가 섞이므로 지금 체크포인트).

Claude-Session: https://claude.ai/code/session_01F9zgJ4c4kDitAoQMm9qxKn
Co-authored-by: qwreey <me@qwreey.moe>
2026-08-27 01:30:48 +09:00
9dd82136bd
qa: 8라운드 손 트레이싱 처리 — 결정 Q1~Q10 반영, M2 착수 게이트 0
발견 17건(H-107~H-123)의 사용자 결정을 base/ 전체에 반영. 7라운드 확정 중
뒤집힌 건 없고, 고친 건 전부 7라운드가 base/에 내려앉을 때 생긴 누락·충돌
(하루 차로 확정된 결정들이 서로를 못 본 자리)이다. 결정의 소스는
qa-request/pre-implementation-handtrace-round8-followup.md.

계약 변경 넷:
- Ref 콜백이 fn(value, ref) — 2번째가 곧 출처 Epoch. Effect가 Update(from)에
  넘길 유일한 통로였다(k(value)뿐이면 Update(nil) 크래시, 실측 재현).
- Observer fn이 세 자리 fn(targetState, self, emitFrom) + observer._state
  강참조. 옛 2-인자는 "self는 리시버" 계약과 정면 충돌해 무인자
  state:Observer()의 내부 콜백이 즉사했다.
- WeakSubscribe도 .Subscribed를 세운다. 안 그러면 Effect의 State dep 전량이
  조용히 침묵. 해제는 "건 경로로 푼다"(양방향 fail-fast).
- 예약 키 진단이 CheckReservedKeys<keyof<T>> — T를 통째로 넘기는 배선은
  실사용 T에서 아예 안 돈다(Source<T>가 *error-type*을 품어 유효한 Store
  전부에 스퓨리어스 에러).

사용자가 문항의 전제를 두 번 정정: Ref 콜백과 Observer 콜백은 이질적이라
애초에 통합 대상이 아니었고(Observer엔 자기 epoch가 없다), H-118은 소유권
문제가 아니라 gate-plan 5번의 문장이 틀린 것이었다(🟡🟢).

커밋 전 검증 — 감사 11라운드(44건, 0건으로 수렴) + /code-review high
7라운드(42건) = 86건. 감사가 0으로 수렴한 직후 code-review가 42건을 냈고,
그중 하나가 H-101의 "새 필드를 안 만든다"를 역전시켰다: getOffsetAt의
부수효과가 splice의 되감기 신호를 지우는 경로가 실재해, 부기 필드를
offsetCacheValidUpTo(캐시)와 offsetSetUpTo(:Set 완료) 둘로 분리했다.
"Set을 해줬느냐"와 "캐시가 유효하냐"를 한 값이 쥔 게 원인이었다.

그 외: splice 무효화 i-1, 명시 recompute 호출부 전부 재진입 게이트,
recompute 되감기 클램프, Store defaults isSource 검증, isModifier 가드를
Source 생성자로, 훅 슈가 nil 가드, pesde.lock 커밋 확정, :Single 3-인자.
2026-08-25 session/ 원문 공백은 2026-08-19 선례대로 재구성 없이 기록만.

doc-check ERROR 0 / WARN 기준선 유지.

Claude-Session: https://claude.ai/code/session_01F9zgJ4c4kDitAoQMm9qxKn
Co-authored-by: qwreey <me@qwreey.moe>
2026-08-26 17:18:30 +09:00
677feabc76
qa: 구현 전 감사 8라운드 — 7라운드 반영분 재트레이싱·실측, 발견 17건 (H-107~H-123)
3패스에 걸쳐 base/ 전 문서 완독. 개별 함수가 아니라 "7라운드에 반영된
결정들이 서로 겹칠 때 성립하는가"를 트레이싱했고, 스파이크 7개로 실측
(H-107 런타임 크래시 재현, H-112 keyof 배선 해법 실증 포함). 사용자 결정
문항 Q1~Q10 배치 정리 — 그중 🔴 다섯(H-107~H-109/H-112/H-119)은 M2 착수
전 회신 필요. 아직 base/ 반영 없음, 결정이 나면 followup을 만든다.

커밋 전 /code-review가 보고서 자체의 결함 7건을 잡아 전부 검증 후 반영:
문서 내 사실 정정 6건([code-review 정정/추가] 표시)과 인덱스 레이어
미갱신 1건 — question.md 최우선 절/todos.md 00/README.md/CLAUDE.md/
project-context.md의 "M2 게이트 0" 서술을 "8라운드 회신 대기"로 정정.

doc-check ERROR 0 / WARN 기준선 유지.

Claude-Session: https://claude.ai/code/session_01Cokkxc38ozgmcxPZraV8b7
Co-authored-by: qwreey <me@qwreey.moe>
2026-08-26 01:21:22 +09:00
eb321e1747
qa: 7라운드 발견 52건 처리·반영 — Store 재설계 시도와 철회, 타입 함수 원칙 신설
`qa-request/pre-implementation-handtrace-round7.md`의 `H-55`~`H-106`을
사용자와 대화형으로 처리하고 `base/`에 전량 반영했다. 결정의 소스는
`-followup.md`(개수·개별 항목은 여기서 세지 않는다).

## 처분

확정 39 / 무효·소멸 4(`H-73`~`H-76`) / 기각 1(`H-77`) / 범위 축소 2 /
다른 항목으로 흡수 6.

**부수로 `question.md` 최우선 절이 비었다** — 중간 State GC는 `_hold`
불변식(하류 → 상류 강함)으로, 동적 키 표면 위치는 `store:Of<<T>>(name)`
하나로 닫혔다. **M2 착수를 막는 항목이 없다.**

## 구조가 바뀐 것

- `Ref`가 `Epoch`를 만족(`.Revision` + `EpochBrand`) — 포탈 캐치업 비대칭과
  같은 `Ref` 중복 dep이 같이 닫힘
- `Weak*` 등록 표면 신설(`Ref:WeakCallback` / `Observer:WeakSubscribe`) —
  Weak 쪽이 프리미티브고 강한 쪽이 "GC 킵"을 얹은 것
- `Effect`: dep 등록이 생성자 한 곳으로, 강한 주인은 `_deps` 하나,
  억제는 사적 `Blocker`, `bindLifetime`은 핸들 하나에만 적용.
  `:Rerun()` 정의 신설(재진입은 지연 재실행), `_installed` 신설
- 전파 루프 의사코드 확정 — 구독자 집합의 원소는 Observer **값**이고
  **자식 State 노드는 `canExecute`를 안 탄다**(그대로 짜면 파생 State
  아래가 전부 침묵했다)
- `rawInvalid` → `cacheTargetCount`/`cacheCurrCount` 카운터 쌍
- `recompute` 재진입 차단 + `invalidAfter` 되감기, `gatedRecompute`가
  인덱스를 캡처하지 않고 토큰으로 조회
- `emit(commit) -> boolean`, `EpochMap:Peek` — 정책이 흡수 집합을
  버리지도 읽지도 못하던 것을 닫음
- error 계약(`level` 이분, 메시지는 영어)과 예외 계약(`pcall`로 안 감쌈)을
  `architecture.md`에 신설

## Store — 같은 날 재설계하고 철회했다

`H-75`/`H-76`으로 `WrapStore`/`ProcessStoreType`이 폐기되자 그 자리를
"`store.key`는 값, `store:Of(k)`가 프리미티브"로 채웠다가 **같은 날
철회**했다(`archive/store-value-field-redesign-withdrawn.md`). 살아남은 건
**명시적 초기화** 하나다. 최종형은 타입 인자에 `Source<T>`를 직접 쓰고
`store.key`는 평범한 레코드 필드이며 **타입 함수를 안 쓴다**.

철회 이유 중 하나가 원칙으로 승격됐다 — **"타입 함수는 타입이 못 잡는
문제를 에러로 띄우는 정도 이상으로 가지 않는다"**(`typing-limits.md` §0).
`index<>`/`keyof<>`도 Luau가 predefine한 타입 함수라 같은 함정을 갖는다.

## 툴체인

**`luau` CLI가 심볼릭 링크를 못 탄다**(디렉토리·파일 둘 다)는 것이 최소
재현으로 밝혀졌다 — pesde 워크스페이스 링크가 전부 심볼릭이라 스모크 2개가
안 돌았고 `luau-analyze`는 **조용히 통과**했다("거짓 클린"). `scripts/relink.sh`
+ `scripts/test.sh` 신설, 이제 스모크 셋 전부 PASS.

## 검증

`/code-review high` 2회(12건 + 14건)와 감사 8패스(12/6/6/4/13/3/6/0건)를
각도를 바꿔가며 돌렸고 전부 반영했다 — 마지막 패스가 무발견으로 수렴.
각 패스의 각도와 대표 발견은 `-followup.md`의 검증 절이 소스.
`doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>

Claude-Session: https://claude.ai/code/session_012oLwATeQdq9TCFdENPutFG
2026-08-25 23:40:19 +09:00
8cc388cfab
qa: 7라운드 발견 52건 검증 패스 — 유효 49 / 범위 축소 2 (H-55~H-106)
발견을 더 찾는 패스가 아니라 이미 나온 것이 유효한지만 판정한다. 52건
전부를 한 세션이 한 컨텍스트에서 썼으므로 쓴 세션 자신은 자기가 뭘 잘못
봤는지 모른다(conventions.md가 반복 확인한 그 패턴). 판정 뒤 사용자가
batch로 결정하므로 무효 항목이 섞이면 그 비용을 그대로 문다.

**재실행이 아니라 대조로 판정했다.** audit/handtrace-round7-reference-impl/
README가 못박은 대로, 4·5·6차 패스의 발견은 스파이크를 다시 돌려서가 아니라
전사물(core/dispatch/chain.luau)과 base/ 확정 의사코드를 줄 단위로 맞춰
판정했다. Luau 언어 동작·저장소 상태를 주장하는 것(H-71/H-77/H-73/H-75/
H-76/H-78/H-83, 타입 스파이크 전량)만 직접 재실행해 교차 확인했다.

## 전사 대조 결과 — 전사 오류로 생긴 발견은 없다

State:_receive / _recompute / Get, GateNode _flush/_receive, Source:Set의
bit32.bnot(-rev), emitDown 스냅샷, getOffsetAt/recompute(에러 메시지까지)/
setLength/setOffsetSource, chain의 process/retractFrom — 전부 원문과 일치.

README가 신고한 의도적 차이 3개 외에 **미신고 차이 3개**를 찾았으나 셋 다
발견을 만들지 않는다: (1) Blocker:Policy가 2인자(죽은 인자, 아무도 안 넘김),
(2) Blocker.handles가 강한 배열(문서는 weak — H-63(2)를 오히려 가림),
(3) M.Observer가 즉시 1회 실행을 안 하고 self.owner 강참조를 둠(앞은
dispatch.luau가 명시 호출로 등가 보정, 뒤는 GC 문제를 가리는 방향).

## 판정

- 유효 49 / 범위 축소 2 / 무효 0 / 중복 0.
- **H-87 범위 축소** — "코퍼스가 error 경로를 어느 쪽도 안 다룬다"가 거짓.
  slot-plan.md가 같은 실패 모드를 명시하고 사용자가 2026-08-21에 "pcall로
  감싸지 않는다"로 이미 판단했다. 갈래 (a)가 든 두 자리 중 하나가 닫힌
  결정이라, 그대로 올리면 사용자가 자기 판단을 모른 채 재결정하게 된다.
  남는 진짜 질문은 그 판단을 Dispatch.drive(자가치유 없음)까지 확장할지.
- **H-105 범위 축소** — 개수를 직접 셌다. 영어 6은 맞지만 한국어는 17이
  아니라 약 23이고, 17+6이 애초에 "42곳"과 안 맞는다. 현상과 결정
  필요성은 유효.
- **H-88 정정** — 셋 문서에 error 0건은 참이나 "코퍼스에 한 줄도 없다"는
  과하다. 선례가 하나 있고 그건 갈래 (b) 쪽을 가리킨다.
- **H-104 정정** — "level 인자가 하나도 없다"는 전수 확인으로 참,
  "42곳"만 약 29곳으로 정정.

## batch용 요약

번호순 52건이 아니라 결정 단위 12묶음(A~L)으로 재편성했다. 같은 묶음은
결론이 서로를 규정하므로 따로 답하면 갈라진다. 몇 가지 눈에 띄는 것:

- H-55(버리기)와 H-86(읽기)은 emit(commit) -> boolean 하나로 같이 닫힌다.
- H-56/H-62/H-61은 "전파 루프 의사코드" 한 블록을 쓰면 같이 닫힌다.
- H-73을 탑레벨 함수로 닫으면 H-74가 통째로 소멸한다.
- H-58과 H-59는 Ref 콜백 등록을 공유 헬퍼로 빼는 같은 수정으로 닫히고,
  그 전에 H-60(Rerun 정의)이 있어야 나머지를 쓸 문장이 생긴다.
- H-71의 해법 (b)가 H-77엔 안 듣는다(실측) — Relate 슬롯 셋을 나눠 쓸 것.
- H-78이 제일 급하다: 지금 이 저장소에서 smoke 2개가 안 돌고
  luau-analyze가 거짓 클린을 준다(검증 세션에서 재현).
- H-66/H-67/H-82/H-91은 결정 불필요(근거·표기만 정정).

.claude/README.md의 qa-request 행에도 등재. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-25 12:26:53 +09:00
6f939a8d2d
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>
2026-08-25 12:00:08 +09:00
ec88253ad2
qa: 손 트레이싱 7라운드 6차 패스 — 부기 실측 + error 계약, 발견 6건 (H-101~H-106)
1~5차가 안 쓴 각도 둘:
(1) 4차의 참조 구현을 M2→M3 경계까지 이어 붙였다 — Length/Offset 부기
    (접두합 캐시 getOffsetAt / recompute / setLength / 배치 게이팅)와
    Dispatch 체인(하강 diff / retractFrom)을 의사코드 그대로 돌렸다.
    이 부기는 2026-08-21 접두합 캐시 도입과 6라운드 H-3/H-5/H-19 수정을
    거친 뒤 한 번도 실행된 적이 없다.
(2) quad 자신이 던지기로 확정한 error 42곳의 계약 감사 — 4차가 "사용자
    콜백이 던졌을 때"를 봤다면 이번엔 반대 방향이다.

🔴 H-101 recompute가 순회 도중 재진입되면 바깥 호출의 꼬리가
     ownerKey.Length를 낡은 합계로 덮어쓴다(합계 3인데 2가 남음).
     offset은 다 맞고 Length만 틀려서, 증상은 한 단계 위 형제부터 난다.
     H-85("긴 연산의 마지막 줄이 도중에 바뀐 값을 덮어쓴다")와 같은 계열
🔴 H-102 spliceArraysDown이 bk.observers를 옮기지만 setLength가 만든
     gatedRecompute 클로저에 박힌 position 인덱스는 안 고쳐진다 → 이후
     그 자리의 길이 변경이 캐시를 엉뚱한 범위로 무효화해 뒤 형제 offset이
     낡은 채로 남는다(A/B 대조: 1 vs 기대 5). 규칙 셋(2026-08-18 observers
     이동, 2026-08-21 접두합, 2026-08-24 H-3)이 각각 옳은데 겹치는 자리가
     아무 목록에도 없었다
🟡 H-103 process가 던지면 체인 슬롯에 NOOP retractor가 영구히 남아 그
     자리가 잡은 자원의 정리가 사라진다. 미주입 op 스텁(확정된 정상 동작)
     때문에 흔하다 — attribute 이름이 영구 잠기고, 다음 사용자에겐 진짜
     원인이 아니라 "이미 다른 소유자가 잡고 있다"는 엉뚱한 에러가 보인다
🟡 H-104 error 42곳 전부에 level 인자가 없다 → 전부 quad 내부 줄을
     가리킨다. 사용자 입력 검증과 내부 불변식 위반을 갈라야 한다
🟡 H-105 error 메시지 언어가 이미 갈려 있다(한국어 17 / 영어 6).
     conventions.md의 "사용자=본인"과 "라이브러리 사용자"가 다르므로
     에이전트가 임의로 정할 수 없는 항목
🟢 H-106 C-6이 승격한 "부기가 깨졌음" 진단이 getOffsetAt 경로에선 우회돼
     익명 산술 에러로 먼저 터진다

부록에 "문제가 없던 것" 5건 — 접두합 캐시 자체의 정확성, 배치 게이팅이
실제로 O(N)으로 접히는 것, 등록 시점 길이가 최종이 아니어도 자가교정되는
것, 빈 Slot 방어, Dispatch 체인 정상 경로.

아무것도 base/에 반영하지 않았다 — 회신 대기.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-25 11:53:58 +09:00
3247ea5907
qa: 손 트레이싱 7라운드 5차 패스 — 타입 실측, 발견 7건 (H-94~H-100)
1~4차가 안 쓴 각도: **확정된 M2 표면을 문서에 적힌 시그니처 그대로 Luau
타입으로 선언하고 `luau-analyze`에 걸었다.** 2차 패스도 타입을 봤지만
대상이 Store의 타입 합성 하나였다(H-73~H-76). 이번엔 호출부 관점 —
"확정된 타입이 확정된 관용구를 통과시키는가"다. 루트 .luaurc가
languageMode: strict라 아래 진단은 프로젝트에 그대로 적용된다.

🔴 H-94 `__call` 테이블은 `(State<T>) -> U` 자리에 안 들어간다 →
     `state:Apply(Debounce{...})` 확정 관용구가 타입에러. gate-plan이
     "확인할 필요도 없어졌다"며 접은 불확실성이 Debounce/Throttle 쪽에
     그대로 남아 있었다(팩토리에 Flush/Cancel을 붙이는 이상 콜러블
     테이블일 수밖에 없다). 런타임은 멀쩡하고 타입만 막힌다
🔴 H-95 콜백이 "선언보다 적게 반환"하면 strict에서 에러 — Effect의
     `fn -> (() -> ())?`는 cleanup 없는 흔한 모양이, :List의
     `updateFn -> (T?, UD?)`는 userdata를 안 쓰는 모든 모양이 안 통과한다.
     통과하는 두 형태(가변 반환 팩 / 함수 타입 유니온)를 대조군으로 확인
🟡 H-96 trailing deps가 붙는 순간 콜백 파라미터 무주석 추론이 깨진다 —
     typing-limits ②쪼개기가 푸는 범위 밖인데 경계가 어디에도 없다.
     2차 패스 부록의 "무주석 콜백도 살아 있다"도 이 구분을 안 했다
🟡 H-97 M2의 `mock 대상 테스트`가 전파 루프를 한 번도 못 돈다 — 루프가
     매 발화마다 부르는 canExecute가 M8에서만 구현되고, 미주입 슬롯의
     확정 기본값은 에러 스텁이다. 커밋된 mock엔 재료(Signal/Connection)가
     이미 있는데 쓰는 쪽이 없다
🟡 H-98 `:Subscribe()`의 공개 계약("참조를 안 담아도 계속 돎")이 중간
     State GC 미해결에 종속 — 잘못 닫히면 "GC도 안 되고 발화도 안 하는"
     조합이 된다(실제 collectgarbage로 재현)
🟢 H-99 Observer가 파일 자리를 못 받았고 :Subscribe() 전역 강참조
     레지스트리의 주인이 어디에도 없다 (Effect.luau는 있는데)
🟢 H-100 `{[Source<T>]: true}`가 `{[Epoch]: true}` 자리에 안 들어간다
     (인덱서 키 불변). 단일 Epoch 전달과 §2의 구조적 만족은 성립 확인

부록에 "걸어봤는데 문제가 없던 것" 6건 — state:Gate + blocker:Policy가
타입으로 성립하고 gate-plan의 2026-08-24 정정(오답 b.Policy)은 타입이
잡아준다, source:Apply에 State용 팩토리 통과, Effect의 이형 deps는 타입으로
표현 가능, 게이트 2겹 unfold가 어느 순서로 풀려도 안 샌다 등.

아무것도 base/에 반영하지 않았다 — 회신 대기.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-25 11:35:00 +09:00
03219af33f
qa: 손 트레이싱 7라운드 4차 패스 — 참조 구현 실측 + 예외 경로, 발견 9건 (H-85~H-93)
1~3차가 안 쓴 각도 둘로 같은 라운드를 이어서 돌렸다.

(1) M2 코어(state-epoch §2~§5 / 전파 모델 / gate 4·8번 / Blocker)를
    방어 코드 없이 문서 그대로 옮긴 참조 구현을 Luau로 짜서 시나리오를
    돌렸다. 손 트레이싱이 못 보는 "여러 규칙이 동시에 걸리는 자리"가
    여기서 나온다. 참조 구현의 대조군은 §1이 예고한 다이아몬드 glitch가
    실제로 사라지는지 확인한 것.
(2) 예외 경로 — 반응형 문서 셋에 "error"가 0건이다. 사용자 콜백이
    던졌을 때 반쯤 갱신된 부기가 무엇을 남기는지 정한 문장이 없다.

🔴 H-85 재계산 끝의 `rawInvalid = false`가 재계산 도중 도착한 무효화를
     지운다 → 캐시가 다음 Set까지 영구 stale (재현 + 한 줄 수정 대조까지)
🔴 H-86 정책이 "보류분이 있는가"를 읽을 수 없어 Throttle의 창이 idle로
     못 돌아온다 → leading 영구 소실 + 타이머 체인이 안 끝나 8절의
     "유계 GC" 주장도 깨짐 (H-55와 별개 — 그쪽은 버리기, 이쪽은 읽기)
🔴 H-87 배치 게이팅 도중 error → 그 owner의 Blocker가 영구 On,
     recompute가 조용히 영영 안 돎 (문서는 yield만 UB로 못박음)
🟡 H-88 전파 중 콜백이 던지면 나머지 구독자는 영구 침묵(값만 자가치유)
🟡 H-89 flush 중 error → 떼어낸 배치 소멸, :Sync와 전파의 순서 미정
🟡 H-90 Effect의 dedup이 루트 에포크 기준이라 dep 하나만 게이팅하면
     같은 루트를 공유하는 다른 dep 때문에 게이팅이 무력화
🟢 H-91 §8의 "항상 Get이 최신"이 과한 서술 (Animate가 반례를 설계로 씀)
🟢 H-92 구독자 스냅샷이 emit마다·노드마다 배열 하나 — §2의 GC 근거와 충돌
🟢 H-93 EpochMap 키가 weak라 중간 State GC 미해결이 "최신이라고 오판"
     으로도 나타남 (실측 스파이크 요구가 하나 늘어남)

부록에 "돌려봤는데 문제가 없던 것" 6건(다이아몬드 glitch 해소, 유한
재진입 안전, Blocker 정책 배선, 빈 배치 무통지, Store lazy __index의
identity 안정성, GateNode 순환 require 없음)도 같이 남겼다.

부수로 .claude/README.md의 qa-request 행에 7라운드가 아예 빠져 있던 걸
채웠다(1~3차 패스 때부터 비어 있었음).

아무것도 base/에 반영하지 않았다 — 회신 대기.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-25 11:18:05 +09:00
1adafea53d
qa: 손 트레이싱 7라운드 3차 패스 — 실측 8건 추가 (H-77~H-84, 회신 대기)
2차 패스와 같은 방식으로 7라운드 문서에 **3차 패스**로 이어붙였다(발견
번호도 계속 이어서 매김). 여전히 아무것도 반영하지 않았고 README 색인도
안 넣음(결정 후 followup과 함께). doc-check ERROR 0.

## 이번 패스의 각도

2차는 문서가 주장하는 Luau 동작을 **새로 짠 최소 재현**으로 걸었다.
3차는 각도를 둘로 바꿨다 — (1) **저장소에 이미 있는 것을 그대로 돌린다**
(`quad-base/test/smoke.*`, `luau-test/done/` 전량, `doc-check.py`, 커밋된
`quad-base/src/*.luau`의 `luau-analyze`). 즉 "설계가 맞나"가 아니라
"지금 이 저장소가 문서가 말하는 상태인가"를 본다. (2) 1차가 범위에서
명시적으로 뺐던 **M2 *소비자* 문서**(`modifier`/`attribute`/`tween`/
`onchange`/`quad-types`/`architecture`/`project-setup`/`module-lifecycle`)
— M2를 만드는 문서가 아니라 M2가 만든 걸 쓰는 문서라, "소비자가
요구하는데 M2 설계엔 없는 것"이 여기서만 보인다.

검증 중 `pesde` 심볼릭 링크를 실제 디렉토리로 치환한 구간이 있으나 전부
원상복구했다(링크 20개, 전부 `.gitignore` 대상이라 저장소 파일 무변경).

## 발견

- `H-77` 🔴 **`Relate`의 *내부 키*가 `inst`를 되참조하면 `SetStrong`/
  `SetWeak` 둘 다 샌다.** `H-71`은 *값*의 되참조였고 결론이 "`SetWeak`은
  안전"이었는데, `WeakMap`이 `__mode = "v"`라 **키는 언제나 강함**이다 —
  즉 `H-71`의 해법 (b)가 여기선 전혀 안 듣는다. `relate-plan.md`의 규칙은
  위험을 전부 "값" 기준으로만 서술해 이 슬롯이 아예 없다. 커밋된
  `Relate.luau`로 50/50, 그리고 **커밋된 `init.luau`의 `RunInit`이 실제로
  물린다** — `initFn`이 module을 캡처하면 30/30이 안 죽고, 그 파일 주석
  ("module이 GC되면 이 기록도 같이 사라짐")이 지금 거짓이다.
- `H-78` 🔴 **커밋된 M1 스모크 2개와 `done/`의 타입 스파이크 `23`이 지금
  저장소 상태에서 안 돈다.** 원인은 pesde 워크스페이스 심볼릭 링크(이
  `luau` CLI가 require 경로의 심볼릭 링크를 디렉토리든 파일이든 해소
  안 함 — 격리 확인). 더 나쁜 쪽은 타입으로, `luau-analyze`가 `Quad`/
  `CheckVersion`을 아예 못 봐 스파이크 `23`의 음성 대조군이 한 건도 안
  뜬다 = "진단 0건"으로 오독된다. 워크어라운드를 적용하면 전부 정상이라
  설계가 아니라 **절차가 없는 것**이고, 그 치환은 `project-setup-plan.md`가
  스스로 "정식화 안 함"이라 밝힌 상태다. `ROADMAP.md` M1의 "전부 PASS"는
  날짜도 전제조건도 없다.
- `H-79` 🟡 **`Store`에 열거 표면이 없는데** 그룹 `Attribute(...)`/
  `:NameMap()`이 그걸 요구한다 — lazy `__index` 때문에 키 집합이 접근
  이력에 좌우되고(실측 0개/1개/2개), `defaults` 없이 만든 Store에선 빈
  맵이 된다. `:NameMap()`이 스냅샷이냐 라이브냐도 미정.
- `H-80` 🟡 M2가 `quad-types`의 `Quad`에 추가할 목록이 `Source`/`State`/
  `Store`뿐 — **`State`는 런타임 값이 아예 없고**(타입 재수출만 가능),
  M2가 실제로 얹는 나머지 탑레벨 값(`Effect`·`is*` 전량·`bindLifetime`
  4종·`Relate`·`Blocker`)이 전부 빠져 `H-25`가 만든 그 벽에 똑같이
  부딪힌다. 부수로 옛 표기 `state(state)`가 두 문서에 남아 있다.
- `H-81` 🟡 `isModifier` 런타임 가드의 적용 지점이 **전부 M2 코드**
  (`Source:Set`/Store 생성/`:Compute` 캐싱)인데 체크박스는 M7에만 있고
  M2 체크리스트엔 한 줄도 없다(그 사이 M4가 가드 없이 돈다). 게다가
  적용 지점 목록이 두 문서에서 갈린다 — `source-state-plan.md`는 독립
  `Source(someModifier)` 생성자도 포함하는데 `modifier-plan.md` 7번엔
  그 자리가 없다.
- `H-82` 🟢 `:With`를 실노드로 확정한 **근거 2번이 pass-through 노드엔
  성립하지 않는다** — 계산 함수가 없으니 공유될 계산도 없다. 2026-08-14
  재작성이 "근거가 더 강해졌다"면서 실제론 정확한 서술(순회 비용)에서
  부정확한 서술(중복 계산)로 내려갔다. 결론은 안 바뀜(근거 1·3 유효).
- `H-83` 🟢 확정된 Store 구현 스케치를 그대로 쓰면 무인자 `Store()`가
  `table.clone(nil)`로 크래시한다 — 같은 절이 `defaults`는 선택이라고
  확정해뒀다.
- `H-84` 🟢 `:With(...)`/`state:Block(b)`/`Source:Emit()`이 M2 체크리스트에
  개별 항목으로 없다 — `H-55`/`H-68`/"중간 State GC"의 결론이 각각 어디로
  가야 하는지가 로드맵에 자리가 없다는 뜻.

## 부록 — 돌려봤는데 문제가 없던 것

다음 라운드가 같은 곳을 다시 파지 않도록 남겼다 — `bit32.bnot(-rev)`
실측 표 정확, `doc-check.py` ERROR 0, `done/` 런타임 스파이크 8개 전원
재통과, `STATUS.md` 개수 표가 실제 폴더와 일치, 워크어라운드 적용 시
커밋된 `quad-base/src/*.luau` 진단 0건, `Relate.luau` 구현이
`relate-plan.md`의 "실제 구조" 절과 일치(문제는 코드가 아니라 규칙 공백).

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_012oLwATeQdq9TCFdENPutFG
2026-08-25 10:50:56 +09:00
a39ad034b4
qa: 손 트레이싱 7라운드 2차 패스 — 실측 6건 추가 (H-71~H-76, 회신 대기)
별도 라운드로 쪼개지 않고 7라운드 문서에 **2차 패스**로 이어붙였다
(6라운드가 패스를 이어 붙인 것과 같은 방식, 발견 번호도 패스를 가로질러
이어서 매김). 여전히 아무것도 반영하지 않았고 README 색인도 안 넣음
(결정 후 followup과 함께). doc-check ERROR 0.

## 이번 패스의 각도

1차는 문서 대 문서 손 트레이싱이었고, 2차는 **문서가 "확인했다"고 적어둔
런타임/타입 주장을 실제로 `luau`/`luau-analyze`에 걸어봤다** — 6건 중
5건이 추측이 아니라 실행 결과이고 재현 코드를 항목마다 넣었다. 부수로
드러난 패턴: **"검증 완료"로 인용되는 스파이크들이 정작 위험한 모양을
안 테스트했다**(`07`은 되참조 없는 payload만, `16`은 대입을 아예 안 함).

## 발견

- `H-71` 🔴 **단일 `Relate` 안의 자기참조(값→키)는 GC 안전하지 않다.**
  `relate-plan.md`의 "위험한 패턴" 절이 안전하다고 단언한 바로 그 모양이
  커밋된 `Relate.luau`로 50/50 누수(SetWeak은 0). 그 절은 "Luau엔
  ephemeron이 없다"를 근거로 두-`Relate` 순환을 금지하면서 단일 `Relate`
  자기참조엔 ephemeron 의미론을 가정한다. `RefLeafHandler`가 정확히 그
  모양이고(`SetStrong(inst,k,ref)` + `ref.Value = inst`), 정상 `Destroy`
  경로엔 그 기록을 지우는 코드가 없다.
- `H-72` 🟡 `GateNode`가 `state-epoch-plan.md` §4 규칙 1~3을 돌려면
  `emitEpochMap`을 갱신 없이 비교해야 하는데 `EpochMap` 표면
  (`Update`/`Refresh`/`Sync`/`TrackFrom`)에 그 연산이 없다.
- `H-73` 🟡 `store:GetDynamic<<T>>(name): Source<T>`는 콜론이든 탑레벨이든
  `T`를 바인딩할 수 없다 — Luau엔 호출부 명시 타입 인자가 없고 기대 타입
  추론도 안 돼 `T`가 `unknown`으로 떨어진다. 콜론 쪽은 합성 타입에 키
  자체가 없어 `Key 'GetDynamic' not found`. `question.md` 최우선 항목의
  실측 근거.
- `H-74` 🟡 eager `defaults` 경로가 `__index`를 통째로 우회해 예약 키
  방어가 성립 안 함 — `Store({GetDynamic = 3}):GetDynamic("x")`가
  `attempt to call a table value`. Modifier와 "정확히 같은 구조"가 아니다.
- `H-75` 🔴 `WrapStore`가 스파이크 `16`의 평평한 모양이면
  `store.key:Compute(무주석 콜백)`이 깨진다 — `typing-limits.md` ②쪼개기를
  `type function` 안에서도 해야 한다(그 모양이면 통과함까지 확인).
- `H-76` 🔴 `type function`은 바깥 타입 별칭을 참조 못 하므로 `Source<T>`
  전 표면을 손으로 중복 유지해야 하고, 메소드 self 파라미터가 불변이라
  `Revision` 하나만 빠뜨려도 `store.key`가 `State<T>` 파라미터 자리에 안
  들어간다. 스파이크 `16`은 그 대입을 한 번도 안 해봤다. 충실한 재현 +
  정합성 단언 한 줄이면 닫힌다(실측 확인).

## 부록 — 열려 있던 실측 항목 하나는 성립

스파이크 `15`의 (B)(이형 다중 deps를 제네릭 팩으로 좁혀 받기)를 같이
재봤고 **된다** — 확정된 `previous?`-앞-팩 순서 그대로, 음성 대조군도
정확히 잡힌다.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_012oLwATeQdq9TCFdENPutFG
2026-08-25 10:01:36 +09:00
34e176398e
qa: 손 트레이싱 7라운드 — M2 범위·M2→M3 경계, 발견 16건 (H-55~H-70, 회신 대기)
6라운드가 안 쓴 각도(시간축 겹치기 / 정책 분리 후 권한 경계 / 두 진입
경로 중 한쪽만 배선 / 두 문서가 다른 말로 부르는 자리)로 반응형 코어
문서를 다시 돌렸다. 아무것도 반영하지 않았고 README 색인도 아직 안 넣음
(결정 후 followup과 함께). doc-check ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Sv6h6YbuzYwPaEoRDQBQsc
2026-08-25 00:53:10 +09:00
56f8269236
design: M2/M3 마일스톤 순서 교체 — 반응형 코어를 먼저, 디스패치를 그 위에
`question.md`에 열려 있던 마지막 항목(마일스톤 경계)을 사용자가
**(a) 순서 교체**로 닫았다 — 이제 **M2 = 반응형 코어(Source/State/Store),
M3 = 디스패치 엔진**이다. 결정과 기각된 선택지는
`archive/question-resolved.md`의 "마일스톤 경계" 절, 경위와 시행착오는
`session/2026-08-24-02-milestone-order-swap.md`.

## 왜 (a)인가

의존이 양방향처럼 보였지만 실제로는 한 방향이었다 — 디스패치→반응형은
*본체* 의존(`setLength`가 `State<number>`를, `setOffsetSource`가
`Source<number>`를 받고 `recompute`가 `offset:Set()`을 부름)이라 우회
불가고, 반대는 *등록 표면* 의존(핸들러 셋)이라 미루면 그만이다. 옛
순서로는 디스패치 마일스톤의 `mock 대상 테스트` 체크박스조차 State 없이
불가능했고, 반응형 코어는 순수 Lua라 `luau`만으로 단독 테스트가 된다.
(b) 분할은 기존 `M2` 참조 전부를 "M2a인가 M2b인가"로 만들어 오히려 비싸고,
(c) 유지는 "순서의 소스는 `ROADMAP.md`"라는 원칙을 스스로 무효화한다.

## 그냥 맞바꾸기로는 안 끝났다

- **`Brand`/`Relate`/`LifetimeHandle`(인터페이스)이 M2 앞머리
  `### 공통 기반` 절로** — 반응형이 이 셋을 먼저 요구한다(`Source`가
  `SourceBrand`+`EpochBrand`에 등록되고, State 전파 루프가 매 발화마다
  `canExecute`를 부르며, 그 판정이 `Relate` 위에 얹힌다). 셋 다 State-free
  이자 dispatch-free라 어느 쪽에도 안 걸린다.
- **2026-08-22에 디스패치로 앞당겼던 `EpochMap`/`GateNode`/`Blocker`가
  M2로 복귀** — 앞당길 이유 자체가 순서 교체로 사라졌다. "게이팅 먼저"는
  그대로 지켜진다(게이팅이 디스패치보다 먼저 지어진다).
- **Observer/Effect 동적 경로 가드 등록과 `ObserverEffectLeafHandler`는
  M3로** — 둘 다 "핸들러를 등록한다"뿐이라 본체와 잘라내기 쉽다. 이것이
  M2가 M3에 개념상 지던 유일한 의존이고, 미뤘으므로 빌드 순서엔 역방향
  간선이 없다.
- **`EpochMap`이 State 본체보다 앞으로**(감사 5라운드 발견) — State가
  `valueEpochMap`/`emitEpochMap` 둘을 컴포지션하므로 옛 배치로는
  `State.luau`를 못 짠다. 문서 자신이 *"아래 `Source.luau`/`State.luau`가
  이걸 전제로"*라 쓰면서 그 둘이 위에 있는 형태로 증거를 남기고 있었다.

## 대가 하나 — 게이트 둘이 "바로 다음"으로 올라왔다

`question.md` 낮은 우선순위에 있던 **중간 State GC 미검증**과
**`store:GetDynamic` 위치**가 원래 반응형(옛 M3)의 게이트였는데, 반응형이
M2가 되면서 착수 직전 항목이 됐다 — 최우선 절로 승격했다. 순수 *설계*
결정 대기는 여전히 0건이다(하나는 실측 미완, 하나는 표면 위치 선택).

## 번호 재부여의 경계

라이브 문서의 `M2`/`M3` 참조 248건은 전량 새 번호로 맞췄다(코퍼스의 참조가
거의 전부 "그 내용이 사는 마일스톤"을 가리켜 기계적 맞교환으로 의미가
보존됨). **`session/`·`archive/`·`qa-request/`는 히스토리라 소급 수정하지
않았다** — 그 문서들의 `M2`/`M3`는 옛 의미(M2=디스패치, M3=반응형)이고,
이 경고를 인덱스 다섯 곳에 박아뒀다.

일괄 치환에 `\bM([23])\b`를 썼다가 Python `\w`가 유니코드라 한글이 붙은
93건(`M2는`/`M2로`)이 안 바뀌는 실수를 냈고, `git show HEAD:<경로>`로
되돌린 뒤 ASCII 경계 lookaround로 재실행했다. 그 부수로 `Relate`/
`LifetimeHandle` 참조들은 구 M2 → 신 M2로 두 번 옮겨져 **번호가 우연히
보존**됐다는 것도 드러났다(치환하면 안 되는 자리 — 감사가 잡아 되돌렸다).

## 검증

`quad-doc-auditor` 루프가 라운드마다 각도를 바꿔 **7라운드에서 새 발견
0건으로 수렴**(10→8→1→1→2→2→0). 가장 값이 큰 건 5라운드(구현 순서
시뮬레이션)로, 위 `EpochMap` 배치 오류를 잡았고 **순서 교체의 전제 자체도
검증**했다(새 M2 전체를 디스패치 심볼로 훑어 "가드 등록 둘 말고는 없음").

**수렴 뒤 사용자가 돌린 `/code-review high`가 5건을 더 잡았고 전부 유효** —
넷이 *"라벨은 치환됐는데 그 라벨을 설명하던 산문이 안 고쳐진"* 종류였고,
그중 하나는 `research/` 안의 **히스토리 블록**이 치환을 맞아 원래 논거가
문장 그대로 거짓이 된 것이었다(소급 수정 제외 대상을 세 폴더로만 잡은 게
샜다). `conventions.md`의 *"`/code-review`는 감사자를 대체하지 않는다"*가
또 재확인됐다. `doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
2026-08-25 00:29:15 +09:00
19cd046275
design: 손 트레이싱 6라운드 전량 처리·반영 — H-1~H-54 확정, M2/M3 설계 게이트 해소
사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한
순서로** 물어 전량 결정하고 `base/` 24개 문서에 반영했다. 결정과 근거는
`qa-request/pre-implementation-handtrace-round6-followup.md`가 소스이고,
진행 경위와 사용자 발언 원문은
`session/2026-08-24-01-handtrace-round6-resolution.md`.

**M2/M3를 막던 것이 전부 닫혔다** — 말단 핸들러 4종의 `setLength`/
`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가
타입에러인 것(`H-25`), `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`),
`:List`의 좌표계 결함 둘(`H-1`/`H-2`).

## 구조가 바뀐 것 넷

- **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설로 `indexOfRaw`가 O(1)
  기본 경로가 되고, `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로
  강등. 사용자 역제안 — *"raw* 가 층위를 알아야할 이유를 모르겠는 상태 …
  realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 같이
  업데이트해주는 편이"*. 맵을 `_elements`와 같은 층에 두니 층 분리가 오히려
  깨끗해졌다
- **`_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`
  신설** — 상태가 셋이 됐다(미실체화/실체화/마운트). `raw*`는 부기를 실체화
  시점부터 항상 하고 `native*`만 가른다. 그래야 최초 population 중
  `getOffsetAt`이 성립해 `updateFn`의 `index`를 거기서 뽑을 수 있다
- **`Ref.Callbacks`가 해시맵 셋 + `:Uncallback`**(사용자 발견) — 해제가 O(1)이
  되고 `ref-plan.md`의 `#t` border 실측 항목이 폐기됐다
- **`blocker:Policy(emit)` 노출** — `Debounce`/`Throttle`이 emit을 안 쥐고 자기
  Blocker를 On/Off만 하는 정책이 된다. `Gate`엔 `Flush`/`Cancel`을 안 둔다

요소 타입 검증은 블랙리스트에서 **주입 술어 `isInst` 기반 화이트리스트**로
뒤집혔고(`H-40`), 주입 op이 둘 늘었다(`isInst`/`onDestroying` — 조합 폴백이
불가능해 미주입이면 에러).

## 사용자가 에이전트 갈래를 뒤집은 자리가 여럿

`H-1`(세 갈래가 전부 차선), `H-2`(*"부기 확정에서 length 를 확정해도 되는거
아님?"* — 부기와 물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이
2026-08-21 인스턴스 브랜드 재작성 이후 성립 불가임을 지적), `H-33`(제 중첩
합성안이 unblock 시 디바운스 창을 새로 시작시켜 창이 안 끝난다는 지적).
`Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 사용자가 먼저 발견.

## 반영 후 재검토 — `/code-review high` 7건 + 감사 9라운드 34건

**`/code-review high` 7건 중 셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이
됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 `Slot { frameA }` 생성자가
크래시하던 것, `native*`를 `_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐
영구 누수를 만든 것, "`:List`와 CRUD는 상호배타"라며 승인받은 분기가
**재마운트 경로를 안 봐서** 포탈을 깬 것. 셋 다 코퍼스 정합성 각도로는
구조적으로 안 보이는 종류라 *"`/code-review`는 감사자를 대체하지 않는다"*가
실측으로 재확인됐다. 같은 리뷰가 `H-11`의 두 결정이 서로 모순임을 잡아
재결정했다 — *"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가
실재하지 않았고**, 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 `isEffect`를
보고 직접 처리하는 것으로 바뀌었다.

**`quad-doc-auditor` 감사 루프는 9라운드에서 새 발견 0건으로 수렴**(라운드별
5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절). 한 턴에 하나씩 돌리고
라운드마다 각도를 바꿨다. 가장 많이 잡은 6라운드(9건)는 *"이 체크박스로 코드를
짜면 무엇이 나오는가"*를 물은 라운드였고, 그때 `ROADMAP.md`의 미완료 항목이
대거 stale인 게 드러났다(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화
계약이 통째로 부재, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순).

**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**:
배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고
`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중
6행만(7·8라운드). 셋 다 핸드오버 체크리스트가 명시적으로 경고하는 항목이라,
규율이 없어서가 아니라 지켰는지 스스로 확인하지 않아서 생긴 실패다.

## 백로그 하나

`Fallback`/`Traceback` 중 생성된 부분 트리의 회수(`H-26`) — 그 둘이 슈가라
구현 시점에 같이 다룬다. 같이 확인된 것: `dispatch-core-plan.md`가 잔여 부기를
인스턴스 GC가 정리한다고 적은 문장은 **gcconn 불멸성과 양립하지 않는 틀린
안전망 주장**이라 삭제했다.

**M2 착수를 막는 설계 항목은 이제 없다** — 남은 건 `question.md` 2번(M2↔M3
양방향 의존, 마일스톤 순서)뿐이다. `doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
2026-08-24 18:54:14 +09:00
198 changed files with 23907 additions and 1285 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,72 @@
# [역전됨] `EffectHandle`의 내부 Observer 바인딩 세부 — `_observers` 배열 + `bindLifetime`/`:Subscribe()` cascade
> **역전일**: 2026-08-25(7라운드 `H-58`/`H-59`). **대체된 곳**:
> `base/effect-plan.md`의 "확정 구조 — 강한 주인은 항상 `Effect`" 절과
> "의사코드 — 생성자 / `bindLifetime`이 부르는 두 훅 / `Rerun`" 절 —
> `bindLifetime`/`unbindLifetime`은 **`Effect` 핸들 하나에만** 적용되고 내부
> Observer로 cascade하지 않는다. dep 등록은 생성자에서 한 번만
> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`
> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는
> 전부 `_deps` 하나와 `_blocker`로 대체됐다. **이 문단대로 짜면 `H-58`
> 중복 `Rerun`이 되살아난다.**
>
> **왜 `archive/`로 왔나 (2026-08-27, 9라운드 `H-130`, 사용자 결정 Q7-(b))**:
> 이 블록은 `base/effect-plan.md` 안에 ⛔⛔ 폐기 배너를 단 채 남아 있었는데,
> 배너 아래 죽은 문단을 **두 번**이나 살아 있는 문장처럼 편집하는 사고가
> 났다(8라운드 2차 #1, 그리고 커밋 `9dd8213`이 아래 40번째 줄 근처의
> *"`.Subscribed`는 구독 경로 전용"*을 날짜 마커 없이 고친 것 — 옛 표기는
> *"전역 `:Subscribe()` 전용"*). `conventions.md`의 핸드오버 체크리스트 3번
> (*"뒤집힌 원문은 `archive/`로 옮기고 포인터만 남길 것"*)이 정확히 이
> 실패 모드를 규정하고 있어 그대로 따랐다. 아래는 **옮기는 시점의 원문
> 그대로**(그 편집 흔적 포함)이고, 더 이상 갱신하지 않는다.
## 옮겨온 원문 (`base/effect-plan.md`, 2026-08-27 시점)
**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째
세션, 재확인 후 명시화)**:
> **⛔⛔ [2026-08-25 폐기, 7라운드 `H-58`/`H-59`] 이 문단 전체는 옛 모델이다.**
> 위 "확정 구조 — 강한 주인은 항상 `Effect`" 절이 **정반대로** 확정했다 —
> **`bindLifetime`/`unbindLifetime`은 `Effect` 핸들 하나에만 적용되고**
> 내부 Observer로 cascade하지 않는다. dep 등록은 **생성자에서 한 번만**
> `WeakSubscribe`/`WeakCallback`으로 하고, 발화 여부는 `canExecute(handle)`
> 전담한다. 아래가 서술하는 `_observers` 배열/cascade/`Subscribe` 순회는
> **전부 `_deps` 하나와 `_blocker`로 대체됐다**(위 "필드 목록").
> 아래는 히스토리로만 읽을 것 — **이 문단대로 짜면 `H-58`의 중복 `Rerun`
> 되살아난다.**
**⚠️ [2026-08-24 6라운드 손 트레이싱 `H-8`, 2026-08-25 폐기] 이 문단 전체가 아직 "Observer 하나"
전제로 쓰여 있었다 — `_observer`(단수)를 `_observers`(배열)로 읽을 것.**
아래 절이 확정한 `Effect(fn, ...deps)`(N-deps)와 정면으로 어긋났고, 그대로
구현하면 **2번째 이후 dep의 Observer엔 `canExecute` 판정 근거가 아예 안 실려**
그 Observer의 재실행이 통째로 죽는다 — 바로 이 문단 자신이 경고하는 실패
모드다. 필드를 배열로 바꾸고 cascade/`Subscribe`/`Unsubscribe`를 전부 순회로
고친다(새 결정 없음, 반영 누락). `Ref` dep은 Observer가 아니라 콜백이라 이
배열에 안 들어간다 — 그쪽 해제는 아래 `H-7` 문단이 소스.
- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observers[i] =
observer`(dep이 State/Source인 경우만 존재). 이건 GC 방지가 목적이 아니라
(그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime`
cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것.
- **`bindLifetime(inst, handle)``state`가 있는 경우 내부 Observer도
같은 `inst``handle._observers` **전부**에 대해
`bindLifetime(inst, observer)`를 cascade해야 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해
`bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`
`handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유:
`canExecute(observer)`가 보는 gcconn 참조는 **그 Observer 자신이
`bindLifetime(inst, observer)`될 때 그 Observer 쪽 릴레이션에
복사되는 것**이라, `EffectHandle`만 바인드하고 내부 Observer는 안 하면
그 Observer에겐 판정 근거가 아예 없어서 `canExecute`가 항상 거짓이 됨
(=재실행이 통째로 죽음). 같은 이유로 `unbindLifetime(handle)`도 내부
Observer까지 같이 풀어야 대칭이 맞음.
**[정정, 2026-08-14 다섯 번째 세션]** 이 항목이 원래 근거로 든
"`canExecute`가 `Subscribed` 필드 + `inst`의 gcconn을 함께 본다"는
틀렸음 — `.Subscribed`는 구독 경로 전용이고 leaf 경로와
무관(`archive/canexecute-inst-arg-reversed.md`). cascade가 필요하다는
결론은 그대로이고 오히려 근거가 더 직접적이 됨.
- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은
전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observers`
전부, 또는 `handle._observers`만으로 충분한지는 구현 세부 — 어느 쪽이든
"`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면
안 됨).

View file

@ -3,6 +3,13 @@
> **⛔ [2026-08-14 세션, 사용자 확정 — 기각]** `research/`에서 > **⛔ [2026-08-14 세션, 사용자 확정 — 기각]** `research/`에서
> `archive/`로 이전. **더 이상 "열린 가능성"이 아니라 미지원으로 확정.** > `archive/`로 이전. **더 이상 "열린 가능성"이 아니라 미지원으로 확정.**
> >
> **⭐ [2026-08-28] 좁은 형태로 부활 — `research/existing-mount-plan.md`.** 여기서
> 기각된 것은 *"이미 있는 Instance에 나중에 새 props를 다시 바인드"*이고 그
> 사유(바깥이 자식 구성을 밀고 당기면 부기가 깨진다)는 그대로 유효하다. 부활한
> 것은 그 반대 방향 — **한 번 `Claim`하면 quad가 소유하고 직계 자식은 사용자가
> 전부 매핑한다**(claim-once · own-all, 재바인드는 여전히 미지원). 루트(PlayerGui)와
> `Clone()` 템플릿이 그 용도다.
>
> **기각 사유(사용자)**: 이게 가능하다고 하면 `Dispatch.setOffsetSource`/ > **기각 사유(사용자)**: 이게 가능하다고 하면 `Dispatch.setOffsetSource`/
> `setLength`(`base/dispatch-core-plan.md`의 "Length/Offset" 절) 같은, > `setLength`(`base/dispatch-core-plan.md`의 "Length/Offset" 절) 같은,
> quad가 자기가 만든 트리에 대해서만 성립한다고 전제하고 세운 부기를 > quad가 자기가 만든 트리에 대해서만 성립한다고 전제하고 세운 부기를

View file

@ -1217,3 +1217,78 @@ hot path다.
`base/state-epoch-plan.md` §2, 근거 기록은 `base/state-epoch-plan.md` §2, 근거 기록은
`reference/epoch-brand-composition.md` §4의 4번. `reference/epoch-brand-composition.md` §4의 4번.
(필드 이름 `Revision`과 타입 `Epoch`는 확정, 승격 시 `base/`에 반영됨.) (필드 이름 `Revision`과 타입 `Epoch`는 확정, 승격 시 `base/`에 반영됨.)
## [해소됨, 2026-08-24] M2/M3 마일스톤 경계 — 순서 교체로 확정
2026-08-22 `ROADMAP.md` 전반 점검에서 드러난 항목. **설계 결정이 아니라
마일스톤 *순서* 문제**라 당시 `question.md`의 최우선 절이 아니라 2번
항목으로 따로 뒀었다. 2026-08-24에 사용자가 **(a) 순서 교체**를 선택해
같은 날 전량 반영됐다.
**발단** — 옛 M2(디스패치 엔진)와 옛 M3(Store/State/Source)의 의존이
양방향이라 그 순서로는 M2를 끝까지 짤 수 없었다.
- **디스패치 → 반응형 (본체 의존)**: `Dispatch.setLength`
`len: number | State<number>`를, `Dispatch.setOffsetSource`
`Source<number>`를 받고, `recompute``offset:Set()`을 부른다
(`base/dispatch-core-plan.md`의 "Length/Offset" 절). 2026-08-22에
디스패치 쪽으로 앞당겼던 `GateNode`/`Blocker`도 State 위에 얹힌다.
- **반응형 → 디스패치 (얕은 의존)**: `state:Observer`/`Effect`의 동적 경로
가드가 `Dispatch.addHandler` + `Handler.luau` 계약을 쓴다 — 레지스트리
등록 표면만 있으면 되므로 디스패치 **전체**를 요구하지 않는다.
**선택지와 판단** — (a) 순서 교체 / (b) 디스패치를 둘로 분할 / (c) 지금
구조를 두고 구현 시 알아서 오간다. **사용자 선택은 (a)**("(a) M2와 M3의
순서를 바꾸는게 맞겠던데"). 그 자리에서 같이 확인된 근거:
1. **의존의 비대칭**이 명확하다 — 무거운 쪽(본체 의존)을 아래에 깔고
가벼운 쪽(핸들러 등록)을 뒤로 미루는 게 맞다.
2. 옛 순서로는 **디스패치 마일스톤의 `mock 대상 테스트` 체크박스가
원리적으로 불가능**했다(`setLength`/`recompute`를 State 없이 테스트할
수 없음). 반대로 반응형 코어는 순수 Lua라 `luau`만으로 단독 테스트가
된다.
3. 2026-08-22의 `EpochMap`/`GateNode`/`Blocker` 앞당김이 **불필요해진다**
— 그 이동은 "디스패치가 먼저인데 디스패치가 쟤들을 호출한다"는 이유로
한 것이었다. 순서를 바꾸면 마일스톤 내용이 의존 계층과 그대로 일치하고
끌어온 항목이 0개가 된다. "게이팅 먼저"는 그대로 지켜진다.
**(b)를 안 고른 이유**: 참조 비용이 오히려 더 크다 — 기존 `M2` 참조 전부가
"M2a인가 M2b인가"로 애매해지는데 이건 순서 교체처럼 기계적으로 못 푼다.
게다가 `Brand`를 앞으로 빼는 일은 (b)에서도 똑같이 해야 하고, 얻는 건
"디스패치 코어를 조금 일찍 짠다"뿐인데 M4 전까지 그걸 소비하는 게 없다.
**(c)를 안 고른 이유**: `project-context.md`가 못박은 *"어떤 순서로
만드는가"*의 소스가 `ROADMAP.md`라는 원칙을 스스로 무효화한다.
**같이 확정된 것 — 순서만 바꿔서는 안 끝났다.** 옛 M2 안에 반응형보다
먼저 와야 하는 항목이 셋 섞여 있었고, 이들은 새 M2 앞머리의
`### 공통 기반` 절로 옮겨졌다(State-free이자 dispatch-free):
- **`Brand.luau`** — `Source``SourceBrand`+`EpochBrand`에 등록돼야 함
(`base/source-state-plan.md`), `isState`/`isObserver`/`isEffect`가 전부
여기 얹힘.
- **`Relate.luau`** — `bindLifetime`/`unbindLifetime`이 그 위에 구현됨
(`base/lifecycle-pattern.md`의 `InstData`/`BindData`).
- **`LifetimeHandle.luau` 인터페이스** — State 전파 루프가 발화마다
`canExecute`를, 이중 바인딩 게이트가 `canBound`를 부름. 이미 M8→디스패치로
한 번 앞당긴 전례가 있던 항목이라 한 칸 더 앞당긴 것뿐.
반대로 새 M3로 넘어간 것은 둘 — Observer/Effect **동적 경로 가드 등록**과
**`ObserverEffectLeafHandler`**(`H-39`). 둘 다 "핸들러를 등록한다"뿐이라
본체와 잘라내기 쉽고, **이것이 M2가 M3에 개념상 지던 유일한 의존**이었다 —
그 둘을 M3로 미뤘으므로 **빌드 순서상 역방향 간선은 남지 않는다**:
```
L0 공통 기반 Brand · Relate · LifetimeHandle(인터페이스) ┐
L1 반응형 Source/State/Store · EpochMap · Gate/GateNode · ├ M2
Blocker · :Compute/:Apply · Observer · Effect ┘
L2 디스패치 Handler · Dispatch 코어 · chains · None · ┐
setLength/setOffsetSource/getOffsetAt · ├ M3
Leaf · 가드 등록 · ObserverEffectLeafHandler ┘
```
**⚠️ 번호 재부여의 부작용** — 라이브 문서의 `M2`/`M3` 참조는 전부 새 번호로
맞췄지만(코퍼스의 참조가 거의 전부 "그 내용이 사는 마일스톤"을 가리켜서
기계적 맞교환으로 의미가 보존됨), **`session/`·`archive/`·`qa-request/`는
히스토리 문서라 소급 수정하지 않았다.** 2026-08-24 이전에 쓰인 그 문서들의
`M2`/`M3`는 **옛 의미**(M2=디스패치, M3=반응형)로 읽을 것. 이 경고는
`ROADMAP.md`의 M2 배너에도 있다.

View file

@ -0,0 +1,355 @@
# [검토 후 철회] Store를 "값 필드 + 타입 함수 합성"으로 바꾸려던 시도
**상태**: archive — **2026-08-25 오전에 도입했다가 같은 날 철회됨.**
지금 유효한 설계는 `base/store-plan.md`가 소스. 이 문서는 그 시도의 원문과
**왜 되돌렸는지**를 보존한다(히스토리 전용, **여기 적힌 것을 현재 설계로
읽지 말 것**).
## 무엇을 시도했나
`WrapStore`/`ProcessStoreType` 타입 함수로 결과 타입을 **합성**하는 옛
접근이 7라운드 `H-75`/`H-76`으로 무너지자, 그 자리를 다음으로 채우려 했다:
| 시도한 것 | 지금(철회 후) |
|---|---|
| 타입 인자가 **평범한 값 타입**(`Store<<{hp: number}>>`) | 타입 인자에 **`Source<T>`를 직접**(`Store<<{hp: Source<number>}>>`) |
| `store.key`**값**(`__index`가 `Of(k):Get()`) | `store.key`**`Source<T>`**(평범한 레코드 필드) |
| `store.key = v` **부활**(`__newindex`) | **폐기 유지**`store.key:Set(v)` |
| `store:Of(k)`**프리미티브** | `store:Of<<T>>(name)`**동적 키 전용**(옛 `GetDynamic` 흡수) |
| 팬텀 필드 `__store` + `index<>`/`keyof<>` | **타입 함수 안 씀** — 평범한 레코드 |
| 선언만 하고 값을 안 주는 키를 `?`로 표현 | **명시적 초기화**(이건 **살아남았다**) |
**살아남은 것은 명시적 초기화 하나**다 — eager/lazy 이중 모델 폐기,
`store:Names()` 신설, `WrapStore` 폐기. 나머지는 전부 되돌렸다.
## 왜 철회했나
**사용자 지적**(2026-08-25): *"우리가 너무 '필드셋' 으로 처리하고싶다에
너무 몰두해버린것 같네요. 필드의 read write 의 실질적 의미론이 어떤지도
정의하지 못해버린채요. 이러면, `.Value = 1` 이 정적 쓰기처럼 보일텐데,
여기서 error 터지는 trace 가 나오면 당황스럽기도 하고요, 약간 마법적
동작이기도 해요. … 왜 우리가 `Source()` 를 직접 넣는걸 거부하고, 이렇게
까지 하려 했죠? 단순히, `Store<{ a: Source }>` 로 두고, `store.a:Set,Get`
하지 말아야할 이유가 있을까요?"*
되짚어보니 **비용이 이득보다 컸다**:
1. **읽기/쓰기 의미론을 정의하지 못한 채로 문법만 도입했다.**
`store.key = v`가 정적 쓰기처럼 보이는데 실제로는 `Set`을 트리거하고,
실패하면 그 자리에서 error trace가 난다 — 2026-08-06이 대입 문법을
폐기한 논거 2번(*"`=`가 암시하는 즉시 커밋이 lazy와 안 맞는다"*)이
그대로 되살아난 셈이다.
2. **`index<>`/`keyof<>`도 타입 함수다.** **사용자 지적**: *"사실 keyof 와
index 도 타입함수입니다(구현을 뜯어보면, 루아우에서 프리디파이닝한
타입함수임). 타입함수가 가지는 고질적 문제를 그대로 가져요."*
실제로 `H-76`이 지적한 부류의 문제가 새로 하나 생겼다 — `Of`
**`Self` 제네릭을 거치면 콜백 파라미터 추론이 깨져** `s``unknown`으로
떨어졌다(평범한 레코드 필드에선 정상 추론된다, 실측 대조).
3. **`store:Names()`가 런타임에 구현 불가능했다.** Luau가 타입 인자를
런타임에 지우므로 `Store<<{hp: number, name: string?}>>({hp = 100})`에서
`name`이 선언됐다는 사실을 런타임이 알 방법이 없다. 그런데
`attr:NameMap()`은 선언된 전체 키를 요구한다 — 즉 `H-79`가 "닫혔다"고
한 문제가 옵셔널 미설정 키에 대해 그대로 재발한다(감사 4패스 발견).
4. **`?`(nilable) 선언을 떠받칠 sentinel이 없었다.** `None`은 **핸들러
배열의 nil hole** 때문에 생긴 물건이라 "값이 없다"가 아니고,
`Source<None|T|nil>`을 정의한 적도 없어 `Compute`가 뭘 받을지부터
안 정해진다(**사용자 분석**). 그렇다고 `nil``None`을 가르는 새
도구를 두기엔 의미론이 부딪힌다.
5. **표면이 늘었다**`Of` + `Names` + `GetDynamic` 셋이었는데, 철회 후엔
`Of``GetDynamic`을 흡수해 둘이 된다.
**여기서 나온 원칙 하나가 `base/typing-limits.md` §0으로 승격됐다** —
*"타입 함수는 타입이 못 잡는 문제를 **에러로 띄우는** 정도 이상으로 가지
않는다."* 이 시도가 정확히 그 선을 넘은 사례다.
경위 원문은 `qa-request/pre-implementation-handtrace-round7-followup.md`
🅖🅗 절, 발견 원문은 `qa-request/pre-implementation-handtrace-round7.md`
`H-73`~`H-76`.
---
## 철회된 시도의 원문 (보존)
아래는 **2026-08-25 오전에 `base/store-plan.md`에 실제로 들어갔던 네 절**을
그대로 옮긴 것이다. 같은 날 철회돼 지금은 `base/store-plan.md`에 없다.
**여기 적힌 것을 현재 설계로 읽지 말 것** — 특히 `store.key`가 값이라는
서술, `__index`/`__newindex` 슈가, 팬텀 필드 `__store`,
`index<>`/`keyof<>`, `?` nilable 선언, `store.key = v` 부활은 **전부 철회**됐다.
---
## Store = Source들의 이름 붙은 모음 (명시적 선언, 2026-08-25 재설계)
> **⭐⭐ [2026-08-25 재설계] 이 절은 통째로 다시 쓰였다.** 옛 모델
> (`store.key`가 `Source<T>`를 직접 반환하는 레코드 필드 + eager/lazy
> 이중 생성 + `WrapStore` 타입 함수)은 **역전**됐고 원문은
> 이 파일(당시 이름은 store-source-record-model-reversed.md였다)에 있다. 경위는
> `qa-request/pre-implementation-handtrace-round7-followup.md`.
**Store는 키를 타입 인자로 명시해서 만든다.** 타입 인자에 담는 것은
`Source<T>`가 아니라 **평범한 값 타입**이다.
```lua
local store = quad.Store<<{
hp: number,
name: string?, -- 안 넘겨도 되는 값은 `?`로 선언
}>>({ hp = 100 })
```
- **`store.key`는 값**이고 **`store.key = v`는 대입**이다 —
`__index`/`__newindex`가 아래 `Of`를 대신 불러준다. 읽기에 계산이 끼지
않는다(Store가 담는 건 `Source`뿐이므로 `Get`이 곧 현재 값이다).
사용자: *"`__index` 는 이제 `:Get` 해주어도 될것 같다. (연산이 안
일어난다. 진짜 현 값이 맞음)"*
- **`store:Of(key)`가 프리미티브다** — 그 키의 `Source`를 돌려준다.
`__index`/`__newindex`는 그 위의 슈가다(사용자: *"`__index` 를 통한
get 에선 `Of()` 결과에서 `:Get` 하도록 합시다. `__newindex`
유사하죠, 그 이후 `Set` 되는 식"*). 반응형으로 묶을 때는 이걸 쓴다.
```lua
store.hp -- number
store.hp = 5 -- 대입
store:Of("hp") -- Source<number>
store:Of("name") -- Source<string?>`?` 선언이 그대로 드러남
```
- **이름이 `Peek`이 아니라 `Of`인 이유** — 사용자: *"Peek 자체가, 애초에
여긴 다 확정된 값들의 무더기라서, Peek 아닌 다른게 좋긴 해보여요.
`Of` 가 가장 좋아보입니다."* 다른 프레임워크에서 `Peek`은 관례적으로
"구독 없이 값을 읽는다"인데 여기선 `store.key`가 이미 그 일을 하고
이 메소드는 **핸들**을 준다.
- **⭐ 선언은 필수, 값은 선택.** 안 넘겨도 되는 것은 타입에서 `?`
선언한다 — 그러면 `store:Of("name")``Source<string?>`로 나와
**타입이 정직해진다**. 옛 lazy 모델이 갖던 구멍(사용자: *"`Set` 을 안
해주면, 초기 값이 타입에 어긋날 수 있거든요. 애초에,
`Source<number>` 인데, nil을 조용히 가지고 있을수도 있고, 타입으로 못
막네요"*)이 이걸로 닫힌다. 부모 컴포넌트가 기본값 있는 것까지 전부
넘길 필요도 없어진다.
- **`Source` 실체는 그림자 백킹 테이블에 있다** — `store.key`가 값이면
`__index`/`__newindex`는 그 키가 store 테이블에 **없을 때만** 발동하므로
실제 `Source`를 store 테이블에 raw로 넣을 수 없다. **[2026-08-25
정정]** 옛 서술의 *"별도 `__values`류 그림자 실값 저장소도 불필요"*는
이 재설계로 뒤집혔다.
- **미선언 키의 방어선은 여전히 타입이다.** `Store<{field: T}>`로 선언된
Store에 없는 이름을 쓰면 타입 에러가 난다 — `luau-analyze` 실측에서
`store:Of("nope")`이 정확히 거부된다. 런타임에 이름이 정해지는 정당한
용도는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구.
- **`store:Names()`가 선언된 키 집합을 준다** — 타입은
`{ keyof<...> }`로 정확히 나온다. 그룹 `Attribute(...)`/`attr:NameMap()`이
이걸 요구한다(`base/attribute-plan.md`). **[2026-08-25 신설, 7라운드
`H-79`]** 이 표면이 없어서 그룹 `Attribute`의 키 집합이 접근 이력에
좌우되던 문제를 닫는다.
- **구현 스케치**: 생성 시 `table.clone(defaults or {})` 후 그 결과를
순회하며 각 슬롯을 `Source(v)`로 교체해 그림자 테이블로 삼는다
(`table.clone`이 원본의 해시/배열 슬롯 구조를 재사용해 빈 테이블에
키를 하나씩 넣는 것보다 쌈 — 2026-08-07 성능 근거 그대로).
**`or {}`가 필수다** — 무인자 `Store<<{}>>()`도 유효한데
`table.clone(nil)``table expected, got nil`로 죽는다(**[2026-08-25]**
7라운드 `H-83` 실측).
`Source()`(인자 없이 호출)는 `Source(nil)`과 동치.
- **⭐ [2026-08-25 신설] 선언은 됐는데 `defaults`에 없는 키는 `Of`가 그
자리에서 만든다.** "선언은 필수, 값은 선택"이 계약이므로
`Store<<{hp: number, name: string?}>>({hp = 100})`
`store:Of("name")`은 **정상 경로**인데, 위 스케치는 `defaults`에 있는
키만 그림자 테이블에 넣으므로 그 자리가 비어 있다. `Of`가 비어 있으면
`Source(nil)`을 만들어 저장하고 반환한다 — 타입이 `Source<string?>`
**정직하고**, 옛 lazy 모델이 갖던 구멍(`Source<number>`인데 조용히 `nil`)은
`?` 선언 강제로 이미 닫혀 있다.
```lua
function Store:Of(key)
local src = shadow[key]
if src == nil then
src = Source() -- == Source(nil)
shadow[key] = src
end
return src
end
-- __index(t, k) == t:Of(k):Get()
-- __newindex(t,k,v) == t:Of(k):Set(v)
```
**미선언 키는 여기 안 온다** — 타입에서 이미 거부되고(`store:Of("nope")`
실측), 런타임에 이름이 정해지는 경로는 `:GetDynamic`이 정식 창구다.
v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게
쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 타입이 핵심
제약이라 그 전제 자체가 더 이상 안 맞고, 2026-08-06 후속 세션의 정리로
Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐. 값 하나만
반응형으로 다루고 싶으면 Store를 통째로 만들지 말고 독립
`Source(default)`를 쓸 것(`base/source-state-plan.md`의 "Source는 독립
공개 프리미티브로 격상" 절).
## Store 값 설정 문법 — `store.key = v``store:Of(k):Set(v)`는 같은 것 (2026-08-25 재역전)
> **⭐⭐ [2026-08-25 재역전] 2026-08-06에 확정했던 "`myStore.key = value`
> 폐기, `source:Set(value)`로 전환"은 뒤집혔다.** 원문과 역전 이유는
> 이 파일(당시 이름은 store-source-record-model-reversed.md였다). 여기 옛 결정을 남겨두면
> 앞에서부터 읽는 구현자가 그걸 그대로 믿으므로 포인터만 남긴다.
**둘 다 정식 경로이고 같은 것이다.**
```lua
store.hp = 5 -- __newindex → store:Of("hp"):Set(5)
store:Of("hp"):Set(5) -- 프리미티브
```
- **왜 되살아났나**: 옛 결정의 첫 근거는 *"읽기(`Source<T>`)/쓰기(`T`)
타입이 갈려 mismatch가 남음"*이었는데, 위 재설계로 **읽기도 `T`가 되어
대칭**이다. `luau-analyze` 실측에서 `store.hp = 5`는 통과하고
`store.hp = "five"`는 정확히 걸린다.
- 옛 결정의 둘째 근거(*"`=`가 암시하는 즉시 커밋이 quad의 lazy와 안
맞음"*)도 이 자리에선 약하다 — lazy한 것은 **하류 전파**이지 Store 값
자체가 아니다. 사용자: *"연산이 안 일어난다. 진짜 현 값이 맞음."*
- 옛 결정의 셋째 근거(**"값을 바꾸는 연산엔 `:` 체이닝 허용"** 원칙,
`base/architecture.md`)는 그대로 유지된다 — `store:Of(k):Set(v)`
그 사례다.
- **`myStore "key"`(문자열 커링)는 여전히 기각**이다(2026-08-18 사용자
판정) — *"저러면 "a" 가 string 으로 들어가서, Source<T> 의 타입을 모르기도
하고, 우린 더이상 필요하지 않게 된 요소임."* 동적 키는 아래
`:GetDynamic`으로 간다.
`base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께
읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며
"불변 빌더"를 흉내낸 것이었지, `:` 체이닝 문법 자체나 대입 문법 자체가
아니었음.
## 타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정 (2026-08-04 3차 라운드, 2026-08-25 재작성)
- `store "key"`(문자열 커링)로 `state<T>`를 오버로드 함수 타입으로 정확히
추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**,
위 절), **`store.key`(dot-access)를 1급 경로로 확정**. Store 타입을
`index<>`/`keyof<>`로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고,
문자열 리터럴 narrowing 문제 자체가 안 생김(구체적인 모양은 아래
"`store.key` 레코드 필드 타이핑" 절).
- **동적 키 경로는 명시적 메소드다**`store:GetDynamic<<T>>(name): Source<T>`.
런타임 동작 자체는 dot-access와 같고 문제는 **타입**뿐이었다: 선언되지
않은 이름은 결과 타입에 없어서 타입 에러가 난다(그게 방어선이라는 게
사용자 확정). 그래서 "런타임에 이름이 정해지는" 정당한 용도를 위해
**타입을 호출자가 직접 주는 명시적 창구**를 둔다 — 사용자 판정:
*"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐. 타입 애러가 난다는
점인데, 이는 GetDynamic<T>(name): Source<T> 로 제공하는게 최선으로 보임."*
- **⭐ [2026-08-25 실측 정정] `<<T>>`는 값 호출부에서 동작한다.**
7라운드 `H-73`이 *"Luau엔 호출부 명시 타입 인자 문법이 없다"*고
단정했으나 **틀렸다** — Luau의 **generic type instantiation**
(`luau.org/types/generics/#generic-type-instantiation`)이 값 호출부에서도,
콜론 메소드에서도 `T`를 실제로 묶는다. `luau-analyze` 실측:
```lua
local ok: Source<number> = store:GetDynamic<<number>>("x") -- 진단 없음
local bad: Source<string> = store:GetDynamic<<number>>("y") -- 정확히 걸림
local none: Source<number> = store:GetDynamic("z") -- Source<unknown>
```
원문은 **인스턴스화를 생략한 호출만** 돌려보고 단정했다. 따라서
`base/quad-types-plan.md`의 이중 꺾쇠 관례는 타입 자리 전용이 아니다.
- **⭐ [2026-08-25 확정] 표면은 전부 콜론 메소드이고, 예약 키 충돌은
타입 함수가 잡는다.** 예약 이름은 `Of`/`Names`/`GetDynamic`/`__store`
(팬텀 필드)다.
- **안 잡으면 조용히 죽는다** — 실측에서 사용자 키가 예약 이름과 겹치면
교집합이 뭉개져 **그 필드의 타입 검사가 통째로 꺼진다**(음성 대조군이
진단 0건으로 통과했다). 시끄럽게 막히는 게 아니라 그냥 지나간다.
- 그래서 `T`**검증만 하고 그대로 통과시키는** 작은 `type function`
둔다. 겹치면 사용 지점에
`TypeError: quad.Store: "Of" is a reserved key`가 뜬다.
```lua
type function CheckReserved(t: type): type
-- 겹치면 print(...) + return types.never, 아니면 t를 그대로 반환
end
type Store<T> = StoreOf<CheckReserved<T>>
```
- **`error()`는 못 쓴다** — `type function` 자체가 실패한 걸로 판정돼
버려진다. `print(...)` + `return types.never` 조합만 된다
(`luau-test/done/23-type-quadtypes-checkversion-addplugin.luau`가
기록해둔 사실이고 `type-version-check`가 이미 쓰는 패턴).
- **[2026-08-25 해소] 옛 "탑레벨 함수로 옮길지" 미결은 닫혔다** —
콜론 유지로 확정했고, 예약 키 문제는 위 타입 함수가 받는다. 팬텀
필드(`__store`)의 사용은 UB로 둔다.
- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트
전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의
**유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환).
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학"
절이 최신 확정 내용.
### `store.key` 레코드 필드 타이핑 — `index<>`/`keyof<>`로 해결 (2026-08-25 재작성, `WrapStore` 폐기)
> **⭐⭐ [2026-08-25] 옛 `WrapStore`/`ProcessStoreType` 타입 함수 접근은
> 폐기됐다.** 원문은 이 파일(당시 이름은 store-source-record-model-reversed.md였다).
> 7라운드 `H-75`/`H-76`이 그 접근의 두 한계를 실측했는데(평평한 선언이면
> `store.key:Compute(무주석 콜백)`이 깨짐 / `type function`이 바깥 타입
> 별칭을 참조 못 해 구조를 통째로 중복 작성해야 하고 메소드 self
> 파라미터가 불변이라 필드 하나만 어긋나도 대입 실패), 아래 모양은
> **결과 타입을 합성하지 않으므로** 두 한계에 아예 안 닿는다.
**확정 형태** — 팬텀 필드에 선언 원형을 싣고, `index<>`/`keyof<>`로 꺼낸다.
```lua
type StoreOf<T> = {
__store: T, -- 팬텀. 사용은 UB.
Of: <Self, K>(self: Self, key: K & keyof<index<Self, "__store">>)
-> Source<index<index<Self, "__store">, K>>,
Names: <Self>(self: Self) -> { keyof<index<Self, "__store">> },
GetDynamic: <T>(self: any, name: string) -> Source<T>,
} & T
type Store<T> = StoreOf<CheckReserved<T>>
```
- `& T`가 dot-access(`store.hp`)를 그대로 만들어준다 — 타입 인자가
**평범한 값 타입**이므로 `store.hp``number`다.
- `K & keyof<...>`가 **싱글톤을 보존**한다. 사용자가 실측으로 확인한
성질이다: 타입 후보 중에 싱글톤이 있으면 `string`으로 뭉개지지 않고
`"hp"` 그대로 넘어간다. 그래서 `index<T, K>`가 그 키의 실제 타입을
뽑아낸다.
- **`luau-analyze` 실측**(전부 `--!strict`, 양성 + 음성 대조군):
| 검사 | 결과 |
|---|---|
| `store.hp` | `number` ✅ (음성 대조군 걸림) |
| `store.hp = 5` / `= "five"` | 통과 / **걸림** ✅ |
| `store:Of("hp")` | `Source<number>` ✅ (음성 대조군 걸림) |
| `store:Of("name")`(`string?` 선언) | `Source<string?>` ✅ |
| `store:Of("nope")` | **거부** ✅ |
| `store:Names()` | `{ "hp" \| "name" }` ✅ |
| `store:Of("hp"):Set(3)` / `:Set("3")` | 통과 / **걸림** ✅ |
| `Store<{ Of: number }>` | **`"Of" is a reserved key`** ✅ |
| `store:Of("hp"):Compute(무주석 콜백)` | ⚠️ **파라미터 주석 필요**(아래 캐비엇) |
- **⚠️ [2026-08-25 정정] `store.key:Compute(…)`라고 적었던 표 행은 거짓이었다.**
실측한 적이 없었고(`/code-review high`가 잡음), 애초에 새 모델에서
`store.key`는 **값**이라 `:Compute`가 없다
(`Type 'number' does not have key 'Compute'`). 파생은 프리미티브를
거친다 — `store:Of("hp"):Compute(...)`.
- **`store:Of(k)`의 반환값 추론은 무주석으로 정확하다 — Store 특유의
타입 문제는 없다.** 실측(`audit/type-store-index-keyof/spikes/08`):
```lua
local hp = store:Of("hp") -- 무주석
local a: number = hp:Get() -- ✅ (string 대조군은 정확히 걸림)
hp:NoSuchMethod() -- ✅ 걸림
hp:Set(5) -- ✅ (`:Set("5")`는 걸림)
local nm = store:Of("name") -- Source<string?>`?` 선언이 그대로 드러남
```
- **걸리는 자리는 하나 — `:Compute`/`:With`의 콜백 파라미터 무주석 추론**이고,
그건 Store가 만든 게 아니라 `base/typing-limits.md` §1의 **문제 A**
자체다. 해법도 그 문서가 정한 그대로 — **파라미터에 주석을 단다**(실측
통과):
```lua
hp:Compute(function(s) return s:Get() * 2 end) -- ❌ 진단
hp:Compute(function(s: StateData<number>) return s:Get() * 2 end) -- ✅
```
§1②쪼개기가 평범한 `Source`에서 이 자리를 덮어주는 것과 달리 `Of`
거치면 안 걸린다(`Self` 제네릭 때문 — `spikes/07`). `& T` 형태에서
`Self`를 빼는 건 불가능하고(`self: any`는 탐색 기록의 `Store4`처럼 통째로
깨진다), §0의 *"Luau의 한계를 우회하려고 타입/API를 비틀지 않는다"*에
따라 표면을 비틀지 않는다.
- **파생 결과에 명시 주석을 다는 상시 규약은 그대로 유효하다**
`audit/type-recursion-issue/REPORT.md` 3-1절이 실측(`43`)으로 확인한
대로, 명시 주석 이후 **다운스트림 전체는 정상 체크**되고 구멍은 그 한
줄(RHS가 실제로 그 타입인지)뿐이다.
- **`__call` 경로는 죽었다** — 타입 레벨 `__call``self`를 못 받고,
`typeof(f<<T>>)`로 타입 인자를 넘기는 것도 실패한다(사용자 실측).
그래서 Store를 콜러블로 만드는 안은 성립하지 않는다. 같은 사실이
`base/source-state-plan.md``:Apply`에도 적용된다(7라운드 `H-94`).
---
## 이 시도 이전의 원문은 archive에 없다
`WrapStore`/`ProcessStoreType` 접근(2026-08-12~2026-08-15)은 `H-75`/`H-76`
실측으로 폐기됐고, 그 폐기는 **철회되지 않았다**`base/typing-limits.md`
§5의 폐기 배너와 `luau-test/STATUS.md``16`/`21` 항목이 소스다.
`store.key = value` 폐기(2026-08-06)와 dot-access 1급 경로도 **현행**이라
`base/store-plan.md`에 그대로 있다.

View file

@ -0,0 +1,98 @@
# 10라운드 참고 구현 — `H-107`~`H-149` 계약으로 갱신한 스파이크 모음
**[2026-08-28 신설]** `audit/handtrace-round7-reference-impl/`(7라운드)와 9라운드
스크래치(`core9.luau`/`dispatch9.luau`, 지금은 여기 같이 둠)를 **커밋 `0ec22fb`
시점의 `base/`**(8·9라운드 반영 + `H-143`~`H-146`)로 옮긴 것.
발견 보고는 `qa-request/pre-implementation-handtrace-round10.md`(§5/§7이 이 폴더를
가리킨다). 이 README는 **무엇을 갱신했고 어디서 결과가 나왔는지**만 적는다 —
결정의 소스가 아니다.
실행: `cd spikes && luau <파일>.luau` (타입 스파이크 `ty*``luau-analyze`).
`luau``~/.local/share/mise/installs/luau/latest`(mise). 저장소 코드는 전혀
`require`하지 않는다 — 참고 구현은 `base/`의 의사코드를 손으로 옮긴 것이다.
## 폴더 구성
| 파일 | 무엇 |
|---|---|
| `spikes/core10.luau` | **반응형 코어 참고 구현(이번 라운드)** — 아래 "계약 갱신" 표 |
| `spikes/dispatch10.luau` | **디스패치 부기 + Slot 참고 구현(이번 라운드)** |
| `spikes/core.luau` / `dispatch.luau` / `chain.luau` | 7라운드 원본(그대로 복사, `cp -n`). `chain.luau`(하강 diff 체인)는 `d23`이 그대로 재사용 |
| `spikes/core9.luau` / `dispatch9.luau` | 9라운드 스크래치 원본(그대로) |
| `spikes/t14`~`t24`, `d20`~`d23`, `ty11` | **이번 라운드 신규** — 아래 결과 표 |
| `spikes/d10`/`d11`/`d13`/`d14` `_r10` | 9라운드 `H-124` 스파이크를 `sed``dispatch10`/`core10`에 물린 사본 |
| 그 외 `t1`~`t13`, `d1`~`d18`, `g*`, `e1`, `ty1`~`ty10` | 7·9라운드 원본 그대로(옛 `core`/`dispatch`/`core9`를 `require`함 — 이번에 다시 돌리지 않았고 결과도 그쪽 README/파일이 소스) |
## 계약 갱신 — round7 `core.luau`/ref9 `core9.luau` 대비 `core10.luau`가 바꾼 것
| 항목 | 옛 참고 구현 | `core10.luau` (근거) |
|---|---|---|
| `EpochMap` | `Update`/`Refresh`/`Sync`/`TrackFrom` | + **`Peek`**(`GateNode:_receive`의 emit 판정, `H-124` 이전 7라운드 확정) |
| Observer `fn` | 2-인자 | **3-인자 `fn(targetState, self, emitFrom)`** + `observer._state` 강참조(`H-109`/`H-110`) |
| Observer 진입점 | `Subscribe`/`Unsubscribe` | 넷 — `WeakSubscribe`(프리미티브, `.Subscribed = true`, `H-111`)/`Subscribe`(위임 — `H-149` 그대로 재현하려고 **일부러** 안 고침)/`WeakUnsubscribe`(관대, `H-133`)/`Unsubscribe`(엄격) |
| `Ref` | `Callbacks` 배열 | 해시맵 셋 + **`WeakCallbacks`**(weak-키) + `Uncallback`, `:Set` 순서 값 → 리비전 → 콜백(스냅샷, 교차 dedup), 콜백 `k(value, self)`(`H-107`/`H-108`) |
| `Effect` 생성자 | dep마다 바인드 시 등록 | **생성자에서 한 번**, `_blocker:On()``OffWithoutEmit()`, dep 종류별 클로저 둘(`onRefFire(_, ref)` / `onStateFire(_, _, from)`), 공통 `fire`: `canExecute``_blocker:IsOn``_epochs:Update``Rerun`. `M.effectTrace` 훅으로 가드 판정을 기록할 수 있다(`t18`) |
| `_bindDestroying` | 없음/부분 | `_unbindDestroying()` 선행 → `onDestroying` 연결 → **`Refresh()` 먼저** → `not _installed or depsChanged → Rerun` |
| `Rerun` | 단순 | 루프 머리 `_consumeCleanup``fn` → 꼬리 `wasAlive and not canExecute` 즉시 소진 + `_pending` 드롭(`H-143`) |
| `Effect` 진입점 | 없음 | 네 개 + `resubscribeTail`(`H-144` (b)) |
| `GateNode` | `_receive` `Update`만 | `Update` + `Peek`, 배치 **unfold**해서 `_withheld`에, `_flush(commit) -> boolean`, 빈 배치 무시 |
| `Blocker` | 강한 핸들 목록 | weak 핸들 집합(`H-63`), 강한 주인은 `onUpstreamEmit` 클로저(`Policy(emit)`가 돌려준 핸들을 업밸류로) |
| `state:Block` | 별도 노드 | `Gate(self, function(emit) return blocker:Policy(emit) end)` |
| 생명주기 mock | `bindLifetime`만 | `isEffect(value)``_bindDestroying(inst)`/`_unbindDestroying()`, `onDestroying(inst, fn) -> {Connected, Disconnect}`, `destroyInst`**Immediate** 모드(콜백 동기 실행 후 `conn.Connected = false`) |
`dispatch10.luau``dispatch9.luau` 대비 바꾼 것: `bk.indexOfElement`를 **weak-키**로
(`H-145`), `recompute`의 되감기 판정을 `lengthList[i]` 읽기 **앞**으로(`H-124`,
`continue`), Slot 생성자에서 `Length`/`Offset`/`_baseObserver` 생성 + `_destroyed`
(9라운드 Q2), `materializeSlotTree` 순서(blocker On → `_baseObserver` bind →
`setOffsetSource`), `reconcile``ownsGate` 배치(`H-136`), `mountTop`의 retractor가
`setLength(inst, k, 0)`**요소 없이** 부름(`H-145`의 해제 경로), `rawMove`
`H-29` 규약(배열 permute, `N` 불변, `minPos - 1` 무효화, 이동 범위 `setLength`
재등록)을 탐사자가 옮긴 것(base에 의사코드 없음 — 검증 대상이 아니라 도구).
## 옮기며 고친 것 (참고 구현 자체의 오류 — 발견 번호 없음)
1. **`_recompute`의 첫 인자.** round7 `core.luau`/ref9 `core9.luau`
`fn(resultNode, cache, deps…)`처럼 **결과 노드**를 첫 인자로 넘겼다. base
(`source-state-plan.md`)는 `fn(self, previous?, ...deps)``self`가 **리시버의
lazy 핸들**이다. 옛 스파이크들은 첫 인자를 안 써서 무해했으나, `t16`/`t17`처럼
`fn` 안에서 `s:Get()`을 부르면 결과 노드 자신을 다시 계산해 **스택 오버플로**.
`core10.luau``self.fn(self._hold[1], self.cache, table.unpack(self._hold, 2))`.
2. **`elementOwner`의 값 모드.** 처음 옮긴 `dispatch10`은 값을 강하게 잡아
weak-키 → 값 → 키 순환(Luau엔 ephemeron이 없다, `relate-plan.md`)으로 옛
Slot이 GC되지 않았다(`d20` 첫 실행). `slot-plan.md`가 요소 소유권 절에서 전부 `SetWeak`이라 못박은 대로
`__mode = "kv"`로 고쳤다.
3. **`isState``GateNode` 포함.** `core10.luau``isState``Source`/`State`/
`GateNode` 메타테이블을 직접 나열한다 — base엔 그 등록이 없다. 그게
`H-152`다(`t24`가 빼면 어떻게 되는지 재현).
4. 브랜드는 `Brand` 대신 **메타테이블 identity**로 판별한다(스파이크 단순화) —
`t24`는 이 단순화를 이용해 "브랜드 누락"을 흉내낸다.
## 결과 (2026-08-28, 전부 종료 코드 0)
| 스파이크 | 확인 대상 | 결과 → 발견 |
|---|---|---|
| `t14_effect_state_matrix` | 네 진입점 × 상태 전이표 | 문서 표와 일치 |
| `t15_rerun_dead_handle` | `H-147` 케이스 1·2·3 | 재현. 케이스 3만 `BindData` 죽은 gcconn으로 `wasAlive` 참 |
| `t16_gate_resub_refresh` | 게이트 × 재구독/재바인드 × `Refresh`/`Peek` 7건 | 1·2(캐치업 우회)·4(형제 dep) → `H-151`; 나머지 확정대로 |
| `t17_hold_gc` | `_hold` 불변식 | 양성/음성 확정대로 |
| `t18_blocker_vestigial_and_set_same` | `Effect._blocker` / 같은 값 `Set` | `drop:canExecute` 3, `drop:blocker` **0**`H-150` |
| `t19_observer_level2` | `H-149` | `Subscribe`만 내부 줄 blame — 재현 |
| `t22_fn_destroys_inst` | `H-143` 동기 자기 파괴 | cleanup 즉시 1회 |
| `t23_store_reserved_runtime` | `Of("Of")`/`defaults.Of` × 구현 (I)/(II) | 둘 다 깨짐 → `H-153` |
| `t24_gate_brand_missing` | 브랜드 누락 | 발화 2 → 0, `Get` 정상 → `H-152` |
| `ty11_store_final`(analyze) | 최종형 Store 타입 + 빈 Store | 진단: 56행 예약 키(H), 43/45/46/52행 음성; 나머지 0 → `H-157` |
| `d10`/`d11`/`d13`/`d14` `_r10` | `H-124` | 통과 |
| `d20_indexOfElement_weak` | `H-145` | 키 수거 확인(테이블 키 한정 — `Instance`는 CLI 밖) |
| `d21_reconcile_batch` | `H-136` + 중첩 Slot | 사이클당 `recompute(L)` 1, 물리 op 최소 |
| `d22_detach_keygone` | `Detach`/`KeyGone` | 확정대로 |
| `d23_instancechild_spurious` | `InstanceChildHandler` 같은 값 | `Parent` nil→inst 2회, `recompute` 2 → `H-154` |
## 한계
- 물리 층은 전부 mock(`D.native` 로그) — Roblox `Instance`의 weak 키 거동,
`SignalBehavior = Deferred`, `ChildAdded`류 이벤트 횟수는 여기서 볼 수 없다
(발견 보고 §6).
- `Debounce`/`Throttle`은 옮기지 않았다(7절이 무효화 배너 아래라 확정 의사코드가
없음). 게이트 정책은 `Blocker:Policy`까지만.
- `Dispatch.drive`/`New` 파이프라인(`H-139`)은 옮기지 않았다 — `d23`은 round7
`chain.luau`의 체인만 쓴다.

View file

@ -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

View 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

View file

@ -0,0 +1,476 @@
--!nocheck
-- 10라운드 참조 구현 — HEAD `0ec22fb`(2026-08-28) 시점 `base/`를 옮긴 전사물.
-- 출발점은 9라운드 전사물 `core9.luau`(같은 폴더에 원본 보존)이고, 거기에
-- `H-143`~`H-149` 시점 계약을 얹었다:
-- · effect-plan.md — `Rerun` 꼬리 `wasAlive and not canExecute` + `_pending` 버림(H-143),
-- 네 진입점 `EffectHandle` 자기 것 + `resubscribeTail`(H-144 (b)),
-- 생성자 `_blocker`, `fire` 가드 순서, `_bindDestroying` 캐치업(Refresh 먼저)
-- · lifecycle-pattern.md (2) — Observer 네 진입점(`Subscribe`→`WeakSubscribe` 콜론 위임 **그대로**,
-- H-149가 판단 대기라 원문대로 둔다), `WeakUnsubscribe` 관대(H-133)
-- · gate-plan.md 4번/GateNode 조립 — `_receive`(valueEpochMap:Update + emitEpochMap:**Peek**),
-- `_flush(commit)` 네 단계 + 반환값, `newWeakK` 스왑(H-9), unfold 합류
-- · blocker-plan.md/H-63 — onunblock 핸들 weak-키 셋, 강한 주인은 `onUpstreamEmit` 클로저,
-- `Off`/`OffWithoutEmit` 스냅샷 순회, `Policy(emit)`
-- · state-epoch-plan.md §3/§4 — EpochMap(`Update`/`Peek`/`Refresh`/`Sync`/`TrackFrom`),
-- 캐시 카운터 쌍, `_hold`(하류→상류 강함), 구독자 weak-키
-- · source-state-plan.md — `_emitDown` 스냅샷, Observer 3-인자 + `_state`, `:With` pass-through,
-- `Source:Set` 동일값도 emit(H-68)
-- · ref-plan.md — `:Set` 값→리비전→콜백, 두 테이블 스냅샷·dedup, `WeakCallback`/`Callback`/`Uncallback`
-- 엔진 부재: mock inst = `{conn = {Connected = bool}, destroying = {}}` (H-97 mock 4종),
-- `onDestroying`은 `inst.destroying` 배열에 콜백을 넣는 것으로 흉내낸다.
-- ⚠️ 이 파일은 문서가 아니라 전사물이다 — 여기서 나온 결과는 원문과 줄 단위 대조 뒤에만 발견이다.
local M = {}
local function weakK() return setmetatable({}, {__mode = "k"}) end
M.weakK = weakK
-- ── EpochMap (state-epoch-plan.md §3) ───────────────────────────────────
local EpochMap = {}; EpochMap.__index = EpochMap
function M.EpochMap() return setmetatable({map = weakK()}, EpochMap) end
local function isEpochLike(x) return type(x) == "table" and x.Revision ~= nil end
local function unfold(from)
if isEpochLike(from) then return {from} end
local t = {}; for e in pairs(from) do t[#t+1] = e end; return t
end
M.unfold = unfold
function EpochMap:Update(from)
local changed = false
for _, e in ipairs(unfold(from)) do
if self.map[e] ~= e.Revision then changed = true; self.map[e] = e.Revision end
end
return changed
end
function EpochMap:Peek(from) -- H-72: 비교만, 덮지 않음
for _, e in ipairs(unfold(from)) do if self.map[e] ~= e.Revision then return true end end
return false
end
function EpochMap:Sync(from) for _, e in ipairs(unfold(from)) do self.map[e] = e.Revision end end
function EpochMap:Refresh()
local changed = false
for e, rev in pairs(self.map) do
if e.Revision ~= rev then changed = true; self.map[e] = e.Revision end
end
return changed
end
function EpochMap:TrackFrom(other) for e in pairs(other.map) do self.map[e] = e.Revision end end
function EpochMap:_keys() local t = {}; for e in pairs(self.map) do t[#t+1] = e end; return t end
-- ── 판별자 (brand-plan.md의 isX — 여기선 메타테이블 항등으로 흉내) ──────
local Source, State, GateNode, Observer, Ref, EffectHandle = {}, {}, {}, {}, {}, {}
Source.__index, State.__index, GateNode.__index, Observer.__index, Ref.__index, EffectHandle.__index =
Source, State, GateNode, Observer, Ref, EffectHandle
local function isSource(x) return getmetatable(x) == Source end
local function isState(x) local mt = getmetatable(x); return mt == Source or mt == State or mt == GateNode end
local function isObserver(x) return getmetatable(x) == Observer end
local function isRef(x) return getmetatable(x) == Ref end
local function isEffect(x) return getmetatable(x) == EffectHandle end
local function isEpoch(x) return isSource(x) or isRef(x) end
M.isSource, M.isState, M.isObserver, M.isRef, M.isEffect, M.isEpoch =
isSource, isState, isObserver, isRef, isEffect, isEpoch
-- ── 생명주기 (lifecycle-pattern.md (1)) — mock inst ────────────────────
local BindData = weakK() -- value -> {gchold=, gcconn=}
M.BindData = BindData
local function isBoundAlive(value)
local bd = BindData[value]
if bd and bd.gcconn ~= nil and bd.gcconn.Connected then return true end
if isObserver(value) or isEffect(value) then return value.Subscribed == true end
return false
end
local function canBound(value) return not isBoundAlive(value) end
local function canExecute(value) return isBoundAlive(value) end
M.canBound, M.canExecute = canBound, canExecute
function M.bindLifetime(inst, value)
if not canBound(value) then
local isGlobal = (isObserver(value) or isEffect(value)) and value.Subscribed == true
error(if isGlobal then "already subscribed" else "already bound to another instance", 2)
end
inst.gchold = inst.gchold or {}
inst.gchold[value] = true
BindData[value] = {gchold = inst.gchold, gcconn = inst.conn}
if isEffect(value) then value:_bindDestroying(inst) end
end
function M.unbindLifetime(value)
if isEffect(value) then value:_unbindDestroying() end
local bd = BindData[value]
if bd and bd.gchold then bd.gchold[value] = nil end
BindData[value] = nil
end
-- mock 엔진: Instance + Destroying 훅(주입 op `onDestroying`)
function M.newInst(name) return {name = name, conn = {Connected = true}, destroying = {}} end
function M.onDestroying(inst, fn)
local conn = {Connected = true}
conn.Disconnect = function(self) self.Connected = false end
inst.destroying[#inst.destroying + 1] = {conn = conn, fn = fn}
return conn
end
-- Immediate 모드 흉내: Destroying 콜백을 동기로 돌린 뒤 커넥션을 끊는다(Roblox 순서).
function M.destroyInst(inst)
local snap = {}
for _, e in ipairs(inst.destroying) do snap[#snap+1] = e end
for _, e in ipairs(snap) do if e.conn.Connected then e.fn() end end
inst.conn.Connected = false
for _, e in ipairs(inst.destroying) do e.conn.Connected = false end
inst.destroying = {}
end
-- ── 전파 루프 (source-state-plan.md "전파 루프 — 확정 의사코드") ──────
local function emitDown(self, from)
local snap = {}
for sub in pairs(self._subs) do snap[#snap+1] = sub end -- H-23 스냅샷
for _, sub in ipairs(snap) do
if isState(sub) then
sub:_receive(from)
elseif canExecute(sub) then
sub.fn(sub._state, sub, from) -- (리시버 State, Observer 자신, 출처) H-109
end
end
end
M.emitDown = emitDown
-- ── Source ─────────────────────────────────────────────────────────────
function M.Source(v)
return setmetatable({_value = v, Revision = 0, _subs = weakK()}, Source)
end
function Source:Get() return self._value end
function Source:Set(v) -- H-68: 동일값도 항상 리비전 갱신 + emit
self._value = v
self.Revision = bit32.bnot(-self.Revision)
emitDown(self, self)
return self
end
function Source:Emit()
self.Revision = bit32.bnot(-self.Revision)
emitDown(self, self)
return self
end
function Source:_track(map) map:Sync(self) end
function Source:_addSub(s) self._subs[s] = true end
-- ── State (Compute / With) — 캐시 카운터 쌍 (state-epoch-plan.md §4) ──
local function seedValueMap(self, deps)
for _, d in ipairs(deps) do d:_addSub(self); d:_track(self.valueEpochMap) end
end
local function newNode(deps, fn)
local self = setmetatable({
fn = fn, _hold = deps, _subs = weakK(),
valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(),
cacheTargetCount = 0, cacheCurrCount = nil, cache = nil, recomputes = 0,
}, State)
seedValueMap(self, deps)
return self
end
function M.Compute(deps, fn) return newNode(deps, fn) end
function State:_track(map) map:TrackFrom(self.valueEpochMap) end
function State:_addSub(s) self._subs[s] = true end
function State:_invalidate() self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount) end
function State:_receive(from)
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged = self.emitEpochMap:Update(from)
if valueChanged then self:_invalidate() end
if valueChanged or emitChanged then emitDown(self, from) end
end
function State:_recompute()
local target = self.cacheTargetCount
self.recomputes += 1
-- ⚠️ [전사 정정] `fn(self, previous?, ...deps)`의 `self`는 **리시버 State의 lazy 핸들**이지 결과 노드가
-- 아니다(source-state-plan.md "self 인자도 lazy 핸들로 통일"). 7·9라운드 전사물은 결과 노드를 넘겨
-- `s:Get()`이 자기 자신을 재귀하게 돼 있었다(그쪽 테스트는 첫 인자를 안 써서 안 드러났다).
self.cache = self.fn(self._hold[1], self.cache, table.unpack(self._hold, 2))
self.cacheCurrCount = target
for _, d in ipairs(self._hold) do d:_track(self.valueEpochMap) end
end
function State:Get()
if self.cacheCurrCount ~= self.cacheTargetCount then
self:_recompute()
elseif self.valueEpochMap:Refresh() then -- 순회: 값만 앞당기고 통지는 안 한다
self:_invalidate()
self:_recompute()
end
return self.cache
end
-- 파생 메소드(콜론) — Source/State/GateNode 공용
local function Compute(self, fn, ...)
local deps = {self}
for i = 1, select("#", ...) do deps[#deps+1] = (select(i, ...)) end
return newNode(deps, fn)
end
local function With(self, ...)
local deps = {self}
for i = 1, select("#", ...) do deps[#deps+1] = (select(i, ...)) end
return newNode(deps, function(s) return s:Get() end) -- pass-through(값은 self 그대로)
end
-- ── Observer + 네 진입점 레지스트리 (lifecycle-pattern.md (2)) ──────────
local Subscribed = {}
local WeakSubscribed = weakK()
M.Subscribed, M.WeakSubscribed = Subscribed, WeakSubscribed
local function newObserver(state, fn)
fn = fn or function(targetState) targetState:Get() end -- H-61
local self = setmetatable({fn = fn, _state = state}, Observer) -- H-110 강참조
state:_addSub(self)
fn(state, self, nil) -- 등록 즉시 1회(설치 발화, emitFrom = nil)
return self
end
function Observer:WeakSubscribe()
if not canBound(self) then
error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2)
end
self.Subscribed = true -- H-111
WeakSubscribed[self] = true
return self
end
function Observer:WeakUnsubscribe()
if Subscribed[self] ~= nil then error("subscribed strongly; use :Unsubscribe()", 2) end
WeakSubscribed[self] = nil -- 관대(H-133): 없어도 그냥 지나간다
self.Subscribed = false
return self
end
function Observer:Subscribe()
self:WeakSubscribe() -- ⚠️ 콜론 위임 그대로(H-149 판단 대기) — level 2가 이 줄을 가리킨다
Subscribed[self] = true
return self
end
function Observer:Unsubscribe()
if Subscribed[self] == nil then error("not subscribed strongly; use :WeakUnsubscribe()", 2) end
Subscribed[self] = nil
return self:WeakUnsubscribe()
end
-- ── GateNode (gate-plan.md "GateNode 조립") ─────────────────────────────
function GateNode:_track(map) map:TrackFrom(self.valueEpochMap) end
function GateNode:_addSub(s) self._subs[s] = true end
function GateNode:_invalidate() self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount) end
function GateNode:_flush(commit)
if next(self._withheld) == nil then return false end -- (1) 빈 배치 얼리리턴(8번)
local batch = self._withheld
self._withheld = weakK() -- (2) 새 weak 테이블(H-9)
self.flushes += 1
if commit == false then return true end -- 버리기(H-55)
self.emitEpochMap:Sync(batch) -- (3) 전파 앞
emitDown(self, batch) -- (4)
return true
end
function GateNode:_receive(from)
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged = self.emitEpochMap:Peek(from) -- Update가 아니라 Peek
if valueChanged then self:_invalidate() end
if not (valueChanged or emitChanged) then return end -- 규칙 3
for _, e in ipairs(unfold(from)) do self._withheld[e] = true end
self.onUpstreamEmit()
end
function GateNode:Get()
if self.cacheCurrCount ~= self.cacheTargetCount then
local target = self.cacheTargetCount
self.cache = self._hold[1]:Get(); self.cacheCurrCount = target
self._hold[1]:_track(self.valueEpochMap)
elseif self.valueEpochMap:Refresh() then
self:_invalidate(); local target = self.cacheTargetCount
self.cache = self._hold[1]:Get(); self.cacheCurrCount = target
self._hold[1]:_track(self.valueEpochMap)
end
return self.cache
end
local function Gate(self, setup)
local node = setmetatable({
_hold = {self}, _subs = weakK(),
valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(),
cacheTargetCount = 0, cacheCurrCount = nil, cache = nil,
_withheld = weakK(), flushes = 0,
}, GateNode)
self:_addSub(node); self:_track(node.valueEpochMap)
node.onUpstreamEmit = setup(function(commit) return node:_flush(commit) end)
return node
end
-- ── Blocker (blocker-plan.md + H-63) ────────────────────────────────────
local Blocker = {}; Blocker.__index = Blocker
function M.Blocker() return setmetatable({IsBlocked = false, handles = weakK()}, Blocker) end
function Blocker:IsOn() return self.IsBlocked end
function Blocker:On() self.IsBlocked = true; return self end
function Blocker:_run(emit)
local snap = {}; for h in pairs(self.handles) do snap[#snap+1] = h end -- H-63 (3) 스냅샷
for _, h in ipairs(snap) do h(emit) end
end
function Blocker:Off() self.IsBlocked = false; self:_run(true); return self end
function Blocker:OffWithoutEmit() self.IsBlocked = false; self:_run(false); return self end
function Blocker:Policy(emit) -- blocker:Policy(emit) -> onUpstreamEmit
local function handle(doEmit)
if doEmit then emit() else emit(false) end -- OffWithoutEmit = 흡수 집합 버리기(H-55)
end
self.handles[handle] = true -- H-63 (1) weak-키 셋
return function() -- H-63 (2) 강한 주인 = 이 클로저(upvalue `handle`)
if handle == nil then return end
if not self.IsBlocked then emit() end
end
end
local function Block(self, blocker)
return Gate(self, function(emit) return blocker:Policy(emit) end)
end
for _, T in ipairs({Source, State, GateNode}) do
T.Compute, T.With, T.Gate, T.Block, T.Observer = Compute, With, Gate, Block, newObserver
end
M.Gate = function(up, setup) return Gate(up, setup) end
-- ── Ref (ref-plan.md `:Set` 순서 절 + WeakCallback 항목) ──────────────
function M.Ref(default)
return setmetatable({Value = default, Revision = 0, Callbacks = {}, WeakCallbacks = weakK()}, Ref)
end
function Ref:WeakCallback(fn)
self.WeakCallbacks[fn] = true
fn(self.Value, self) -- 등록 즉시 1회, nil이어도 그대로(H-120)
return self
end
function Ref:Callback(fn)
self:WeakCallback(fn)
self.Callbacks[fn] = true -- GC 킵 한 겹
return self
end
function Ref:Uncallback(fn)
self.Callbacks[fn] = nil; self.WeakCallbacks[fn] = nil
return self
end
function Ref:Set(value)
self.Value = value -- (1)
self.Revision = bit32.bnot(-self.Revision) -- (2) 콜백보다 앞
local snapshot = {} -- (3)
for k in pairs(self.Callbacks) do table.insert(snapshot, k) end
for k in pairs(self.WeakCallbacks) do
if self.Callbacks[k] == nil then table.insert(snapshot, k) end
end
for _, k in ipairs(snapshot) do
if self.Callbacks[k] == nil and self.WeakCallbacks[k] == nil then continue end
if type(k) == "thread" then
self.Callbacks[k] = nil
coroutine.resume(k, self)
else
k(value, self) -- 값 + Ref 자신(= Epoch)
end
end
return self
end
-- ── Effect (effect-plan.md 생성자 / 훅 / Rerun / 네 진입점) ───────────
M.effectTrace = nil -- 스파이크가 함수를 넣으면 fire 가드 판정을 기록한다
function M.Effect(fn, ...)
local self = setmetatable({fn = fn, _deps = {}, _epochs = M.EpochMap()}, EffectHandle)
self._blocker = M.Blocker()
local seen = {}
for i = 1, select("#", ...) do
local d = select(i, ...)
if d == nil then error("Effect: dep #" .. i .. " is nil", 2) end
if not (isState(d) or isSource(d) or isRef(d)) then
error("Effect: dep #" .. i .. " is not a State/Source/Ref", 2)
end
if not seen[d] then seen[d] = true end
end
self._blocker:On()
local function fire(from) -- 공통 본문
if not canExecute(self) then
if M.effectTrace then M.effectTrace(self, "drop:canExecute", from) end
return
end
if self._blocker:IsOn() then
if M.effectTrace then M.effectTrace(self, "drop:blocker", from) end
return
end
if self._epochs:Update(from) then self:Rerun() end
end
local function onRefFire(_, ref) fire(ref) end
local function onStateFire(_, _, from) fire(from) end
for d in pairs(seen) do
if isRef(d) then
self._deps[d] = onRefFire
d:WeakCallback(onRefFire)
else
local o = d:Observer(onStateFire)
self._deps[d] = o
o:WeakSubscribe()
end
if isEpoch(d) then self._epochs:Sync(d) else self._epochs:TrackFrom(d.valueEpochMap) end
end
self._blocker:OffWithoutEmit()
self:Rerun() -- (2) 설치 — 생성 즉시 1회
return self
end
function EffectHandle:_bindDestroying(inst)
self:_unbindDestroying()
self._destroyConn = M.onDestroying(inst, function()
self:_unbindDestroying()
self:_consumeCleanup()
end)
local depsChanged = self._epochs:Refresh() -- Refresh **먼저**
if not self._installed or depsChanged then self:Rerun() end
end
function EffectHandle:_unbindDestroying()
if self._destroyConn then self._destroyConn:Disconnect(); self._destroyConn = nil end
end
function EffectHandle:_consumeCleanup()
local c = self._cleanup; self._cleanup = nil; self._installed = false
if c then c() end
end
function EffectHandle:Rerun()
if self._running then self._pending = true; return end
self._running = true
repeat
self._pending = false
self:_consumeCleanup()
local wasAlive = canExecute(self)
local c = self.fn(self)
if wasAlive and not canExecute(self) then -- H-143: 이 실행 중에 죽었다
if c then c() end
self._pending = false
else
self._cleanup = c
self._installed = true
end
until not self._pending
self._running = false
end
-- 네 진입점 — EffectHandle 자기 것(H-144 (b)). 공유는 레지스트리 둘 + canBound뿐.
local function resubscribeTail(self)
local depsChanged = self._epochs:Refresh()
if not self._installed or depsChanged then self:Rerun() end
end
function EffectHandle:WeakSubscribe()
if not canBound(self) then
error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2)
end
self.Subscribed = true
WeakSubscribed[self] = true
resubscribeTail(self)
return self
end
function EffectHandle:Subscribe()
if not canBound(self) then
error(if self.Subscribed then "already subscribed" else "already bound to an Instance", 2)
end
self.Subscribed = true
WeakSubscribed[self] = true
Subscribed[self] = true -- 강한 킵이 선 **뒤에** 꼬리
resubscribeTail(self)
return self
end
function EffectHandle:WeakUnsubscribe() -- 관대(H-133) — cleanup 안 건드림
if Subscribed[self] ~= nil then error("subscribed strongly; use :Unsubscribe()", 2) end
WeakSubscribed[self] = nil
self.Subscribed = false
return self
end
function EffectHandle:Unsubscribe()
if Subscribed[self] == nil then error("not subscribed strongly; use :WeakUnsubscribe()", 2) end
Subscribed[self] = nil
WeakSubscribed[self] = nil
self.Subscribed = false
self:_consumeCleanup()
return self
end
-- 상태 스냅샷(스파이크용)
function M.effectState(e)
return ("installed=%s cleanup=%s Subscribed=%s reg[S]=%s reg[W]=%s canExecute=%s running=%s pending=%s"):format(
tostring(e._installed == true), tostring(e._cleanup ~= nil), tostring(e.Subscribed == true),
tostring(Subscribed[e] ~= nil), tostring(WeakSubscribed[e] ~= nil), tostring(canExecute(e)),
tostring(e._running == true), tostring(e._pending == true))
end
return M

View file

@ -0,0 +1,307 @@
--!nocheck
-- 9라운드 참조 구현 — 2026-08-26 `9dd8213` 이후 계약을 base/에서 옮긴 전사물.
-- 옮긴 원문: source-state-plan.md(전파 루프 `_emitDown`, Observer 3-인자, `_state`),
-- lifecycle-pattern.md((1) isBoundAlive/bindLifetime/unbindLifetime, (2) Weak/Subscribe 4진입점),
-- ref-plan.md(`:Set` 순서 값→리비전→콜백, Callbacks/WeakCallbacks 스냅샷·dedup, Uncallback),
-- effect-plan.md(생성자 fire/onRefFire/onStateFire, _bindDestroying, Rerun, Subscribe/Unsubscribe),
-- state-epoch-plan.md(EpochMap, 캐시 카운터 쌍), blocker-plan.md(On/Off/OffWithoutEmit/IsOn).
-- 엔진 부재: bindLifetime은 mock inst `{conn = {Connected = bool}}`의 gcconn을 복사한다(H-97 mock 4종).
local M = {}
local function weakK() return setmetatable({}, {__mode = "k"}) end
-- ── EpochMap ───────────────────────────────────────────────────────────
local EpochMap = {}; EpochMap.__index = EpochMap
function M.EpochMap() return setmetatable({map = weakK()}, EpochMap) end
local function unfold(from)
if from.Revision ~= nil then return {from} end
local t = {}; for e in pairs(from) do t[#t+1] = e end; return t
end
function EpochMap:Update(from)
local changed = false
for _, e in ipairs(unfold(from)) do
if self.map[e] ~= e.Revision then changed = true; self.map[e] = e.Revision end
end
return changed
end
function EpochMap:Peek(from)
for _, e in ipairs(unfold(from)) do if self.map[e] ~= e.Revision then return true end end
return false
end
function EpochMap:Sync(from) for _, e in ipairs(unfold(from)) do self.map[e] = e.Revision end end
function EpochMap:Refresh()
local changed = false
for e, rev in pairs(self.map) do
if e.Revision ~= rev then changed = true; self.map[e] = e.Revision end
end
return changed
end
function EpochMap:TrackFrom(other) for e in pairs(other.map) do self.map[e] = e.Revision end end
-- ── 판별자 ─────────────────────────────────────────────────────────────
local Source, State, Observer, Ref, EffectHandle = {}, {}, {}, {}, {}
Source.__index, State.__index, Observer.__index, Ref.__index, EffectHandle.__index =
Source, State, Observer, Ref, EffectHandle
local function isSource(x) return getmetatable(x) == Source end
local function isState(x) return getmetatable(x) == Source or getmetatable(x) == State end
local function isObserver(x) return getmetatable(x) == Observer end
local function isRef(x) return getmetatable(x) == Ref end
local function isEffect(x) return getmetatable(x) == EffectHandle end
local function isEpoch(x) return isSource(x) or isRef(x) end
M.isSource, M.isState, M.isObserver, M.isRef, M.isEffect, M.isEpoch =
isSource, isState, isObserver, isRef, isEffect, isEpoch
-- ── 생명주기 (lifecycle-pattern.md (1)) — mock inst = {conn = {Connected = true}} ──
local BindData = weakK() -- value -> {gchold=, gcconn=}
local function isBoundAlive(value)
local bd = BindData[value]
if bd and bd.gcconn ~= nil and bd.gcconn.Connected then return true end
if isObserver(value) or isEffect(value) then return value.Subscribed == true end
return false
end
local function canBound(value) return not isBoundAlive(value) end
local function canExecute(value) return isBoundAlive(value) end
M.canBound, M.canExecute = canBound, canExecute
function M.bindLifetime(inst, value)
if not canBound(value) then
local isGlobal = (isObserver(value) or isEffect(value)) and value.Subscribed == true
error(if isGlobal then "already subscribed" else "already bound to another instance", 2)
end
inst.gchold = inst.gchold or {}
inst.gchold[value] = true
BindData[value] = {gchold = inst.gchold, gcconn = inst.conn}
if isEffect(value) then value:_bindDestroying(inst) end
end
function M.unbindLifetime(value)
if isEffect(value) then value:_unbindDestroying() end
local bd = BindData[value]
if bd and bd.gchold then bd.gchold[value] = nil end
BindData[value] = nil
end
function M.newInst(name) return {name = name, conn = {Connected = true}, destroying = {}} end
function M.destroyInst(inst)
inst.conn.Connected = false
for _, fn in ipairs(inst.destroying) do fn() end
end
-- ── 전파 루프 (source-state-plan.md "전파 루프 — 확정 의사코드") ──────
local function emitDown(self, from)
local snap = {}
for sub in pairs(self._subs) do snap[#snap+1] = sub end -- H-23 스냅샷
for _, sub in ipairs(snap) do
if isState(sub) then
sub:_receive(from)
elseif canExecute(sub) then
sub.fn(sub._state, sub, from) -- ⭐ (리시버 State, Observer 자신, 출처) H-109
end
end
end
-- ── Source ─────────────────────────────────────────────────────────────
function M.Source(v)
return setmetatable({_value = v, Revision = 0, _subs = weakK()}, Source)
end
function Source:Get() return self._value end
function Source:Set(v)
self._value = v
self.Revision = bit32.bnot(-self.Revision)
emitDown(self, self)
return self
end
function Source:_track(map) map:Sync(self) end
function Source:_addSub(s) self._subs[s] = true end
-- ── State (Compute) — 캐시 카운터 쌍 (state-epoch-plan.md §4, H-85) ────
function M.Compute(deps, fn)
local self = setmetatable({
fn = fn, _hold = deps, _subs = weakK(),
valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(),
cacheTargetCount = 0, cacheCurrCount = nil, cache = nil,
}, State)
for _, d in ipairs(deps) do d:_addSub(self); d:_track(self.valueEpochMap) end
return self
end
function State:_track(map) map:TrackFrom(self.valueEpochMap) end
function State:_addSub(s) self._subs[s] = true end
function State:_receive(from)
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged = self.emitEpochMap:Update(from)
if valueChanged then self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount) end
if valueChanged or emitChanged then emitDown(self, from) end
end
function State:Get()
if self.cacheCurrCount ~= self.cacheTargetCount or self.valueEpochMap:Refresh() then
if self.cacheCurrCount == self.cacheTargetCount then
self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount)
end
local target = self.cacheTargetCount
self.cache = self.fn(self, self.cache)
self.cacheCurrCount = target
for _, d in ipairs(self._hold) do d:_track(self.valueEpochMap) end
end
return self.cache
end
Source.Compute = function(self, fn) return M.Compute({self}, fn) end
State.Compute = Source.Compute
-- ── Observer + 4진입점 레지스트리 (lifecycle-pattern.md (2)) ───────────
local Subscribed = {}
local WeakSubscribed = weakK()
local function newObserver(state, fn)
fn = fn or function(targetState) targetState:Get() end -- H-61
local self = setmetatable({fn = fn, _state = state}, Observer) -- H-110 강참조
state:_addSub(self)
fn(state, self, nil) -- 등록 즉시 1회(설치 발화, emitFrom = nil)
return self
end
Source.Observer, State.Observer = newObserver, newObserver
function Observer:WeakSubscribe()
if not canBound(self) then
error(if self.Subscribed then "already subscribed" else "already bound to an instance", 2)
end
self.Subscribed = true -- H-111
WeakSubscribed[self] = true
return self
end
function Observer:WeakUnsubscribe()
if Subscribed[self] ~= nil then error("subscribed strongly; use :Unsubscribe()", 2) end
WeakSubscribed[self] = nil
self.Subscribed = false
return self
end
function Observer:Subscribe()
self:WeakSubscribe()
Subscribed[self] = true
return self
end
function Observer:Unsubscribe()
if Subscribed[self] == nil then error("not subscribed strongly; use :WeakUnsubscribe()", 2) end
Subscribed[self] = nil
return self:WeakUnsubscribe()
end
-- ── Ref (ref-plan.md `:Set` 순서 절 + WeakCallback 항목) ──────────────
function M.Ref(default)
return setmetatable({Value = default, Revision = 0, Callbacks = {}, WeakCallbacks = weakK()}, Ref)
end
function Ref:WeakCallback(fn)
self.WeakCallbacks[fn] = true
fn(self.Value, self) -- 등록 즉시 1회, nil이어도 그대로(H-120)
return self
end
function Ref:Callback(fn)
self:WeakCallback(fn)
self.Callbacks[fn] = true -- GC 킵 한 겹
return self
end
function Ref:Uncallback(fn)
self.Callbacks[fn] = nil; self.WeakCallbacks[fn] = nil
return self
end
function Ref:Set(value)
self.Value = value -- (1)
self.Revision = bit32.bnot(-self.Revision) -- (2) 콜백보다 앞
local snapshot = {} -- (3)
for k in pairs(self.Callbacks) do table.insert(snapshot, k) end
for k in pairs(self.WeakCallbacks) do
if self.Callbacks[k] == nil then table.insert(snapshot, k) end
end
for _, k in ipairs(snapshot) do
if self.Callbacks[k] == nil and self.WeakCallbacks[k] == nil then continue end
if type(k) == "thread" then
self.Callbacks[k] = nil
coroutine.resume(k, self)
else
k(value, self) -- ⭐ 값 + Ref 자신(= Epoch)
end
end
return self
end
-- ── Effect (effect-plan.md 생성자/훅/Rerun/Subscribe) ─────────────────
function M.Effect(fn, ...)
local self = setmetatable({fn = fn, _deps = {}, _epochs = M.EpochMap()}, EffectHandle)
self._blocker = M.Blocker()
local seen = {}
for i = 1, select("#", ...) do
local d = select(i, ...)
if d == nil then error("Effect: dep #" .. i .. " is nil", 2) end
if not (isState(d) or isSource(d) or isRef(d)) then
error("Effect: dep #" .. i .. " is not a State/Source/Ref", 2)
end
if not seen[d] then seen[d] = true end
end
self._blocker:On()
local function fire(from)
if not canExecute(self) then return end
if self._blocker:IsOn() then return end
if self._epochs:Update(from) then self:Rerun() end
end
local function onRefFire(_, ref) fire(ref) end
local function onStateFire(_, _, from) fire(from) end
for d in pairs(seen) do
if isRef(d) then
self._deps[d] = onRefFire
d:WeakCallback(onRefFire)
else
local o = d:Observer(onStateFire)
self._deps[d] = o
o:WeakSubscribe()
end
if isEpoch(d) then self._epochs:Sync(d) else self._epochs:TrackFrom(d.valueEpochMap) end
end
self._blocker:OffWithoutEmit()
self:Rerun()
return self
end
function EffectHandle:_bindDestroying(inst)
self:_unbindDestroying()
local conn = {Connected = true}
table.insert(inst.destroying, function()
if not conn.Connected then return end
self:_unbindDestroying(); self:_consumeCleanup()
end)
self._destroyConn = conn
local depsChanged = self._epochs:Refresh()
if not self._installed or depsChanged then self:Rerun() end
end
function EffectHandle:_unbindDestroying()
if self._destroyConn then self._destroyConn.Connected = false; self._destroyConn = nil end
end
function EffectHandle:_consumeCleanup()
local c = self._cleanup; self._cleanup = nil; self._installed = false
if c then c() end
end
function EffectHandle:Rerun()
if self._running then self._pending = true; return end
self._running = true
repeat
self._pending = false
self:_consumeCleanup()
self._cleanup = self.fn(self)
self._installed = true
until not self._pending
self._running = false
end
-- Effect의 Subscribe/Unsubscribe — lifecycle-pattern의 Observer 4진입점을 그대로 재사용
-- (핸들 자신만 등록, 내부 Observer는 건드리지 않는다 — H-59)
EffectHandle.WeakSubscribe, EffectHandle.WeakUnsubscribe = Observer.WeakSubscribe, Observer.WeakUnsubscribe
EffectHandle.Subscribe = Observer.Subscribe
function EffectHandle:Unsubscribe()
Observer.Unsubscribe(self) -- 게이트 + 레지스트리 + .Subscribed = false
self:_consumeCleanup() -- 마지막 cleanup 정확히 1회
return self
end
-- ── Blocker ─────────────────────────────────────────────────────────────
local Blocker = {}; Blocker.__index = Blocker
function M.Blocker() return setmetatable({IsBlocked = false, handles = weakK()}, Blocker) end
function Blocker:IsOn() return self.IsBlocked end
function Blocker:On() self.IsBlocked = true; return self end
function Blocker:_run(emit)
local snap = {}; for h in pairs(self.handles) do snap[#snap+1] = h end
for _, h in ipairs(snap) do h(emit) end
end
function Blocker:Off() self.IsBlocked = false; self:_run(true); return self end
function Blocker:OffWithoutEmit() self.IsBlocked = false; self:_run(false); return self end
return M

View file

@ -0,0 +1,26 @@
--!nocheck
-- H-124 후보: recompute가 커서 i == N을 처리하는 중 사용자 코드가 요소를 제거하면
-- `local v = bk.lengthList[i]`가 되감기 판정보다 먼저 nil을 읽는다.
local q = require("./core9"); local D = require("./dispatch9")
local inst = q.newInst("inst")
local function L(n) return D.newSlot(n, {{name = n .. "_x"}}) end
local s1, s2, s3 = L("s1"), L("s2"), L("s3")
local O = D.newSlot("O", { s1, s2, s3 })
D.mountTop(inst, O)
print("초기: s3.Offset", s3.Offset:Get(), "O.Length", O.Length:Get())
-- s3.Offset(마지막 자리)을 보는 사용자 Observer가 그 통지 도중 O:Remove(1)
local fired = false
local w = s3.Offset:Observer(function(_, _, from)
if from == nil or fired then return end
fired = true
print(" [watcher] s3.Offset ->", s3.Offset:Get(), "→ O:Remove(1) 호출")
D.rawRemove(O, 1)
print(" [watcher] rawRemove 반환. N =", D.getBookkeeping(O).N, "offsetSetUpTo =", D.getBookkeeping(O).offsetSetUpTo)
end)
q.bindLifetime(inst, w)
D.log = {}
local ok, err = pcall(function() D.rawAdd(s1, {name="s1_y"}) end) -- s1 길이 1→2, s2·s3 offset이 밀린다 → i=3에서 watcher
print("rawAdd(s1, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err))
local bk = D.getBookkeeping(O)
print("O.Length", O.Length:Get(), "| N", bk.N, "| s2.Offset", s2.Offset:Get(), "(기대 0) | s3.Offset", s3.Offset:Get(), "(기대 1)")
print("log:", table.concat(D.log, " "))

View file

@ -0,0 +1,26 @@
--!nocheck
-- H-124 후보: recompute가 커서 i == N을 처리하는 중 사용자 코드가 요소를 제거하면
-- `local v = bk.lengthList[i]`가 되감기 판정보다 먼저 nil을 읽는다.
local q = require("./core10"); local D = require("./dispatch10")
local inst = q.newInst("inst")
local function L(n) return D.newSlot(n, {{name = n .. "_x"}}) end
local s1, s2, s3 = L("s1"), L("s2"), L("s3")
local O = D.newSlot("O", { s1, s2, s3 })
D.mountTop(inst, O)
print("초기: s3.Offset", s3.Offset:Get(), "O.Length", O.Length:Get())
-- s3.Offset(마지막 자리)을 보는 사용자 Observer가 그 통지 도중 O:Remove(1)
local fired = false
local w = s3.Offset:Observer(function(_, _, from)
if from == nil or fired then return end
fired = true
print(" [watcher] s3.Offset ->", s3.Offset:Get(), "→ O:Remove(1) 호출")
D.rawRemove(O, 1)
print(" [watcher] rawRemove 반환. N =", D.getBookkeeping(O).N, "offsetSetUpTo =", D.getBookkeeping(O).offsetSetUpTo)
end)
q.bindLifetime(inst, w)
D.log = {}
local ok, err = pcall(function() D.rawAdd(s1, {name="s1_y"}) end) -- s1 길이 1→2, s2·s3 offset이 밀린다 → i=3에서 watcher
print("rawAdd(s1, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err))
local bk = D.getBookkeeping(O)
print("O.Length", O.Length:Get(), "| N", bk.N, "| s2.Offset", s2.Offset:Get(), "(기대 0) | s3.Offset", s3.Offset:Get(), "(기대 1)")
print("log:", table.concat(D.log, " "))

View file

@ -0,0 +1,26 @@
--!nocheck
-- 4차 code-review의 2-연산 경로(Remove 뒤 Add, 한 콜백 안)를 두 필드 분리 위에서 실제 값으로.
local q = require("./core9"); local D = require("./dispatch9")
local inst = q.newInst("inst")
local s = {}; for i = 1, 4 do s[i] = D.newSlot("s" .. i, {{name = "x" .. i}}) end
local O = D.newSlot("O", { s[1], s[2], s[3], s[4] })
D.mountTop(inst, O)
print("초기 offsets:", s[1].Offset:Get(), s[2].Offset:Get(), s[3].Offset:Get(), s[4].Offset:Get(), "| O.Length", O.Length:Get())
local s5 = D.newSlot("s5", {{name = "x5"}})
local fired = false
local w = s[3].Offset:Observer(function(_, _, from)
if from == nil or fired then return end
fired = true
local bk = D.getBookkeeping(O)
print(" [watcher@i=3] Remove(1): before setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo)
D.rawRemove(O, 1)
print(" [watcher] after Remove: N", bk.N, "setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo)
D.rawAdd(O, s5)
print(" [watcher] after Add: N", bk.N, "setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo, "s5.Offset", s5.Offset:Get())
end)
q.bindLifetime(inst, w)
D.log = {}
local ok, err = pcall(function() D.rawAdd(s[2], {name="x2b"}) end)
print("rawAdd(s2, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err))
print("최종 offsets: s2", s[2].Offset:Get(), "(기대 0) s3", s[3].Offset:Get(), "(기대 2) s4", s[4].Offset:Get(), "(기대 3) s5", s5.Offset:Get(), "(기대 4) | O.Length", O.Length:Get(), "(기대 5)")
print("log:", table.concat(D.log, " "))

View file

@ -0,0 +1,26 @@
--!nocheck
-- 4차 code-review의 2-연산 경로(Remove 뒤 Add, 한 콜백 안)를 두 필드 분리 위에서 실제 값으로.
local q = require("./core10"); local D = require("./dispatch10")
local inst = q.newInst("inst")
local s = {}; for i = 1, 4 do s[i] = D.newSlot("s" .. i, {{name = "x" .. i}}) end
local O = D.newSlot("O", { s[1], s[2], s[3], s[4] })
D.mountTop(inst, O)
print("초기 offsets:", s[1].Offset:Get(), s[2].Offset:Get(), s[3].Offset:Get(), s[4].Offset:Get(), "| O.Length", O.Length:Get())
local s5 = D.newSlot("s5", {{name = "x5"}})
local fired = false
local w = s[3].Offset:Observer(function(_, _, from)
if from == nil or fired then return end
fired = true
local bk = D.getBookkeeping(O)
print(" [watcher@i=3] Remove(1): before setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo)
D.rawRemove(O, 1)
print(" [watcher] after Remove: N", bk.N, "setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo)
D.rawAdd(O, s5)
print(" [watcher] after Add: N", bk.N, "setUpTo", bk.offsetSetUpTo, "cacheValid", bk.offsetCacheValidUpTo, "s5.Offset", s5.Offset:Get())
end)
q.bindLifetime(inst, w)
D.log = {}
local ok, err = pcall(function() D.rawAdd(s[2], {name="x2b"}) end)
print("rawAdd(s2, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err))
print("최종 offsets: s2", s[2].Offset:Get(), "(기대 0) s3", s[3].Offset:Get(), "(기대 2) s4", s[4].Offset:Get(), "(기대 3) s5", s5.Offset:Get(), "(기대 4) | O.Length", O.Length:Get(), "(기대 5)")
print("log:", table.concat(D.log, " "))

View file

@ -0,0 +1,24 @@
--!nocheck
-- H-125 후보: 언마운트→다른 베이스로 재마운트 시 setOffsetSource가 S.Offset을 바꾸는 순간
-- S._baseObserver는 unbindLifetime된 상태라 canExecute가 거짓 → 두 필드가 0으로 안 내려가
-- 재마운트 꼬리 recompute(S)가 옛 offsetCache[1](옛 베이스)을 그대로 쓴다.
local q = require("./core9"); local D = require("./dispatch9")
local inst = q.newInst("inst")
local t1, t2 = D.newSlot("t1", {{name="t1x"}}), D.newSlot("t2", {{name="t2x"}})
local S = D.newSlot("S", { t1, t2 })
local O = D.newSlot("O", { {name="a"}, {name="b"}, S }) -- S는 베이스 2에서 시작
D.mountTop(inst, O)
print("1차 마운트: S.Offset", S.Offset:Get(), "(기대 2) t1", t1.Offset:Get(), "(2) t2", t2.Offset:Get(), "(3)")
local bkS = D.getBookkeeping(S)
print(" S.bk: cacheValid", bkS.offsetCacheValidUpTo, "setUpTo", bkS.offsetSetUpTo, "cache[1]", bkS.offsetCache[1])
-- O에서 S를 뗀다(rawUnmount 상당: 언마운트 + splice) — 포탈
D.rawRemove(O, 3)
print("언마운트 후: canExecute(S._baseObserver) =", q.canExecute(S._baseObserver), "| S.Offset(보존)", S.Offset:Get())
-- 다른 owner O2(베이스 0)의 1번 자리에 재마운트
local O2 = D.newSlot("O2", {})
local inst2 = q.newInst("inst2")
D.mountTop(inst2, O2)
D.rawAdd(O2, S) -- → materializeSlotTree(S, inst2, O2, 1)
print("재마운트: S.Offset", S.Offset:Get(), "(기대 0) | t1.Offset", t1.Offset:Get(), "(기대 0) | t2.Offset", t2.Offset:Get(), "(기대 1)")
print(" S.bk: cacheValid", bkS.offsetCacheValidUpTo, "setUpTo", bkS.offsetSetUpTo, "cache[1]", bkS.offsetCache[1], "(베이스 0이어야)")
if t1.Offset:Get() ~= 0 then print(" ❌ 자식 offset이 옛 베이스 위에 남았다") else print(" ✅ 정합") end

View file

@ -0,0 +1,18 @@
--!nocheck
-- H-113 양성 확인: 커서 자리 i(< N)에서 제거하면 -1 덕에 i를 재방문해 밀려 들어온 요소가 Set을 받는다.
local q = require("./core9"); local D = require("./dispatch9")
local inst = q.newInst("inst")
local s = {}; for i = 1, 4 do s[i] = D.newSlot("s" .. i, {{name = "x" .. i}}) end
local O = D.newSlot("O", { s[1], s[2], s[3], s[4] })
D.mountTop(inst, O)
local fired = false
local w = s[2].Offset:Observer(function(_, _, from)
if from == nil or fired then return end
fired = true
print(" [watcher@i=2] Remove(2) — 커서 자리 자신을 제거")
D.rawRemove(O, 2)
end)
q.bindLifetime(inst, w)
local ok, err = pcall(function() D.rawAdd(s[1], {name="x1b"}) end)
print("rawAdd(s1, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err))
print("최종: s3.Offset", s[3].Offset:Get(), "(기대 2) s4.Offset", s[4].Offset:Get(), "(기대 3) | O.Length", O.Length:Get(), "(기대 4)")

View file

@ -0,0 +1,18 @@
--!nocheck
-- H-113 양성 확인: 커서 자리 i(< N)에서 제거하면 -1 덕에 i를 재방문해 밀려 들어온 요소가 Set을 받는다.
local q = require("./core10"); local D = require("./dispatch10")
local inst = q.newInst("inst")
local s = {}; for i = 1, 4 do s[i] = D.newSlot("s" .. i, {{name = "x" .. i}}) end
local O = D.newSlot("O", { s[1], s[2], s[3], s[4] })
D.mountTop(inst, O)
local fired = false
local w = s[2].Offset:Observer(function(_, _, from)
if from == nil or fired then return end
fired = true
print(" [watcher@i=2] Remove(2) — 커서 자리 자신을 제거")
D.rawRemove(O, 2)
end)
q.bindLifetime(inst, w)
local ok, err = pcall(function() D.rawAdd(s[1], {name="x1b"}) end)
print("rawAdd(s1, child) →", if ok then "정상 종료" else "ERROR: " .. tostring(err))
print("최종: s3.Offset", s[3].Offset:Get(), "(기대 2) s4.Offset", s[4].Offset:Get(), "(기대 3) | O.Length", O.Length:Get(), "(기대 4)")

View file

@ -0,0 +1,13 @@
--!nocheck
local q = require("./core9"); local D = require("./dispatch9")
-- inst > O { a(plain), S{ t1, t2 }, b(plain) } — 형제 offset 전파 대조군
local inst = q.newInst("inst")
local t1, t2 = D.newSlot("t1", {{name="t1x"}}), D.newSlot("t2", {{name="t2x"}})
local S = D.newSlot("S", { t1, t2 })
local O = D.newSlot("O", { {name="a"}, S, {name="b"} })
D.mountTop(inst, O)
print("O.Length", O.Length:Get(), "(기대 4) | S.Offset", S.Offset:Get(), "(기대 1) | t1", t1.Offset:Get(), "(1) t2", t2.Offset:Get(), "(2) | S.Length", S.Length:Get(), "(2)")
-- t1이 커지면 t2와 b가 밀린다
t1.Length:Set(3)
print("after t1=3: S.Length", S.Length:Get(), "(기대 4) | t2.Offset", t2.Offset:Get(), "(기대 4) | O.Length", O.Length:Get(), "(기대 6)")
print("log:", table.concat(D.log, " "))

View file

@ -0,0 +1,13 @@
--!nocheck
local q = require("./core10"); local D = require("./dispatch10")
-- inst > O { a(plain), S{ t1, t2 }, b(plain) } — 형제 offset 전파 대조군
local inst = q.newInst("inst")
local t1, t2 = D.newSlot("t1", {{name="t1x"}}), D.newSlot("t2", {{name="t2x"}})
local S = D.newSlot("S", { t1, t2 })
local O = D.newSlot("O", { {name="a"}, S, {name="b"} })
D.mountTop(inst, O)
print("O.Length", O.Length:Get(), "(기대 4) | S.Offset", S.Offset:Get(), "(기대 1) | t1", t1.Offset:Get(), "(1) t2", t2.Offset:Get(), "(2) | S.Length", S.Length:Get(), "(2)")
-- t1이 커지면 t2와 b가 밀린다
t1.Length:Set(3)
print("after t1=3: S.Length", S.Length:Get(), "(기대 4) | t2.Offset", t2.Offset:Get(), "(기대 4) | O.Length", O.Length:Get(), "(기대 6)")
print("log:", table.concat(D.log, " "))

View file

@ -0,0 +1,30 @@
--!nocheck
-- spliceArraysUp이 비운 자리(index)의 observers/tokens를 명시적으로 nil로 두지 않으면?
-- (요구 목록은 lengthList=0/sourceList=None 자리표시자만 말한다)
local q = require("./core9"); local D = require("./dispatch9")
local function run(vacate)
local inst = q.newInst("inst")
local A, B = D.newSlot("A", {{name="a"}}), D.newSlot("B", {{name="b"}})
local O = D.newSlot("O", { A, B })
D.mountTop(inst, O)
local bk = D.getBookkeeping(O)
-- 손으로 spliceArraysUp(O, 1)을 "복사 루프" 방식으로 흉내: i→i+1 복사, 비운 자리는 vacate 여부에 따라
local N = bk.N
for i = N, 1, -1 do
bk.lengthList[i+1] = bk.lengthList[i]; bk.sourceList[i+1] = bk.sourceList[i]
bk.observers[i+1] = bk.observers[i]; bk.tokens[i+1] = bk.tokens[i]
bk.indexOfToken[bk.tokens[i+1]] = i+1
end
bk.lengthList[1] = 0; bk.sourceList[1] = D.None
if vacate then bk.observers[1] = nil; bk.tokens[1] = nil end
bk.N = N + 1
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, 0); bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, 0)
table.insert(O._elements, 1, {name = "new"})
D.setOffsetSource(O, 1, D.None)
D.setLength(O, 1, 1, inst) -- oldObserver가 있으면 unbindLifetime한다
-- 이제 A(2번 자리)의 길이가 바뀌면 B가 밀려야 한다
A.Length:Set(3)
print(("[vacate=%s] A.Length=3 후 B.Offset = %d (기대 4) | canExecute(observers[2]) = %s | indexOfToken[tokens[2]] = %s")
:format(tostring(vacate), B.Offset:Get(), tostring(q.canExecute(bk.observers[2])), tostring(bk.indexOfToken[bk.tokens[2]])))
end
run(true); run(false)

View file

@ -0,0 +1,40 @@
--!nocheck
-- Q2 대조: 재마운트 처방 none / (a) / (c)
-- 트리: O = { a, b, S }, S = { C, plain }, C = { plain } (S의 베이스 2)
-- 유저 체인: `:List`의 updateFn이 만든 요소가 S.Offset을 구독해 LayoutOrder를 정하는 모양.
-- 요소 인스턴스는 언마운트를 넘어 살아남으므로(파괴 아님) 그 Observer도 살아 있다.
local q = require("./core9"); local D = require("./dispatch9")
local function run(fix)
D.remountFix = fix
local inst = q.newInst("inst1")
local elem = q.newInst("elem") -- updateFn이 만든 요소(살아남는다)
local C = D.newSlot("C", {{name="cx"}})
local S = D.newSlot("S", { C, {name="sx"} })
local O = D.newSlot("O", { {name="a"}, {name="b"}, S })
D.mountTop(inst, O)
-- 유저 LayoutOrder: index(=1) + S.Offset
local lo = { value = nil }
local userObs = S.Offset:Observer(function(target) lo.value = 1 + target:Get() end)
q.bindLifetime(elem, userObs)
print(("[%s] 1차: S.Offset=%d C.Offset=%d userLO=%s | bk(S) valid=%d setUpTo=%d cache[1]=%s")
:format(fix, S.Offset:Get(), C.Offset:Get(), tostring(lo.value),
D.getBookkeeping(S).offsetCacheValidUpTo, D.getBookkeeping(S).offsetSetUpTo,
tostring(D.getBookkeeping(S).offsetCache[1])))
D.rawRemove(O, 3) -- 언마운트(포탈로 떼어냄)
local inst2 = q.newInst("inst2")
local O2 = D.newSlot("O2", {})
D.mountTop(inst2, O2)
D.rawAdd(O2, S) -- 베이스 0에 재마운트
local bkS = D.getBookkeeping(S)
print(("[%s] 재마운트: S.Offset=%d (기대 0) C.Offset=%d (기대 0) userLO=%s (기대 1) | bk(S) valid=%d setUpTo=%d cache[1]=%s")
:format(fix, S.Offset:Get(), C.Offset:Get(), tostring(lo.value),
bkS.offsetCacheValidUpTo, bkS.offsetSetUpTo, tostring(bkS.offsetCache[1])))
local ok = (S.Offset:Get() == 0) and (C.Offset:Get() == 0) and (lo.value == 1)
print(("[%s] → %s"):format(fix, if ok then "✅ 전부 정합" else "❌ 어긋남"))
-- 재마운트 뒤 런타임 베이스 변경도 도는가(O2 앞에 요소를 하나 끼워 S를 민다)
D.rawAdd(O2, {name="z"}, 1)
print(("[%s] 앞에 z 삽입 후: S.Offset=%d (기대 1) C.Offset=%d (기대 1) userLO=%s (기대 2)")
:format(fix, S.Offset:Get(), C.Offset:Get(), tostring(lo.value)))
print("")
end
run("none"); run("a"); run("c"); run("ctor")

View file

@ -0,0 +1,13 @@
--!nocheck
local q = require("./core9"); local D = require("./dispatch9")
D.remountFix = "ctor"
local s = D.newSlot("lonely")
print("생성 직후: Offset =", s.Offset:Get(), "| _baseObserver 존재 =", s._baseObserver ~= nil,
"| bk 미생성(books에 없음) =", rawget(D, "x") == nil)
-- 미실체화 Slot에 Add — rawAdd의 _physicalTarget 얼리리턴이 그대로 성립하는가
local i = D.rawAdd(s, {name="q"})
print("미실체화 Add: index =", i, "| _elements =", #s._elements, "| 크래시 없음")
-- 실체화 후 정상 동작
local inst = q.newInst("i"); local O = D.newSlot("O", { s })
D.mountTop(inst, O)
print("마운트 후: O.Length =", O.Length:Get(), "(기대 1) | s.Offset =", s.Offset:Get(), "(기대 0)")

View file

@ -0,0 +1,12 @@
--!nocheck
-- Q3 실측: 중간 삽입 뒤 밀려난 요소의 길이 Observer가 살아 있고, 요소 키 조회가 새 자리를 준다
local q = require("./core9"); local D = require("./dispatch9")
local inst = q.newInst("inst")
local A, B = D.newSlot("A", {{name="a"}}), D.newSlot("B", {{name="b"}})
local O = D.newSlot("O", { A, B }); D.mountTop(inst, O)
D.rawAdd(O, D.newSlot("X", {{name="x"}}), 1) -- 맨 앞 삽입 → A는 2번, B는 3번
local bk = D.getBookkeeping(O)
print("indexOfElement: X", bk.indexOfElement[O._elements[1]], "A", bk.indexOfElement[A], "B", bk.indexOfElement[B], "(기대 1 2 3)")
print("삽입 후 offsets: A", A.Offset:Get(), "(기대 1) B", B.Offset:Get(), "(기대 2)")
D.rawAdd(A, {name="a2"}) -- A 길이 1→2 — 밀려난 A의 Observer가 살아있어야 B가 밀린다
print("A 성장 후 B.Offset", B.Offset:Get(), "(기대 3) | O.Length", O.Length:Get(), "(기대 4)")

View file

@ -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())

View file

@ -0,0 +1,40 @@
--!nocheck
-- H-145: 최상위 Slot 교체(해제 setLength(inst,k,0))가 옛 Slot 키를 못 지워도 weak-key라 GC로 빠지는가.
-- 요소가 테이블이라 weak-key 자체는 동작한다 — Instance userdata 키의 거동은 여기서 못 본다(한계).
local q = require("./core10"); local D = require("./dispatch10")
local inst = q.newInst("inst")
local probe = setmetatable({}, {__mode = "k"})
local retract
do
local S1 = D.newSlot("S1", { {name="a"}, {name="b"} })
probe[S1] = true
retract = D.mountTop(inst, S1, 1)
local bk = D.getBookkeeping(inst)
print("마운트 후 bk(inst).indexOfElement[S1] =", bk.indexOfElement[S1], "lengthList[1] =", tostring(bk.lengthList[1]))
local S2 = D.newSlot("S2", { {name="c"} })
retract(S2) -- State<Slot> 교체: retractor(nextValue=S2) → 언마운트 + setLength(inst,1,0)
print("교체(해제) 후 indexOfElement[S1] =", bk.indexOfElement[S1], "(해제엔 요소 인자가 없어 안 지워짐)")
print(" S1 canExecute(_baseObserver) =", q.canExecute(S1._baseObserver), "| observers[1] =", tostring(bk.observers[1]))
end
retract = nil -- retractor 클로저가 S1을 잡고 있으므로 놓는다(SlotHandler 체인 슬롯이 교체된 것에 해당)
collectgarbage(); collectgarbage()
local alive = 0; for _ in pairs(probe) do alive += 1 end
local cnt = 0; for _ in pairs(D.getBookkeeping(inst).indexOfElement) do cnt += 1 end
print("참조 놓고 GC → 옛 Slot 생존", alive, "(기대 0) | indexOfElement 항목 수", cnt, "(기대 0)")
-- 마운트 중인 요소는 절대 빠지지 않아야 한다(보장자: _elements + gatedRecompute 캡처)
local inst2 = q.newInst("inst2")
local outer = D.newSlot("outer", {})
D.mountTop(inst2, outer, 1)
do
local inner = D.newSlot("inner", { {name="x"} })
D.rawAdd(outer, inner)
D.rawAdd(outer, {name="y"})
end
collectgarbage(); collectgarbage()
local bko = D.getBookkeeping(outer)
local keys = 0; for _ in pairs(bko.indexOfElement) do keys += 1 end
print("마운트 중 요소(로컬 참조 없음) GC 뒤 indexOfElement 항목", keys, "(기대 2 — _elements가 붙잡음)")
print(" inner.Length 변경 전파: outer.Length =", outer.Length:Get())
D.rawAdd(outer._elements[1], {name="z"})
print(" inner에 z 추가 → outer.Length =", outer.Length:Get(), "(기대 3)")

View file

@ -0,0 +1,50 @@
--!nocheck
-- H-136/Q10: `:List` 재실행 reconcile이 배치 Blocker 하나로 도는가 — {a,b} → {b,a,c} → {b} 사이클별 recompute 수
local q = require("./core10"); local D = require("./dispatch10")
local inst = q.newInst("inst")
local items = q.Source({ "a", "b" })
local made = {}
local slot = D.newSlot("L")
D.List(slot, items, function(item, physIndex, offset, prev, ud)
if item == D.KeyGone then return nil end
if prev then return prev end
local e = { name = item }; made[item] = e
return e
end, function(item) return item end)
D.mountTop(inst, slot, 1)
local function names() local t = {}; for _, e in ipairs(slot._elements) do t[#t+1] = e.name end; return table.concat(t, ",") end
print("최초 population:", names(), "| Length", slot.Length:Get(), "| recomputes", D.recomputes)
local function cycle(list, label)
local l0, n0 = #D.log, #D.native
items:Set(list)
local rl, ri = 0, 0
for i = l0 + 1, #D.log do if D.log[i] == "recompute(L)" then rl += 1 elseif D.log[i] == "recompute(inst)" then ri += 1 end end
print(("%s: %s | Length %d | recompute(L) %d (기대 1) recompute(inst) %d | 물리 op: %s"):format(label, names(), slot.Length:Get(),
rl, ri, table.concat(D.native, " ", n0 + 1)))
local bk = D.getBookkeeping(slot)
local offs = {}; for i = 1, bk.N do offs[i] = tostring(bk.sourceList[i] == D.None and "-" or bk.sourceList[i]:Get()) end
print((" bk.N=%d lengthList={%s} validUpTo=%d setUpTo=%d blocker=%s"):format(bk.N,
table.concat(bk.lengthList, ",", 1, bk.N), bk.offsetCacheValidUpTo, bk.offsetSetUpTo, tostring(D.getBlockerOf(slot):IsOn())))
end
cycle({ "b", "a", "c" }, "{b,a,c}")
cycle({ "b" }, "{b}")
cycle({ "d", "b" }, "{d,b}")
-- 중첩 Slot 요소와 Length 전파
local inner = D.newSlot("inner", { {name="i1"}, {name="i2"} })
local items2 = q.Source({ "p", "q" })
local slot2 = D.newSlot("L2")
D.List(slot2, items2, function(item, physIndex, offset, prev)
if item == D.KeyGone then return nil end
if prev then return prev end
if item == "q" then return inner end
return { name = item }
end, function(item) return item end)
local inst2 = q.newInst("inst2")
D.mountTop(inst2, slot2, 1)
print("중첩: L2.Length", slot2.Length:Get(), "(기대 3) inner.Offset", inner.Offset:Get(), "(기대 1)")
D.rawAdd(inner, {name="i3"})
print("inner에 추가 → L2.Length", slot2.Length:Get(), "(기대 4)")
items2:Set({ "q", "p" })
print("리오더 {q,p} → inner.Offset", inner.Offset:Get(), "(기대 0) L2.Length", slot2.Length:Get(), "(기대 4)")
local bk2 = D.getBookkeeping(slot2)
print(" p의 offset(getOffsetAt 2) =", D.getOffsetAt(slot2, 2), "(기대 3)")

View file

@ -0,0 +1,34 @@
--!nocheck
-- 레인 B: `:List` × Detach × KeyGone × 재등장(재마운트) × 파괴 — settle 분기 전부를 값으로
local q = require("./core10"); local D = require("./dispatch10")
local inst = q.newInst("inst")
local items = q.Source({ "a", "b", "c" })
local created, disposed = {}, {}
local slot = D.newSlot("L")
D.List(slot, items, function(item, physIndex, offset, prev, ud)
if item == D.KeyGone then return D.Detach end -- 사라진 키는 홀드
if prev then return prev end
local e = { name = item }; created[#created+1] = item
return e
end, function(item) return item end)
D.mountTop(inst, slot, 1)
local function names() local t = {}; for _, e in ipairs(slot._elements) do t[#t+1] = e.name end; return table.concat(t, ",") end
local function detachedKeys() local t = {}; for k in pairs(slot._detached or {}) do t[#t+1] = k end; table.sort(t); return table.concat(t, ",") end
print("population:", names(), "| created", table.concat(created, ","))
items:Set({ "a", "c" })
print("{a,c}: mounted", names(), "| detached", detachedKeys(), "| Length", slot.Length:Get(), "| 물리:", table.concat(D.native, " ", #D.native - 0))
local n0 = #D.native
items:Set({ "a", "b", "c" })
print("{a,b,c} 재등장: mounted", names(), "| detached", detachedKeys(), "| created 추가?", #created, "(기대 3 — 재사용) | 물리:", table.concat(D.native, " ", n0 + 1))
local bk = D.getBookkeeping(slot)
print(" bk.N", bk.N, "lengthList", table.concat(bk.lengthList, ",", 1, bk.N), "indexOfElement[b] =", bk.indexOfElement[slot._elements[2]])
-- KeyGone 뒤 새 값 반환은 error
local slot2 = D.newSlot("L2"); local items2 = q.Source({ "x" })
D.List(slot2, items2, function(item, _, _, prev) if item == D.KeyGone then return { name = "new" } end; return prev or { name = item } end, function(i) return i end)
local inst2 = q.newInst("inst2"); D.mountTop(inst2, slot2, 1)
print("KeyGone에 새 값:", select(2, pcall(function() items2:Set({}) end)))
-- Detach 홀드 중 Slot 파괴 → _detached도 파괴
n0 = #D.native
D.destroySlotTree(slot)
print("destroySlotTree 뒤 물리:", table.concat(D.native, " ", n0 + 1), "| _destroyed", slot._destroyed)
print("파괴된 Slot 재마운트:", select(2, pcall(function() D.mountTop(q.newInst("i3"), slot, 1) end)))

View file

@ -0,0 +1,42 @@
--!nocheck
-- 레인 B: `InstanceChildHandler`(ROADMAP M5 / dispatch-core `H-134` 다섯째 문단)를 하강 diff 체인 위에 그대로 옮기고,
-- `state<Frame>`의 **같은 Frame 재발행**(spurious)과 **교체**에서 `Parent` 대입이 몇 번 나는지 센다.
-- 대조군: `SlotHandler`(같은 값이면 retractor 얼리리턴) / `RefLeafHandler`(`old ~= v` dedup).
local q = require("./core10"); local D = require("./dispatch10"); local C = require("./chain")
local inst = q.newInst("inst")
local parentOps = {}
local function log(s) parentOps[#parentOps+1] = s end
local None = D.None
-- StoreBind (dispatch-core "Store 바인드는 … 래핑" 의사코드)
local StoreBind = { priority = 100,
isHandlable = function(_, _, v) return q.isState(v) end,
process = function(inst, k, state, index)
local observer = state:Observer(function() C.process(inst, k, state:Get(), index + 1) end)
q.bindLifetime(inst, observer)
return function() q.unbindLifetime(observer) end
end }
-- InstanceChildHandler — ROADMAP M5 그대로: None → Parent → setLength(1); retractor: Parent=nil → None → setLength(0)
local InstanceChild = { priority = 10,
isHandlable = function(_, k, v) return type(k) == "number" and type(v) == "table" and v.__isInst == true end,
process = function(inst, k, v, index)
D.setOffsetSource(inst, k, None)
v.Parent = inst; log("Parent(" .. v.name .. ")=inst")
D.setLength(inst, k, 1, inst)
return function(nextValue)
v.Parent = nil; log("Parent(" .. v.name .. ")=nil")
D.setOffsetSource(inst, k, None)
D.setLength(inst, k, 0)
end
end }
C.addHandler(StoreBind); C.addHandler(InstanceChild)
local A, B = { __isInst = true, name = "A" }, { __isInst = true, name = "B" }
local child = q.Source(A)
D.recomputes = 0
C.process(inst, 1, child, 1)
print("최초:", table.concat(parentOps, " "), "| recompute(inst)", D.recomputes)
parentOps = {}; D.recomputes = 0
child:Set(A) -- spurious 재발행 — 같은 Frame
print("같은 Frame 재발행:", table.concat(parentOps, " "), "| recompute", D.recomputes, "(SlotHandler/RefLeaf라면 0)")
parentOps = {}; D.recomputes = 0
child:Set(B)
print("교체 A→B:", table.concat(parentOps, " "), "| recompute", D.recomputes, "| A.Parent =", tostring(A.Parent))

View file

@ -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)")

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -0,0 +1,587 @@
--!nocheck
-- 10라운드 참조 구현 — 9라운드 전사물 `dispatch9.luau`(같은 폴더 보존)를 HEAD `0ec22fb`
-- 계약으로 갱신한 것. 옮긴 원문:
-- · dispatch-core-plan.md — getBookkeeping 초기화 열거(`indexOfElement` **weak-key**, H-145),
-- getOffsetAt / 두 필드 / 무효화 표 / recompute(H-124 `continue` 순서, 클램프, recomputeBlocker) /
-- setLength(5번째 `element`, 요소 캡처 조회) / setOffsetSource / 배치 게이팅
-- · slot-plan.md — Slot 생성자(Length·Offset·_baseObserver·_destroyed, Q2), makeBaseObserver,
-- materializeSlotTree(게이트→바인드→setOffsetSource 순서) / mountSlotTree / attachSlot,
-- unmountSlotTree / destroySlotTree(핸들 보존, `_destroyed`), rawAdd / rawReplace / rawRemove /
-- rawUnmount / rawDetach, spliceArraysUp/Down(세 배열 비우기, `index-1`), reindexFrom,
-- `H-29` 규약대로 쓴 rawMove(의사코드가 없는 함수 — **규약을 옮긴 것**이라 여기 결함은
-- 규약 자체의 결함일 때만 발견이다), activateList/reconcile(`H-136` `ownsGate` 배치).
-- · 최상위 `SlotHandler.process`의 마운트/retractor(설치·해제 `setLength(inst,k,0)`) 흉내 — `H-145`.
-- 물리 op(native*)은 no-op 기록이고, 요소는 `{name=}` 테이블(길이 1) 또는 Slot.
-- ⚠️ Instance를 weak-key로 쓰는 경로는 흉내낼 수 없다 — 요소가 테이블이라 weak-key 자체는 동작하지만
-- Roblox Instance userdata의 weak-key 거동(`audit/gcconn-trick-verification.md` 미확인)은 별개다.
local q = require("./core10")
local D = {}
local None = setmetatable({}, {__tostring = function() return "None" end})
D.None = None
local Detach, KeyGone = setmetatable({}, {__tostring = function() return "Detach" end}),
setmetatable({}, {__tostring = function() return "KeyGone" end})
D.Detach, D.KeyGone = Detach, KeyGone
local isState = q.isState
local function isSlot(x) return type(x) == "table" and x.__isSlot == true end
D.isSlot = isSlot
local function weakK() return setmetatable({}, {__mode = "k"}) end
D.native = {} -- 물리 op 기록
local function nativeLog(op, target, offset, elements, extra)
local names = {}
for _, e in ipairs(elements or {}) do names[#names+1] = tostring(e.name) end
D.native[#D.native+1] = ("%s(%s@%s:[%s]%s)"):format(op, tostring(target.name), tostring(offset),
table.concat(names, ","), if extra then "→" .. tostring(extra.name) else "")
end
local function nativeInsert(t, off, els) nativeLog("insert", t, off, els) end
local function nativeExtract(t, off, els, new) nativeLog("extract", t, off, els, new and new[1]) end
local function nativeRemove(t, off, els, new) nativeLog("remove", t, off, els, new and new[1]) end
local function nativeMove(t, from, els, to) nativeLog("move", t, from .. "→" .. to, els) end
local function nativeDispose(e) D.native[#D.native+1] = "dispose(" .. tostring(e.name) .. ")" end
-- ── 부기 (dispatch-core-plan.md "저장 위치") ─────────────────────────────
local books, blockers = weakK(), weakK()
local function getBookkeeping(ownerKey)
local bk = books[ownerKey]
if not bk then
bk = { lengthList = {}, sourceList = {}, observers = {}, N = nil,
offsetCache = {}, offsetCacheValidUpTo = 0, offsetSetUpTo = 0,
recomputeBlocker = q.Blocker(),
indexOfElement = weakK() } -- H-145 weak-key
books[ownerKey] = bk
end
return bk
end
local function getBlocker(ownerKey)
local b = blockers[ownerKey]
if not b then b = q.Blocker(); blockers[ownerKey] = b end
return b
end
D.getBookkeeping, D.getBlocker, D.books = getBookkeeping, getBlocker, books
local function contribution(bk, i)
local v = bk.lengthList[i]
return (if isState(v) then v:Get() else v)
end
function D.getOffsetAt(ownerKey, at)
local bk = getBookkeeping(ownerKey)
if bk.offsetCacheValidUpTo == 0 then
bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
bk.offsetCacheValidUpTo = 1
end
if at <= bk.offsetCacheValidUpTo then
return bk.offsetCache[at]
end
local cur = bk.offsetCache[bk.offsetCacheValidUpTo]
for i = bk.offsetCacheValidUpTo, at - 1 do
if bk.lengthList[i] == nil then
error("Dispatch.getOffsetAt: lengthList[" .. i .. "] is nil — bookkeeping is broken", 1)
end
cur += contribution(bk, i)
bk.offsetCache[i + 1] = cur
end
bk.offsetCacheValidUpTo = at
return cur
end
D.log = {}
D.recomputes = 0
local function recompute(ownerKey, bk)
D.recomputes += 1
table.insert(D.log, "recompute(" .. tostring(ownerKey.name) .. ")")
local sum = 0
bk.recomputeBlocker:On()
local prefix, i = {}, 1
while i <= (bk.N or 0) do
prefix[i] = sum
local offset = bk.sourceList[i]
if offset == nil then
error("Dispatch.recompute: sourceList[" .. i .. "] is nil — bookkeeping is broken", 1)
end
local abs = D.getOffsetAt(ownerKey, i)
bk.offsetSetUpTo = i
if offset ~= None and offset:Get() ~= abs then
offset:Set(abs)
end
if bk.offsetSetUpTo < i then -- H-124: 되감기 판정이 읽기보다 먼저
i = math.max(bk.offsetSetUpTo, 1)
sum = prefix[i]
continue
end
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v)
i += 1
end
bk.offsetSetUpTo = bk.N or 0
bk.recomputeBlocker:OffWithoutEmit()
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
ownerKey.Length:Set(sum)
end
end
D.recompute = recompute
function D.setLength(ownerKey, i, len, anchor, element)
anchor = anchor or ownerKey
local bk = getBookkeeping(ownerKey)
local blocker = getBlocker(ownerKey)
local oldObserver = bk.observers[i]
if oldObserver then q.unbindLifetime(oldObserver); bk.observers[i] = nil end
bk.lengthList[i] = len
bk.N = math.max(bk.N or 0, i)
if element ~= nil then bk.indexOfElement[element] = i end
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, i)
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, i)
local function gatedRecompute()
local cur = if element ~= nil then bk.indexOfElement[element] else i
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, cur)
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, cur)
if blocker:IsOn() then return end
if bk.recomputeBlocker:IsOn() then return end
recompute(ownerKey, bk)
end
if isState(len) then
local observer = len:Observer(gatedRecompute)
q.bindLifetime(anchor, observer)
bk.observers[i] = observer
else
gatedRecompute()
end
end
function D.setOffsetSource(ownerKey, i, source)
local bk = getBookkeeping(ownerKey)
bk.sourceList[i] = source
if source == None then return end
local offset = D.getOffsetAt(ownerKey, i)
if source:Get() ~= offset then source:Set(offset) end
end
-- ── splice / reindex (slot-plan.md 요구 목록) ────────────────────────────
local function reindexFrom(self, from)
local bk = getBookkeeping(self)
for i = from, #self._elements do bk.indexOfElement[self._elements[i]] = i end
end
local function spliceArraysUp(self, index)
local bk = getBookkeeping(self)
local N = bk.N or 0
for i = N, index, -1 do
bk.lengthList[i + 1] = bk.lengthList[i]; bk.sourceList[i + 1] = bk.sourceList[i]
bk.observers[i + 1] = bk.observers[i]
end
bk.lengthList[index] = 0 -- H-5 자리표시자
bk.sourceList[index] = None
bk.observers[index] = nil -- Q3: 비운 자리는 세 배열 전부
bk.N = N + 1
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, index - 1)
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, index - 1)
end
local function spliceArraysDown(self, index)
local bk = getBookkeeping(self)
local N = bk.N
for i = index, N - 1 do
bk.lengthList[i] = bk.lengthList[i + 1]; bk.sourceList[i] = bk.sourceList[i + 1]
bk.observers[i] = bk.observers[i + 1]
end
bk.lengthList[N], bk.sourceList[N], bk.observers[N] = nil, nil, nil
bk.N = N - 1
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, index - 1)
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, index - 1)
end
D.spliceArraysUp, D.spliceArraysDown, D.reindexFrom = spliceArraysUp, spliceArraysDown, reindexFrom
-- ── 소유권 (slot-plan.md "요소 소유권") — 최소형 ─────────────────────────
local elementOwner = setmetatable({}, {__mode = "kv"}) -- slot-plan.md: elementOwner는 전부 SetWeak(값도 weak) — 값이 키(요소)를 되참조하므로 강하면 샌다(H-71)
local function claimOwner(element, slot, fromDetached)
local cur = elementOwner[element]
if cur ~= nil and cur ~= slot then error("Slot: element already mounted elsewhere", 2) end
if cur == slot and not fromDetached then error("Slot: element already in this Slot", 2) end
elementOwner[element] = slot
end
local function releaseOwner(element, slot)
if elementOwner[element] == slot then elementOwner[element] = nil end
end
-- ── Slot 값 타입 (Q2 생성자) ─────────────────────────────────────────────
local makeBaseObserver, materializeSlotTree, mountSlotTree, attachSlot, unmountSlotTree, destroySlotTree
local rawAdd, rawRemove, rawUnmount, rawDetach, rawReplace, rawMove
function D.newSlot(name, elements)
local slot = { __isSlot = true, name = name, _elements = {},
Length = q.Source(0), Offset = q.Source(0),
_physicalTarget = nil, _mounted = false, _mountedInst = nil }
slot._baseObserver = makeBaseObserver(slot) -- 등록 즉시 1회는 가드가 삼킨다
for _, e in ipairs(elements or {}) do rawAdd(slot, e) end
return slot
end
makeBaseObserver = function(slot)
return slot.Offset:Observer(function()
if slot._physicalTarget == nil then return end
local bk = getBookkeeping(slot)
bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0
table.insert(D.log, "baseObs(" .. tostring(slot.name) .. ")")
if getBlocker(slot):IsOn() or bk.recomputeBlocker:IsOn() then return end
recompute(slot, bk)
end)
end
local activateList
materializeSlotTree = function(slot, physicalTarget, ownerKey, position)
if slot._destroyed then error("Slot: destroyed Slot cannot be mounted", 2) end
slot._physicalTarget = physicalTarget
local blocker = getBlocker(slot)
blocker:On()
q.bindLifetime(physicalTarget, slot._baseObserver)
D.setOffsetSource(ownerKey, position, slot.Offset)
if slot._listed and not slot._listActivated then
activateList(slot, physicalTarget)
else
if slot._listed then activateList(slot, physicalTarget) end
for i, element in ipairs(slot._elements) do
if isSlot(element) then
materializeSlotTree(element, physicalTarget, slot, i)
else
D.setOffsetSource(slot, i, None)
D.setLength(slot, i, 1, physicalTarget, element)
end
end
end
blocker:OffWithoutEmit()
local bk = getBookkeeping(slot)
if not bk.recomputeBlocker:IsOn() then recompute(slot, bk) end
D.setLength(ownerKey, position, slot.Length, physicalTarget, slot)
end
mountSlotTree = function(slot, physicalTarget)
slot._mounted = true
slot._mountedInst = physicalTarget
local acc = slot.Offset:Get()
for _, element in ipairs(slot._elements) do
if isSlot(element) then
mountSlotTree(element, physicalTarget); acc += element.Length:Get()
else
nativeInsert(physicalTarget, acc, { element }); acc += 1
end
end
end
attachSlot = function(slot, physicalTarget, ownerKey, position, mount)
materializeSlotTree(slot, physicalTarget, ownerKey, position)
if mount ~= false then mountSlotTree(slot, physicalTarget) end
end
D.materializeSlotTree, D.mountSlotTree, D.attachSlot = materializeSlotTree, mountSlotTree, attachSlot
unmountSlotTree = function(slot)
local physicalTarget = slot._mountedInst
for i = #slot._elements, 1, -1 do
local element = slot._elements[i]
if isSlot(element) then unmountSlotTree(element)
elseif physicalTarget then nativeExtract(physicalTarget, D.getOffsetAt(slot, i), { element }) end
end
local bk = getBookkeeping(slot)
for _, observer in pairs(bk.observers) do q.unbindLifetime(observer) end
if slot._listObserver then q.unbindLifetime(slot._listObserver) end
if slot._detachCleanup then q.unbindLifetime(slot._detachCleanup) end
q.unbindLifetime(slot._baseObserver)
slot._mounted, slot._mountedInst = false, nil
slot._physicalTarget = nil
end
destroySlotTree = function(slot)
if slot._destroyed then return end
if slot._owned == false then unmountSlotTree(slot); return end
for _, element in ipairs(slot._elements) do
if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end
end
if slot._detached then
for key, element in pairs(slot._detached) do
if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end
slot._detached[key] = nil
end
end
if slot._detachCleanup then q.unbindLifetime(slot._detachCleanup); slot._detachCleanup = nil end
if slot._listObserver then q.unbindLifetime(slot._listObserver) end
q.unbindLifetime(slot._baseObserver)
local bk = getBookkeeping(slot)
for _, observer in pairs(bk.observers) do q.unbindLifetime(observer) end
slot._mounted, slot._mountedInst = false, nil
slot._physicalTarget = nil
slot._destroyed = true
end
D.unmountSlotTree, D.destroySlotTree = unmountSlotTree, destroySlotTree
-- ── raw* ──────────────────────────────────────────────────────────────────
rawAdd = function(self, element, index, fromDetached)
claimOwner(element, self, fromDetached)
index = index or (#self._elements + 1)
table.insert(self._elements, index, element)
reindexFrom(self, index)
if self._physicalTarget == nil then return index end
spliceArraysUp(self, index)
if isSlot(element) then
attachSlot(element, self._physicalTarget, self, index, self._mounted)
else
D.setOffsetSource(self, index, None)
if self._mounted then nativeInsert(self._mountedInst, D.getOffsetAt(self, index), { element }) end
D.setLength(self, index, 1, self._physicalTarget, element)
end
return index
end
rawReplace = function(self, index, newElement, destroyOld)
local oldElement = self._elements[index]
claimOwner(newElement, self)
releaseOwner(oldElement, self)
self._elements[index] = newElement
local bk0 = getBookkeeping(self)
bk0.indexOfElement[oldElement] = nil
bk0.indexOfElement[newElement] = index
if not self._mounted then
if destroyOld then
if isSlot(oldElement) then destroySlotTree(oldElement) else nativeDispose(oldElement) end
end
if self._physicalTarget ~= nil then
if isSlot(newElement) then attachSlot(newElement, self._physicalTarget, self, index, false)
else
D.setOffsetSource(self, index, None)
D.setLength(self, index, 1, self._physicalTarget, newElement)
end
end
return
end
local offset = D.getOffsetAt(self, index)
if isSlot(oldElement) or isSlot(newElement) then
if isSlot(oldElement) then
if destroyOld then destroySlotTree(oldElement) else unmountSlotTree(oldElement) end
else
(if destroyOld then nativeRemove else nativeExtract)(self._mountedInst, offset, { oldElement })
end
if isSlot(newElement) then
attachSlot(newElement, self._physicalTarget, self, index, self._mounted); return
end
D.setOffsetSource(self, index, None)
nativeInsert(self._mountedInst, offset, { newElement })
else
(if destroyOld then nativeRemove else nativeExtract)(self._mountedInst, offset, { oldElement }, { newElement })
end
D.setLength(self, index, 1, self._physicalTarget, newElement)
end
local function removeLike(self, index, mode) -- mode: "remove" | "unmount" | "detach"
local element = self._elements[index]
local bk = getBookkeeping(self)
if bk.observers[index] then q.unbindLifetime(bk.observers[index]) end
if mode ~= "detach" then releaseOwner(element, self) end
if isSlot(element) then
if mode == "remove" then destroySlotTree(element) else unmountSlotTree(element) end
elseif self._mounted then
if mode == "remove" then nativeRemove(self._mountedInst, D.getOffsetAt(self, index), { element })
else nativeExtract(self._mountedInst, D.getOffsetAt(self, index), { element }) end
elseif mode == "remove" then
nativeDispose(element)
end
table.remove(self._elements, index)
bk.indexOfElement[element] = nil
reindexFrom(self, index)
if self._physicalTarget ~= nil then
spliceArraysDown(self, index)
if not (getBlocker(self):IsOn() or bk.recomputeBlocker:IsOn()) then recompute(self, bk) end
end
end
rawRemove = function(self, index) removeLike(self, index, "remove") end
rawUnmount = function(self, index) removeLike(self, index, "unmount") end
rawDetach = function(self, index) removeLike(self, index, "detach") end
-- rawMove — `H-29` 규약 1~6을 옮긴 것(의사코드 없음). 치환: _elements/indexOfElement/lengthList/
-- sourceList/observers 같은 순열, N 불변, 두 필드 minPos-1, 이동 구간 setLength 재등록, 물리는 _mounted로.
rawMove = function(self, from, to)
if from == to then return end
local bk = getBookkeeping(self)
local element = table.remove(self._elements, from)
table.insert(self._elements, to, element)
reindexFrom(self, math.min(from, to))
if self._physicalTarget == nil then return end
local function permute(t)
local v = table.remove(t, from); table.insert(t, to, v)
end
-- observers 배열은 구멍이 있을 수 있어 table.remove가 안 맞는다 — 수동 순열
local newObs = {}
for i = 1, bk.N do newObs[i] = bk.observers[i] end
local mv = newObs[from]; table.remove(newObs, from); table.insert(newObs, to, mv)
for i = 1, bk.N do bk.observers[i] = newObs[i] end
permute(bk.lengthList); permute(bk.sourceList)
local minPos = math.min(from, to)
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, minPos - 1)
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, minPos - 1)
if self._mounted then
local leaves = {}
local function collect(e) if isSlot(e) then for _, c in ipairs(e._elements) do collect(c) end else leaves[#leaves+1] = e end end
collect(element)
nativeMove(self._mountedInst, from, leaves, to)
end
for p = minPos, math.max(from, to) do -- 규약 4: 이동 구간 setLength 재등록
D.setLength(self, p, bk.lengthList[p], self._physicalTarget, self._elements[p])
end
end
D.rawAdd, D.rawRemove, D.rawUnmount, D.rawDetach, D.rawReplace, D.rawMove =
rawAdd, rawRemove, rawUnmount, rawDetach, rawReplace, rawMove
-- ── :List / activateList / reconcile (slot-plan.md "구현", H-136 배치) ───
local function indexOfRaw(self, element) return getBookkeeping(self).indexOfElement[element] end
function D.List(slot, data, updateFn, keyFn)
slot._listed = true
slot._listData, slot._updateFn = data, updateFn
slot._keyFn = keyFn or function(_, i) return i end
if slot._physicalTarget then
local blocker = getBlocker(slot); blocker:On()
activateList(slot, slot._physicalTarget)
blocker:OffWithoutEmit()
local bk = getBookkeeping(slot)
if not bk.recomputeBlocker:IsOn() then recompute(slot, bk) end
end
return slot
end
activateList = function(self, physicalTarget)
if self._listActivated then
if self._listObserver then q.bindLifetime(physicalTarget, self._listObserver) end
q.bindLifetime(physicalTarget, self._detachCleanup)
return
end
self._listActivated = true
local keyFn, updateFn = self._keyFn, self._updateFn
local offset = self.Offset
local mounted, userdata, prevKeys = {}, {}, {}
local function settle(key, result, detach, slotPos)
local wasMounted = mounted[key]
local detached = self._detached
local wasDetached = detached and detached[key]
local prev = wasMounted or wasDetached
if detach then
if wasMounted ~= nil then
local idx = indexOfRaw(self, wasMounted)
rawDetach(self, idx)
self._detached = self._detached or {}
self._detached[key], mounted[key] = wasMounted, nil
end
elseif result == nil then
if prev ~= nil then
if wasDetached ~= nil then
if isSlot(prev) then destroySlotTree(prev) else nativeDispose(prev) end
else
rawRemove(self, indexOfRaw(self, wasMounted))
end
end
mounted[key] = nil
if detached then detached[key] = nil end
elseif result == prev then
if wasDetached ~= nil then
detached[key] = nil
rawAdd(self, prev, slotPos, true)
mounted[key] = prev
else
local idx = indexOfRaw(self, wasMounted)
if idx ~= slotPos then rawMove(self, idx, slotPos) end
end
else
local element = result
if wasDetached ~= nil then
if isSlot(prev) then destroySlotTree(prev) else nativeDispose(prev) end
detached[key] = nil
rawAdd(self, element, slotPos)
elseif wasMounted ~= nil then
local idx = indexOfRaw(self, wasMounted)
rawReplace(self, idx, element, true)
if idx ~= slotPos then rawMove(self, idx, slotPos) end
else
rawAdd(self, element, slotPos)
end
mounted[key] = element
end
end
local function reconcile(items)
local keys, seen = {}, {}
for i, item in ipairs(items) do
local key = keyFn(item, i)
if seen[key] then error("Slot:List — duplicate key: " .. tostring(key)) end
seen[key] = true; keys[i] = key
end
local blocker = getBlocker(self)
local ownsGate = not blocker:IsOn()
if ownsGate then blocker:On() end
local slotPos = 0
for i, item in ipairs(items) do
local key = keys[i]
local prev = mounted[key] or (self._detached and self._detached[key])
local candidateSlot = slotPos + 1
local physIndex = D.getOffsetAt(self, candidateSlot) - offset:Get() + 1
local result, ud = updateFn(item, physIndex, offset, prev, userdata[key])
if result == None then result = nil end
local detach = (result == Detach)
if detach then result = nil end
if result ~= nil then slotPos = candidateSlot end
prevKeys[key] = true
settle(key, result, detach, slotPos)
userdata[key] = ud
end
for key in pairs(table.clone(prevKeys)) do
if not seen[key] then
local prev = mounted[key] or (self._detached and self._detached[key])
local result, ud = updateFn(KeyGone, 0, offset, prev, userdata[key])
if result == None then result = nil end
local detach = (result == Detach)
if detach then result = nil end
if result ~= nil then error("Slot:List — KeyGone accepts only nil/None or Detach") end
settle(key, result, detach, 0)
userdata[key] = ud
prevKeys[key] = nil
end
end
if ownsGate then
blocker:OffWithoutEmit()
local bk = getBookkeeping(self)
if not bk.recomputeBlocker:IsOn() then recompute(self, bk) end
end
end
self._detachCleanup = q.Effect(function()
return function()
if not self._detached then return end
for key, element in pairs(self._detached) do
releaseOwner(element, self)
if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end
self._detached[key] = nil
end
end
end)
q.bindLifetime(physicalTarget, self._detachCleanup)
local data = self._listData
if isState(data) then
local observer = data:Observer(function() reconcile(data:Get()) end)
self._listObserver = observer
q.bindLifetime(physicalTarget, observer)
else
reconcile(data)
end
end
-- ── 최상위 (Dispatch.drive + SlotHandler 흉내) ────────────────────────────
-- 최상위 배열 자리 k에 Slot을 붙인다: SlotHandler.process의 마운트 + retractor 반환.
function D.mountTop(inst, slot, k)
k = k or 1
local bl = getBlocker(inst); bl:On()
attachSlot(slot, inst, inst, k)
q.bindLifetime(inst, slot)
bl:OffWithoutEmit()
local bk = getBookkeeping(inst)
if not bk.recomputeBlocker:IsOn() then recompute(inst, bk) end
return function(nextValue) -- retractor: State<Slot> 교체 = 언마운트(파괴 아님)
if nextValue ~= slot then
unmountSlotTree(slot)
q.unbindLifetime(slot)
D.setOffsetSource(inst, k, None)
D.setLength(inst, k, 0) -- 해제 호출엔 요소 인자가 없다(H-145)
end
end
end
-- 배열 자리 k에 plain 요소(InstanceChildHandler 흉내): None → Parent → setLength(1)
function D.mountChild(inst, child, k)
D.setOffsetSource(inst, k, None)
nativeInsert(inst, k - 1, { child })
D.setLength(inst, k, 1, inst)
end
D.getBlockerOf = getBlocker
return D

View file

@ -0,0 +1,289 @@
--!nocheck
-- 9라운드 참조 구현 — base/dispatch-core-plan.md(getOffsetAt / "두 필드" / 무효화 표 /
-- recompute(되감기·클램프·recomputeBlocker) / setLength(토큰·두 게이트) / setOffsetSource)
-- + base/slot-plan.md(materializeSlotTree의 _baseObserver·꼬리 게이트, unmountSlotTree,
-- rawAdd, rawRemove, spliceArraysUp/Down 요구 목록)을 옮긴 전사물.
-- 물리 op(native*)은 no-op이고, 요소는 {name=} 테이블(길이 1) 또는 Slot.
local q = require("./core9")
local D = {}
D.remountFix = "ctor" -- "none" | "a"(materialize 진입부 0,0) | "c"(bind/blocker를 emit 위로)
local None = setmetatable({}, {__tostring = function() return "None" end})
D.None = None
local isState = q.isState
local function isSlot(x) return type(x) == "table" and x.__isSlot == true end
D.isSlot = isSlot
local books, blockers = {}, {}
local function getBookkeeping(ownerKey)
local bk = books[ownerKey]
if not bk then
bk = { lengthList = {}, sourceList = {}, observers = {}, indexOfElement = {}, N = nil,
offsetCache = {}, offsetCacheValidUpTo = 0, offsetSetUpTo = 0,
recomputeBlocker = q.Blocker() }
books[ownerKey] = bk
end
return bk
end
local function getBlocker(ownerKey)
local b = blockers[ownerKey]
if not b then b = q.Blocker(); blockers[ownerKey] = b end
return b
end
D.getBookkeeping, D.getBlocker = getBookkeeping, getBlocker
local function contribution(bk, i)
local v = bk.lengthList[i]
return (if isState(v) then v:Get() else v)
end
function D.getOffsetAt(ownerKey, at)
local bk = getBookkeeping(ownerKey)
if bk.offsetCacheValidUpTo == 0 then
bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
bk.offsetCacheValidUpTo = 1
end
if at <= bk.offsetCacheValidUpTo then
return bk.offsetCache[at]
end
local cur = bk.offsetCache[bk.offsetCacheValidUpTo]
for i = bk.offsetCacheValidUpTo, at - 1 do
if bk.lengthList[i] == nil then
error("Dispatch.getOffsetAt: lengthList[" .. i .. "]가 nil — 부기가 깨졌음")
end
cur += contribution(bk, i)
bk.offsetCache[i + 1] = cur
end
bk.offsetCacheValidUpTo = at
return cur
end
D.log = {}
local function recompute(ownerKey, bk)
table.insert(D.log, "recompute(" .. tostring(ownerKey.name) .. ")")
local sum = 0
bk.recomputeBlocker:On()
local prefix, i = {}, 1
while i <= (bk.N or 0) do
prefix[i] = sum
local offset = bk.sourceList[i]
if offset == nil then
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
end
local abs = D.getOffsetAt(ownerKey, i)
bk.offsetSetUpTo = i
if offset ~= None and offset:Get() ~= abs then
offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리
end
-- [Q1 (a), 사용자 확정] 되감기 판정이 `lengthList[i]` 읽기보다 **앞**.
-- 되감으면 `sum`은 `prefix[i]`로 복원되므로 새로 만든 sum은 어차피 버려진다.
if bk.offsetSetUpTo < i then
i = math.max(bk.offsetSetUpTo, 1)
sum = prefix[i]
continue
end
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v)
i += 1
end
bk.offsetSetUpTo = bk.N or 0
bk.recomputeBlocker:OffWithoutEmit()
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
ownerKey.Length:Set(sum)
end
end
D.recompute = recompute
function D.setLength(ownerKey, i, len, anchor, element) -- [Q3] 5번째 = 그 자리의 요소
anchor = anchor or ownerKey
local bk = getBookkeeping(ownerKey)
local blocker = getBlocker(ownerKey)
local oldObserver = bk.observers[i]
if oldObserver then q.unbindLifetime(oldObserver); bk.observers[i] = nil end
bk.lengthList[i] = len
bk.N = math.max(bk.N or 0, i)
if element ~= nil then bk.indexOfElement[element] = i end
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, i)
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, i)
local function gatedRecompute()
local cur = if element ~= nil then bk.indexOfElement[element] else i
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, cur)
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, cur)
if blocker:IsOn() then return end
if bk.recomputeBlocker:IsOn() then return end
recompute(ownerKey, bk)
end
if isState(len) then
local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회도 게이팅
q.bindLifetime(anchor, observer)
bk.observers[i] = observer
else
gatedRecompute()
end
end
function D.setOffsetSource(ownerKey, i, source)
local bk = getBookkeeping(ownerKey)
bk.sourceList[i] = source
if source == None then return end
local offset = D.getOffsetAt(ownerKey, i)
if source:Get() ~= offset then source:Set(offset) end
end
-- ── splice (slot-plan.md 요구 목록: lengthList/sourceList/observers/tokens/indexOfToken/N, 두 필드 index-1) ──
local function spliceArraysUp(self, index)
local bk = getBookkeeping(self)
local N = bk.N or 0
for i = N, index, -1 do
bk.lengthList[i + 1] = bk.lengthList[i]; bk.sourceList[i + 1] = bk.sourceList[i]
bk.observers[i + 1] = bk.observers[i]
end
bk.lengthList[index] = 0 -- H-5 자리표시자
bk.sourceList[index] = None
bk.observers[index] = nil -- [Q3] 비운 자리는 세 배열 전부
bk.N = N + 1
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, index - 1)
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, index - 1)
end
local function spliceArraysDown(self, index)
local bk = getBookkeeping(self)
local N = bk.N
for i = index, N - 1 do
bk.lengthList[i] = bk.lengthList[i + 1]; bk.sourceList[i] = bk.sourceList[i + 1]
bk.observers[i] = bk.observers[i + 1]
end
bk.lengthList[N], bk.sourceList[N], bk.observers[N] = nil, nil, nil
bk.N = N - 1
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, index - 1)
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, index - 1)
end
D.spliceArraysUp, D.spliceArraysDown = spliceArraysUp, spliceArraysDown
-- ── Slot 최소형 ──────────────────────────────────────────────────────────
local makeBaseObserver
function D.newSlot(name, elements)
local slot = { __isSlot = true, name = name, _elements = elements or {},
Length = q.Source(0), Offset = nil, _physicalTarget = nil, _mounted = false }
if D.remountFix == "ctor" then
-- Length와 **같은 자리**에서 Offset을 만든다(공개 필드 대칭).
slot.Offset = q.Source(0)
slot._baseObserver = makeBaseObserver(slot) -- 등록 즉시 1회는 아래 가드가 삼킨다
end
return slot
end
local materializeSlotTree, unmountSlotTree
makeBaseObserver = function(slot)
return slot.Offset:Observer(function()
-- ⭐ 미실체화면 할 일이 없다 — 생성자에서 만들 때의 "등록 즉시 1회"를 여기서 삼킨다
-- (그래야 bk/recomputeBlocker가 eager 생성되지 않는다).
if slot._physicalTarget == nil then return end
local bk = getBookkeeping(slot)
bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0
table.insert(D.log, "baseObs(" .. tostring(slot.name) .. ")")
if getBlocker(slot):IsOn() or bk.recomputeBlocker:IsOn() then return end
recompute(slot, bk)
end)
end
materializeSlotTree = function(slot, physicalTarget, ownerKey, position)
slot._physicalTarget = physicalTarget
local offsetSource = slot.Offset or q.Source(0)
slot.Offset = offsetSource
local blocker = getBlocker(slot)
local function installBase()
if slot._baseObserver then
q.bindLifetime(physicalTarget, slot._baseObserver)
else
slot._baseObserver = makeBaseObserver(slot)
q.bindLifetime(physicalTarget, slot._baseObserver)
end
end
if D.remountFix == "ctor" then
-- 분기 없음 — 관측자는 이미 있다. 게이트 → 바인드 → emit.
blocker:On()
q.bindLifetime(physicalTarget, slot._baseObserver)
D.setOffsetSource(ownerKey, position, offsetSource)
elseif D.remountFix == "c" then
blocker:On() -- (c): 게이트와 관측자를 emit **위로**
installBase()
D.setOffsetSource(ownerKey, position, offsetSource)
else
if D.remountFix == "a" then -- (a): 진입부 명시 초기화
local bk0 = getBookkeeping(slot)
bk0.offsetCacheValidUpTo, bk0.offsetSetUpTo = 0, 0
end
D.setOffsetSource(ownerKey, position, offsetSource)
blocker:On()
installBase()
end
for i, element in ipairs(slot._elements) do
if isSlot(element) then
materializeSlotTree(element, physicalTarget, slot, i)
else
D.setOffsetSource(slot, i, None)
D.setLength(slot, i, 1, physicalTarget, element)
end
end
blocker:OffWithoutEmit()
local bk = getBookkeeping(slot)
if not bk.recomputeBlocker:IsOn() then recompute(slot, bk) end
D.setLength(ownerKey, position, slot.Length, physicalTarget, slot)
end
D.materializeSlotTree = materializeSlotTree
unmountSlotTree = function(slot)
for i = #slot._elements, 1, -1 do
local element = slot._elements[i]
if isSlot(element) then unmountSlotTree(element) end
end
local bk = getBookkeeping(slot)
for _, observer in pairs(bk.observers) do q.unbindLifetime(observer) end
if slot._baseObserver then q.unbindLifetime(slot._baseObserver) end
slot._mounted, slot._mountedInst, slot._physicalTarget = false, nil, nil
end
D.unmountSlotTree = unmountSlotTree
-- 요소 소유권/래핑은 생략(트레이스 대상 아님)
local function reindexFrom(self, from)
local bk = getBookkeeping(self)
for i = from, #self._elements do bk.indexOfElement[self._elements[i]] = i end
end
function D.rawAdd(self, element, index)
index = index or (#self._elements + 1)
table.insert(self._elements, index, element)
reindexFrom(self, index)
if self._physicalTarget == nil then return index end
spliceArraysUp(self, index)
if isSlot(element) then
materializeSlotTree(element, self._physicalTarget, self, index)
else
D.setOffsetSource(self, index, None)
D.setLength(self, index, 1, self._physicalTarget, element)
end
return index
end
function D.rawRemove(self, index)
local element = self._elements[index]
local bk = getBookkeeping(self)
if bk.observers[index] then q.unbindLifetime(bk.observers[index]) end
if isSlot(element) then unmountSlotTree(element) end -- (파괴 대신 언마운트로 흉내)
table.remove(self._elements, index)
bk.indexOfElement[element] = nil
reindexFrom(self, index)
spliceArraysDown(self, index)
if not (getBlocker(self):IsOn() or bk.recomputeBlocker:IsOn()) then
recompute(self, bk)
end
end
-- 최상위 mount: inst를 owner로, slot을 position 1에
function D.mountTop(inst, slot)
local bl = getBlocker(inst); bl:On()
materializeSlotTree(slot, inst, inst, 1)
bl:OffWithoutEmit()
local bk = getBookkeeping(inst)
if not bk.recomputeBlocker:IsOn() then recompute(inst, bk) end
end
return D

View file

@ -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)

View file

@ -0,0 +1,28 @@
--!strict
type function CheckReservedKeys(keys: type)
if keys:is("never") then print("keys is never") return types.singleton(true) end
local list = if keys:is("union") then keys:components() else {keys}
for _, k in list do
if k:is("singleton") and (k:value() == "Of" or k:value() == "Names" or k:value() == "__reservedCheck") then
print(`quad.Store: "{k:value()}" is a reserved key`)
return types.never
end
end
return types.singleton(true)
end
type Source<T> = { Get: (self: any) -> T, Set: (self: any, T) -> () }
type Store<T> = T & {
Of: <U>(self: any, name: string) -> Source<U>,
Names: (self: any) -> { string },
__reservedCheck: CheckReservedKeys<keyof<T>>,
}
local function Store<T>(defaults: T?): Store<T> return (nil :: any) end
-- 빈 Store
local empty = Store({} :: {})
local _n1: { string } = empty:Names()
-- 키 있는 Store (양성)
local st = Store({ hp = (nil :: any) :: Source<number> })
local _h: number = st.hp:Get()
-- 음성 대조군
local bad = Store({ Of = (nil :: any) :: Source<number> })
local _o = bad.Names

View file

@ -0,0 +1,17 @@
--!strict
type function CheckReservedKeys(keys: type)
local list = if keys:is("union") then keys:components() else {keys}
for _, k in list do
if k:is("singleton") and (k:value() == "Of" or k:value() == "Names" or k:value() == "__reservedCheck") then
print(`quad.Store: "{k:value()}" is a reserved key`)
return types.never
end
end
return types.singleton(true)
end
type Source<T> = { Get: (self: any) -> T, Set: (self: any, T) -> () }
type Store<T> = T & { Of: <U>(self: any, name: string) -> Source<U>, Names: (self: any) -> { string }, __reservedCheck: CheckReservedKeys<keyof<T>> }
local function Store<T>(defaults: T?): Store<T> return (nil :: any) end
local empty = Store({} :: {})
local _n1: { string } = empty:Names()
local _d: Source<number> = empty:Of("dyn")

View file

@ -0,0 +1,9 @@
--!strict
-- lifecycle-hooks-plan.md: "Luau 함수 타입은 파라미터에 반변이라 인자를 덜 받는 함수가 대입 가능"
type Ref = { Value: number }
local function OnCreated(fn: (inst: string, ref: Ref) -> ()): () return end
OnCreated(function(inst) print(inst) end) -- 1-인자 람다
OnCreated(function(inst: string) print(inst) end) -- 주석 붙은 1-인자
OnCreated(function(inst, ref) print(inst, ref.Value) end)
local f: (number, string) -> () = function(x: number) end
local g: (number) -> () = function(x: number, y: string) end -- 더 받는 쪽: 에러여야 정상

View file

@ -0,0 +1,4 @@
--!nocheck
local seen = { a = true }
local ok, err = pcall(function() for d in seen do print(d) end end)
print("for d in seen do (일반화 반복):", ok, err)

View file

@ -0,0 +1,4 @@
--!strict
local seen: { [string]: boolean } = { a = true }
for d in seen do print(d) end
for d in pairs(seen) do print(d) end

View file

@ -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, "| 그러나 다시는 안 울린다")

View file

@ -0,0 +1,42 @@
--!nocheck
local q = require("./core9")
local inst = q.newInst("inst")
-- (1) Ref dep: onRefFire → fire(ref) → Update(ref) → Rerun
local r = q.Ref(nil)
local runs, cleanups = 0, 0
local e = q.Effect(function(self) runs += 1; return function() cleanups += 1 end end, r)
print("생성 직후 runs", runs, "(기대 1)")
r:Set(1) -- 아직 바인드 안 됨 → canExecute 거짓 → 무시
print("미바인드 Set 후 runs", runs, "(기대 1)")
q.bindLifetime(inst, e) -- 캐치업: Refresh()가 r의 리비전 변화를 봄 → Rerun
print("바인드 캐치업 후 runs", runs, "(기대 2) cleanups", cleanups, "(기대 1)")
r:Set(2)
print("바인드 뒤 Set 후 runs", runs, "(기대 3)")
r:Set(2) -- 같은 값이라도 Set은 새 리비전 → 한 번 더
print("같은 값 Set 후 runs", runs, "(기대 4)")
-- (2) 다이아몬드: A → b, A → c, Effect(fn, b, c) — A:Set 한 번에 fn 한 번
local A = q.Source(1)
local b = A:Compute(function(_) return A:Get() + 1 end)
local c = A:Compute(function(_) return A:Get() * 2 end)
local n = 0
local e2 = q.Effect(function() n += 1 end, b, c)
q.bindLifetime(inst, e2)
A:Set(5)
print("다이아몬드 A:Set 후 n", n, "(기대 2 = 설치 1 + 파동 1)")
-- (3) 혼합 deps: Ref + State 한 Effect에서, 각각 독립 발화
local m = 0
local e3 = q.Effect(function() m += 1 end, r, b)
q.bindLifetime(inst, e3)
r:Set(3); A:Set(6)
print("혼합 deps r:Set + A:Set 후 m", m, "(기대 3)")
-- (4) 무인자 state:Observer() 내부 콜백이 3-인자 루프에서 살아남는가
local s = q.Source(0)
local o = s:Observer() -- function(targetState) targetState:Get() end
o:Subscribe()
local ok, err = pcall(function() s:Set(1) end)
print("무인자 Observer 전파:", if ok then "OK" else "ERROR " .. tostring(err))
-- (5) leaf 죽음 → cleanup 1회, 이후 Ref:Set 무시
q.destroyInst(inst)
print("destroy 후 cleanups", cleanups, "(기대: 직전 + 1)")
local before = runs; r:Set(9)
print("죽은 뒤 Set → runs 변화", runs - before, "(기대 0)")

View file

@ -0,0 +1,26 @@
--!nocheck
local q = require("./core9")
local s = q.Source(0)
local function try(label, f) local ok, err = pcall(f); print(label, if ok then "→ 통과(no error)" else "→ error: " .. tostring(err)) end
local o1 = s:Observer(function() end)
try("WeakSubscribe 뒤 Unsubscribe", function() o1:WeakSubscribe(); o1:Unsubscribe() end)
local o2 = s:Observer(function() end)
try("Subscribe 뒤 WeakUnsubscribe", function() o2:Subscribe(); o2:WeakUnsubscribe() end)
local o3 = s:Observer(function() end)
try("Subscribe 두 번", function() o3:Subscribe(); o3:Subscribe() end)
local o4 = s:Observer(function() end)
try("구독 안 한 값에 Unsubscribe", function() o4:Unsubscribe() end)
local o5 = s:Observer(function() end)
try("구독 안 한 값에 WeakUnsubscribe", function() o5:WeakUnsubscribe() end)
local o6 = s:Observer(function() end)
try("Subscribe → Unsubscribe → Subscribe (재구독)", function() o6:Subscribe(); o6:Unsubscribe(); o6:Subscribe() end)
-- Effect: 내부 Observer는 WeakSubscribe만. Effect:Subscribe/Unsubscribe가 내부에 손대지 않는지
local n = 0
local e = q.Effect(function() n += 1 end, s)
try("Effect:Subscribe", function() e:Subscribe() end)
s:Set(1); print(" Effect 구독 뒤 Set → n", n, "(기대 2)")
try("Effect:Unsubscribe", function() e:Unsubscribe() end)
s:Set(2); print(" Effect 해제 뒤 Set → n", n, "(기대 2)")
-- 내부 Observer에 범용 정리 코드가 Unsubscribe를 부르면?
local inner; for _, v in pairs(e._deps) do inner = v end
try("Effect 내부 Observer에 Unsubscribe", function() inner:Unsubscribe() end)

View file

@ -0,0 +1,20 @@
--!nocheck
local q = require("./core9")
local r = q.Ref(nil)
local calls = {}
local function fn(v, ref) table.insert(calls, tostring(v) .. "/" .. tostring(ref == r)) end
r:WeakCallback(fn); r:Callback(fn) -- 같은 fn을 약·강 양쪽에
print("등록 즉시 호출:", table.concat(calls, ","), "(기대 nil/true 두 번 — 등록마다 1회)")
calls = {}
r:Set(7)
print("Set(7) 후 호출:", table.concat(calls, ","), "(기대 7/true 한 번 — 교차 dedup)")
r:Uncallback(fn); calls = {}
r:Set(8)
print("Uncallback 뒤 Set(8):", #calls, "회 (기대 0)")
-- thread 대기자 + 값→리비전→콜백 순서
local seenRev
local co = coroutine.create(function(ref) seenRev = ref.Revision end)
r.Callbacks[co] = true
local rev0 = r.Revision
r:Set(9)
print("thread가 본 Revision ~= 옛 리비전:", seenRev ~= rev0, "| 소진됨:", r.Callbacks[co] == nil)

View file

@ -0,0 +1,99 @@
--!nocheck
-- 레인 A: EffectHandle 상태 기계 — 모든 진입점 × 모든 상태의 전이를 실제 값으로 찍는다.
-- 열: installed / cleanup 보유 / .Subscribed / 강한 레지스트리 / 약한 레지스트리 / canExecute / running / pending
local q = require("./core10")
local function row(label, e, extra) print(("%-46s | %s%s"):format(label, q.effectState(e), if extra then " " .. extra else "")) end
local function try(f) local ok, err = pcall(f); return ok, err end
local S = q.Source(0)
local runs, cleans = 0, 0
local function mk(...) return q.Effect(function(self) runs += 1; return function() cleans += 1 end end, ...) end
print("== (1) 생성자 → Subscribe → Unsubscribe → Subscribe(재구독) → WeakUnsubscribe(교차 error) ==")
local e = mk(S)
row("생성 직후", e, ("runs=%d cleans=%d"):format(runs, cleans))
S:Set(1)
row("미구독 상태 Set(1) — 버려져야", e, ("runs=%d"):format(runs))
e:Subscribe()
row("Subscribe (Refresh 캐치업 → Rerun 1회)", e, ("runs=%d cleans=%d"):format(runs, cleans))
S:Set(2)
row("구독 중 Set(2)", e, ("runs=%d cleans=%d"):format(runs, cleans))
e:Unsubscribe()
row("Unsubscribe (cleanup 1회, installed=false)", e, ("runs=%d cleans=%d"):format(runs, cleans))
e:Subscribe()
row("재Subscribe (not installed → Rerun)", e, ("runs=%d cleans=%d"):format(runs, cleans))
print(" Subscribe 두 번:", select(2, try(function() e:Subscribe() end)))
print(" 강구독에 WeakUnsubscribe:", select(2, try(function() e:WeakUnsubscribe() end)))
e:Unsubscribe()
print("\n== (2) WeakSubscribe → WeakUnsubscribe(관대, cleanup 미소진) → 재WeakSubscribe ==")
local e2 = mk(S)
e2:WeakSubscribe()
row("WeakSubscribe", e2, ("runs=%d cleans=%d"):format(runs, cleans))
e2:WeakUnsubscribe()
row("WeakUnsubscribe — cleanup 유지, installed 유지", e2, ("runs=%d cleans=%d"):format(runs, cleans))
e2:WeakUnsubscribe()
row("WeakUnsubscribe 두 번 — 통과(H-133)", e2)
S:Set(3)
row("약해제 상태 Set(3) — 버려져야", e2, ("runs=%d"):format(runs))
e2:WeakSubscribe()
row("재WeakSubscribe (dep 변했음 → Rerun)", e2, ("runs=%d cleans=%d"):format(runs, cleans))
local e2b = mk()
e2b:WeakSubscribe(); e2b:WeakUnsubscribe(); local r0 = runs; e2b:WeakSubscribe()
row("deps 없음: Weak해제→재구독 (변화 없음 → no-op)", e2b, ("runs 변화=%d (기대 0)"):format(runs - r0))
print(" 약구독에 Unsubscribe:", select(2, try(function() e2:Unsubscribe() end)))
print("\n== (3) leaf: bindLifetime → unbindLifetime(포탈) → 재bind → destroy ==")
local inst, inst2 = q.newInst("inst"), q.newInst("inst2")
local e3 = mk(S)
q.bindLifetime(inst, e3)
row("bindLifetime (Refresh 캐치업: dep 변함 → Rerun)", e3, ("runs=%d cleans=%d"):format(runs, cleans))
print(" leaf에 Subscribe:", select(2, try(function() e3:Subscribe() end)))
print(" leaf에 WeakSubscribe:", select(2, try(function() e3:WeakSubscribe() end)))
print(" leaf에 Unsubscribe:", select(2, try(function() e3:Unsubscribe() end)))
print(" leaf에 WeakUnsubscribe(관대):", select(2, try(function() e3:WeakUnsubscribe() end)))
row(" ↑ 그 뒤 상태 (installed/cleanup 그대로여야)", e3)
print(" leaf를 다른 inst에 bind:", select(2, try(function() q.bindLifetime(inst2, e3) end)))
q.unbindLifetime(e3)
row("unbindLifetime — cleanup 미소진, installed 유지", e3, ("cleans=%d"):format(cleans))
S:Set(4)
row("언바인드 상태 Set(4) — 버려져야", e3, ("runs=%d"):format(runs))
q.bindLifetime(inst2, e3)
row("재bind(포탈) — Refresh true → Rerun", e3, ("runs=%d cleans=%d"):format(runs, cleans))
q.destroyInst(inst2)
row("inst2 destroy — Destroying → cleanup 1회", e3, ("cleans=%d"):format(cleans))
S:Set(5)
row("파괴 뒤 Set(5) — 버려져야", e3, ("runs=%d"):format(runs))
local inst3 = q.newInst("inst3")
q.bindLifetime(inst3, e3)
row("파괴 뒤 재bind — not installed → Rerun (H-65)", e3, ("runs=%d cleans=%d"):format(runs, cleans))
q.unbindLifetime(e3)
e3:Subscribe()
row("unbind 뒤 Subscribe로 전환 (허용)", e3, ("runs=%d cleans=%d"):format(runs, cleans))
e3:Unsubscribe()
print("\n== (4) fn 안 자기 해제 (H-143) ==")
local e4runs, e4cleans = 0, 0
local S4 = q.Source(0)
local e4; e4 = q.Effect(function(self)
e4runs += 1
if e4runs == 2 then self:Unsubscribe() end
return function() e4cleans += 1 end
end, S4)
e4:Subscribe()
S4:Set(6)
row("Set → fn 안 Unsubscribe → 반환 cleanup 즉시 소진", e4, ("e4cleans=%d (기대 2: 직전 소진 1 + 즉시 소진 1) runs=%d"):format(e4cleans, e4runs))
S4:Set(7)
row("죽은 뒤 Set(7) — 버려져야", e4, ("runs=%d"):format(e4runs))
print("\n== (5) fn 안 self:Rerun() (재진입 지연) ==")
local n5 = 0
local e5 = q.Effect(function(self) n5 += 1; if n5 == 1 then self:Rerun() end end)
row("생성자 안 자기 Rerun → pending으로 한 번 더", e5, ("n5=%d (기대 2)"):format(n5))
print("\n== (6) fn 안 dep Set (재진입) ==")
local n6, S6 = 0, q.Source(0)
local e6 = q.Effect(function(self) n6 += 1; if n6 == 2 then S6:Set(99) end end, S6)
e6:Subscribe(); S6:Set(1)
row("구독 중 Set → fn이 dep Set → pending 재실행", e6, ("n6=%d (기대 3)"):format(n6))
e6:Unsubscribe()

View file

@ -0,0 +1,53 @@
--!nocheck
-- H-147 재검토: 진입 시점에 이미 죽은 핸들에서 Rerun()이 불리면.
local q = require("./core10")
local S = q.Source(0)
local function row(l, e, x) print(("%-52s | %s %s"):format(l, q.effectState(e), x or "")) end
print("== 케이스 (1): 직전 cleanup이 self:Unsubscribe()를 부른다 ==")
local runs1, cleans1 = 0, 0
local e1; e1 = q.Effect(function(self)
runs1 += 1
return function() cleans1 += 1; if cleans1 == 1 then self:Unsubscribe() end end
end, S)
e1:Subscribe()
S:Set(1) -- Rerun: 루프 머리 _consumeCleanup → cleanup이 Unsubscribe → wasAlive=false → fn 실행(!)
row("Set 후", e1, ("runs=%d (죽은 뒤 fn이 돌았으면 2) cleans=%d"):format(runs1, cleans1))
S:Set(2)
row("다음 Set — 버려짐", e1, ("runs=%d"):format(runs1))
e1:Subscribe() -- 재구독: _installed가 true라 resubscribeTail이 Rerun을 생략(Refresh는 true)
row("재Subscribe — Refresh true라 Rerun은 돈다", e1, ("runs=%d cleans=%d"):format(runs1, cleans1))
e1:Unsubscribe()
row("Unsubscribe — 고아였던 cleanup이 여기서 소진되는가", e1, ("cleans=%d"):format(cleans1))
print("\n== 케이스 (2): Unsubscribe 뒤 타이머의 self:Rerun() ==")
local runs2, cleans2 = 0, 0
local e2 = q.Effect(function(self) runs2 += 1; return function() cleans2 += 1 end end, S)
e2:Subscribe(); e2:Unsubscribe()
row("Unsubscribe 직후", e2, ("runs=%d cleans=%d"):format(runs2, cleans2))
e2:Rerun() -- 타이머 흉내
row("죽은 핸들 Rerun() — fn 실행 + cleanup 저장", e2, ("runs=%d cleans=%d"):format(runs2, cleans2))
collectgarbage() -- 아무도 안 잡으면 cleanup은 영영 안 불린다(사용자가 e2를 잡고 있는 한 남아 있다)
e2:Subscribe()
row("재Subscribe — installed=true, Refresh false → Rerun 생략", e2, ("runs=%d cleans=%d"):format(runs2, cleans2))
e2:Unsubscribe()
row("Unsubscribe — 죽은 실행의 cleanup이 소진됨", e2, ("cleans=%d (소진됐으면 2)"):format(cleans2))
print("\n== 케이스 (3): leaf 파괴 뒤 타이머의 self:Rerun() ==")
local inst = q.newInst("inst")
local runs3, cleans3 = 0, 0
local e3 = q.Effect(function(self) runs3 += 1; return function() cleans3 += 1 end end, S)
q.bindLifetime(inst, e3)
q.destroyInst(inst)
row("destroy 직후", e3, ("runs=%d cleans=%d"):format(runs3, cleans3))
e3:Rerun()
row("죽은 leaf 핸들 Rerun()", e3, ("runs=%d cleans=%d BindData(gcconn dead)=%s"):format(runs3, cleans3, tostring(q.BindData[e3] ~= nil)))
local inst2 = q.newInst("inst2")
q.bindLifetime(inst2, e3)
row("재bind — installed=true라 Refresh 없으면 Rerun 생략", e3, ("runs=%d cleans=%d"):format(runs3, cleans3))
q.destroyInst(inst2)
row("inst2 destroy — 그 cleanup 소진", e3, ("cleans=%d"):format(cleans3))
print("\n== 케이스 (0) 대조군: 생성자 최초 Rerun은 죽은 게 아니라 '산 적 없음' ==")
local e0 = q.Effect(function() return function() end end)
row("생성 직후 (installed=true여야 — H-143 회귀 대조)", e0)

View file

@ -0,0 +1,100 @@
--!nocheck
-- 레인 A: state:Gate 유보 × Effect 재구독/재바인드 × EpochMap:Refresh / Peek / Update 조합
local q = require("./core10")
local function runs(e) return e._n end
local function mkEffect(...)
local e; e = q.Effect(function(self) self._n = (self._n or 0) + 1 end, ...)
return e
end
print("== (1) 게이트 유보 중 재구독: Refresh가 유보된 변경을 먼저 삼킨다 ==")
local A = q.Source(1)
local b = q.Blocker()
local gated = A:Block(b)
local e = mkEffect(gated)
e:Subscribe()
print("초기 runs", runs(e), "(기대 1)")
b:On()
A:Set(2) -- gated가 유보. Effect엔 아무것도 안 온다
print("블록 중 A:Set → runs", runs(e), "(기대 1) | gated withheld:", next(gated._withheld) ~= nil)
e:Unsubscribe(); e:Subscribe() -- 재구독 꼬리: Refresh()가 A의 새 리비전을 본다 → Rerun
print("재구독 후 runs", runs(e), "(기대 2 — 게이트가 잡고 있는데도 캐치업이 fn을 돌렸다)")
print(" fn이 읽는 gated:Get() =", gated:Get(), "(게이트는 값은 안 가림)")
b:Off() -- 유보 풀림 → batch {A} → Effect Update(A) → 이미 최신 → 접힘
print("Off 후 runs", runs(e), "(기대 2 — 접힘, 중복 없음)")
print("\n== (2) 재바인드(포탈) 캐치업도 게이트를 우회한다 ==")
local A2 = q.Source(1); local b2 = q.Blocker(); local g2 = A2:Block(b2)
local e2 = mkEffect(g2)
local inst = q.newInst("inst")
q.bindLifetime(inst, e2)
b2:On(); A2:Set(2)
q.unbindLifetime(e2)
local inst2 = q.newInst("inst2")
q.bindLifetime(inst2, e2)
print("블록 중 언바인드→재바인드 후 runs", runs(e2), "(기대 2 — 캐치업이 게이트를 우회)")
b2:Off()
print("Off 후 runs", runs(e2), "(기대 2)")
print("\n== (3) emit(false) 버리기: Effect가 캐치업으로 이미 돌았다면? ==")
local A3 = q.Source(1); local g3 = A3:Gate(function(emit)
return function() end -- 항상 유보(정책이 emit을 안 부름)
end)
local flush3 = nil
-- 정책 밖에서 emit 핸들을 얻으려고 Gate를 다시 감싼다
local A3b = q.Source(1); local disc
local g3b = A3b:Gate(function(emit) disc = emit; return function() end end)
local e3 = mkEffect(g3b); e3:Subscribe()
A3b:Set(2)
e3:Unsubscribe(); e3:Subscribe()
print("유보 중 재구독 runs", runs(e3), "(기대 2)")
print("emit(false) 반환:", disc(false), "(버릴 게 있었으니 true)")
A3b:Set(3)
print("버린 뒤 새 Set → runs", runs(e3), "(기대 2 — 여전히 유보 중)")
print("emit() 반환:", disc(), "→ runs", runs(e3), "(기대 3)")
print("\n== (4) 다이아몬드: A→gated, A→plain, Effect(fn, gated, plain) — 비게이트 경로가 게이트를 새게 한다 ==")
local A4 = q.Source(1); local b4 = q.Blocker()
local g4 = A4:Block(b4)
local p4 = A4:Compute(function(s) return s:Get() end)
local e4 = mkEffect(g4, p4); e4:Subscribe()
b4:On(); A4:Set(2)
print("블록 중 A:Set → runs", runs(e4), "(기대 2 — plain 경로가 Effect를 깨웠다; 게이트는 무의미)")
b4:Off()
print("Off 후 runs", runs(e4), "(기대 2 — 접힘)")
print("\n== (5) 게이트 위의 다이아몬드: A→B→G, A→C→G, 유보 중 두 번 도착 → 정책 두 번, 집합 하나 ==")
local A5 = q.Source(1)
local B5 = A5:Compute(function(s) return s:Get() end)
local C5 = A5:Compute(function(s) return s:Get() end)
local M5 = B5:With(C5) -- fan-in
local policyCalls = 0
local g5 = M5:Gate(function(emit) return function() policyCalls += 1 end end)
local o5 = g5:Observer(function() end); o5:Subscribe()
A5:Set(2)
local cnt = 0; for _ in pairs(g5._withheld) do cnt += 1 end
print("A:Set 한 번 → 정책 호출", policyCalls, "(다이아몬드가 M5에서 접히면 1) | withheld 크기", cnt)
print("\n== (6) 게이트 → 게이트 (unfold) 유보 후 각각 풀기 ==")
local A6 = q.Source(1); local b6a, b6b = q.Blocker(), q.Blocker()
local g6a = A6:Block(b6a); local g6b = g6a:Block(b6b)
local fires = 0
local o6 = g6b:Observer(function(_, _, from) if from ~= nil then fires += 1 end end); o6:Subscribe()
b6a:On(); b6b:On(); A6:Set(2); A6:Set(3)
b6a:Off()
print("안쪽 게이트 풀림 → 바깥 withheld에 합류, 발화", fires, "(기대 0)")
b6b:Off()
print("바깥 풀림 → 발화", fires, "(기대 1)")
A6:Set(4)
print("둘 다 열린 뒤 Set → 발화", fires, "(기대 2)")
print("\n== (7) 게이트 유보 중 하류 Get()으로 앞당겨 읽기 → 풀릴 때 규칙 2(통지만) ==")
local A7 = q.Source(1); local b7 = q.Blocker(); local g7 = A7:Block(b7)
local d7 = g7:Compute(function(s) return s:Get() * 10 end)
local n7 = 0
local o7 = d7:Observer(function() n7 += 1 end); o7:Subscribe()
b7:On(); A7:Set(2)
print("유보 중 d7:Get() =", d7:Get(), "(기대 20 — 값은 안 가림) recomputes", d7.recomputes)
local rc = d7.recomputes
b7:Off()
print("Off 후 Observer 발화", n7, "(기대 1 — 통지는 온다) | 재계산 추가", d7.recomputes - rc, "(기대 0 — 규칙 2)")

View file

@ -0,0 +1,69 @@
--!nocheck
-- 레인 A: `_hold` 불변식(하류→상류 강함, 상류→하류 weak) 실측 — 중간 State가 수거되지 않는가 + 음성 대조군
local q = require("./core10")
local A = q.Source(1)
local fired = 0
local inst = q.newInst("inst")
do
-- A → B → C → Observer(leaf) — B/C를 아무도 로컬로 안 든다
local o = A:Compute(function(s) return s:Get() + 1 end)
:Compute(function(s) return s:Get() * 2 end)
:Observer(function(t) fired += 1; t:Get() end)
q.bindLifetime(inst, o)
end
collectgarbage(); collectgarbage()
local before = fired
A:Set(5)
print("(양성) 체인 GC 뒤 A:Set → Observer 발화", fired - before, "(기대 1 — 중간 State가 _hold로 살아남음)")
-- 음성 대조군: 말단이 아무 데도 안 묶이면 통째로 수거된다
local weakProbe = setmetatable({}, {__mode = "k"})
do
local mid = A:Compute(function(s) return s:Get() end)
weakProbe[mid] = true
local o2 = mid:Observer(function() end) -- 미바인드·미구독
weakProbe[o2] = true
end
collectgarbage(); collectgarbage()
local alive = 0; for _ in pairs(weakProbe) do alive += 1 end
print("(음성) 말단 미바인드 체인 GC 뒤 생존 객체", alive, "(기대 0 — 상류→하류가 weak라 수거)")
-- 상류(A)의 구독자 집합이 수거된 자식을 자동으로 잊는가
local subs = 0; for _ in pairs(A._subs) do subs += 1 end
print("A._subs 크기", subs, "(기대 1 — 살아있는 leaf 체인의 B만)")
-- Effect: deps 강한 맵 + 내부 Observer; Effect가 leaf에 묶여 있으면 dep 체인 생존
local fired2 = 0
do
local e = q.Effect(function() fired2 += 1 end,
A:Compute(function(s) return s:Get() end):Compute(function(s) return s:Get() end))
q.bindLifetime(inst, e)
end
collectgarbage(); collectgarbage()
local b2 = fired2
A:Set(6)
print("(양성) Effect deps 체인 GC 뒤 A:Set → fn", fired2 - b2, "(기대 1)")
-- Effect를 약하게만 구독하고 참조를 놓으면 통째로 수거(문서화된 UB — cleanup 안 불림)
local cleans = 0
do
local e = q.Effect(function() return function() cleans += 1 end end, A)
e:WeakSubscribe()
weakProbe[e] = true
end
collectgarbage(); collectgarbage()
alive = 0; for _ in pairs(weakProbe) do alive += 1 end
print("(문서화된 UB) WeakSubscribe만 한 Effect 참조 놓기 → 생존", alive, "cleanup 호출", cleans, "(기대 0, 0)")
-- 중간 GateNode도 _hold로 산다
local fired3 = 0
do
local b = q.Blocker()
local o = A:Block(b):Compute(function(s) return s:Get() end):Observer(function() fired3 += 1 end)
q.bindLifetime(inst, o)
-- b(Blocker)는 여기서 놓는다 — 게이트 노드는 살아야 하고, Blocker의 handles는 weak
end
collectgarbage(); collectgarbage()
local b3 = fired3
A:Set(7)
print("(양성) A→Gate→Compute→Observer, Blocker 참조 놓음 → 발화", fired3 - b3, "(기대 1 — 통과 모드)")

View file

@ -0,0 +1,37 @@
--!nocheck
-- 레인 A: (a) Effect 생성자의 사적 `_blocker`가 실제로 무엇을 막는가 — fire 가드 판정을 전부 기록
-- (b) Source:Set 동일값 emit(H-68) × state:Block × Effect/Observer 발화 수
local q = require("./core10")
local trace = {}
q.effectTrace = function(e, why) trace[why] = (trace[why] or 0) + 1 end
local A = q.Source(1)
local r = q.Ref(0)
local B = A:Compute(function(s) return s:Get() end)
local e = q.Effect(function() end, A, B, r) -- Source + State + Ref dep
print("생성자 동안 fire 가드 판정:", "canExecute 드롭 =", trace["drop:canExecute"] or 0, "| blocker 드롭 =", trace["drop:blocker"] or 0)
e:Subscribe()
A:Set(2); r:Set(1)
print("구독 뒤 발화 중 blocker 드롭 =", trace["drop:blocker"] or 0, "(생성자 밖에선 blocker가 항상 Off)")
print("→ `_blocker`가 실제로 거른 발화 =", trace["drop:blocker"] or 0, "(0이면 canExecute 가드가 전부 먼저 걸렀다는 뜻)")
e:Unsubscribe()
q.effectTrace = nil
print("\n== 동일값 Set × Block × 발화 수 ==")
local S = q.Source(5)
local n = 0
local o = S:Observer(function() n += 1 end); o:Subscribe()
S:Set(5); S:Set(5)
print("같은 값 Set 두 번 → Observer 발화", n, "(기대 2 — H-68)")
local b = q.Blocker(); local g = S:Block(b)
local m = 0
local o2 = g:Observer(function() m += 1 end); o2:Subscribe()
b:On(); S:Set(5); S:Set(5); S:Set(5)
local cnt = 0; for _ in pairs(g._withheld) do cnt += 1 end
print("블록 중 같은 값 Set 세 번 → withheld 크기", cnt, "(기대 1 — 집합)")
b:Off()
print("Off → gated Observer 발화", m, "(기대 1)")
local e2n = 0
local e2 = q.Effect(function() e2n += 1 end, g); e2:Subscribe()
b:On(); S:Set(5); S:Set(5); b:Off()
print("Effect(gated): 블록 중 두 번 Set → Off → fn", e2n, "(기대 2 = 설치 1 + 배치 1)")

View file

@ -0,0 +1,24 @@
--!nocheck
-- H-149 재검토: Observer:Subscribe의 콜론 위임 때문에 error level 2가 어느 줄을 가리키는가
local q = require("./core10")
local S = q.Source(0)
local inst = q.newInst("inst")
local o = S:Observer(function() end)
q.bindLifetime(inst, o)
local ok, err = pcall(function()
o:Subscribe() -- ← 사용자 호출부 (이 파일의 줄)
end)
print("Observer(leaf):Subscribe →", err)
local o2 = S:Observer(function() end)
q.bindLifetime(inst, o2)
local ok2, err2 = pcall(function()
o2:WeakSubscribe() -- ← 사용자 호출부
end)
print("Observer(leaf):WeakSubscribe →", err2)
-- 대조: EffectHandle은 인라인이라 사용자 줄을 가리킨다
local e = q.Effect(function() end)
q.bindLifetime(inst, e)
local ok3, err3 = pcall(function()
e:Subscribe() -- ← 사용자 호출부
end)
print("Effect(leaf):Subscribe →", err3)

View file

@ -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, ","))

View file

@ -0,0 +1,17 @@
--!nocheck
-- 레인 A: leaf Effect의 fn이 자기 inst를 동기 파괴(Immediate 모드 흉내) → H-143 판정이 잡는가
local q = require("./core10")
local inst = q.newInst("inst")
local S = q.Source(0)
local cleans, runs = 0, 0
local e = q.Effect(function(self)
runs += 1
if runs == 2 then q.destroyInst(inst) end -- 두 번째 실행에서 자기 inst 파괴
return function() cleans += 1 end
end, S)
q.bindLifetime(inst, e)
print("bind 후", q.effectState(e), "runs", runs, "cleans", cleans)
S:Set(1)
print("fn 안 destroy 후", q.effectState(e), "runs", runs, "cleans", cleans, "(기대 cleans=2: 루프 머리 소진 1 + 즉시 소진 1)")
S:Set(2)
print("파괴 뒤 Set", "runs", runs, "(기대 2)")

View file

@ -0,0 +1,49 @@
--!nocheck
-- 레인 A: Store 구현 스케치의 "그림자 테이블"이 store 자신인지 별도인지에 따라 `Of("Of")` 런타임 거동이 어떻게 갈리는가
-- (타입 함수는 정적 이름만 잡고, 동적 `Of(name)`은 이름이 타입에 안 실린다 — store-plan.md 스스로 적어둔 갭)
local function Source(v) return { _v = v, Get = function(s) return s._v end, Set = function(s, x) s._v = x end, __isSource = true } end
local function isSource(x) return type(x) == "table" and x.__isSource == true end
-- 구현 (I): 그림자 = store 자신(메타테이블 __index에 메소드) — "store.key는 평범한 레코드 필드"에 맞는 모양
local StoreMT_I = {}; StoreMT_I.__index = StoreMT_I
function StoreMT_I:Of(name)
local src = self[name] -- 스케치 그대로 `shadow[name]`
if src == nil then src = Source(nil); self[name] = src end
return src
end
function StoreMT_I:Names() local t = {}; for k in pairs(self) do t[#t+1] = k end; return t end
local function StoreI(defaults)
for k, v in pairs(defaults or {}) do if not isSource(v) then error(("quad.Store: defaults.%s is not a Source"):format(k), 2) end end
return setmetatable(table.clone(defaults or {}), StoreMT_I)
end
-- 구현 (II): 그림자 별도 + store 프록시(__index → shadow) — "그림자 테이블"이라는 단어에 맞는 모양
local function StoreII(defaults)
local shadow = table.clone(defaults or {})
local methods = {}
function methods:Of(name)
local src = shadow[name]
if src == nil then src = Source(nil); shadow[name] = src end
return src
end
function methods:Names() local t = {}; for k in pairs(shadow) do t[#t+1] = k end; return t end
return setmetatable({}, { __index = function(_, k) local m = methods[k]; if m ~= nil then return m end; return shadow[k] end })
end
for label, ctor in pairs({ ["(I) store==shadow"] = StoreI, ["(II) proxy→shadow"] = StoreII }) do
print("== " .. label .. " ==")
local s = ctor({ hp = Source(1) })
local ok, err = pcall(function()
local x = s:Of("Of") -- 동적 예약 이름
print(" Of('Of') 반환 타입:", type(x), isSource(x) and "Source" or "NOT a Source")
local y = s:Of("hp2") -- 이후 정상 Of가 아직 되는가
print(" 이후 Of('hp2'):", if isSource(y) then "OK" else "BROKEN")
end)
if not ok then print(" error:", err) end
local ok2, err2 = pcall(function() return s:Names() end)
print(" Names():", ok2 and table.concat(err2, ",") or ("error " .. tostring(err2)))
end
-- defaults에 예약 이름(타입은 잡지만 --!nocheck/동적이면 통과)
print("== defaults = { Of = Source(1) } ==")
local s3 = StoreI({ Of = Source(1) })
print(" isSource 검증은 통과 → s3:Of 는", type(s3.Of), "(메소드가 가려졌다)")
print(" s3:Of('x') →", select(2, pcall(function() return s3:Of("x") end)))

View file

@ -0,0 +1,22 @@
--!nocheck
-- H-152 재현: `GateNode`가 `StateBrand`(여기선 메타테이블 판별)에 등록되지 않으면 `_emitDown`의
-- `isState(sub)` 분기가 거짓 → `canExecute(sub)`로 떨어져(게이트는 bind/Subscribe된 적 없음) 조용히 건너뜀.
-- 값(`:Get()`)은 멀쩡하고 통지만 죽는다 — "값은 맞는데 아무도 안 깨어나는" 모드.
local q = require("./core10")
local function scenario(label, dropBrand)
local s = q.Source(1)
local b = q.Blocker()
local g = s:Block(b) -- GateNode
if dropBrand then
local mt = getmetatable(g); local copy = table.clone(mt); copy.__index = mt.__index
setmetatable(g, copy) -- 같은 메소드, 다른 identity → isState(g) == false
end
local fires = 0
local o = g:Observer(function() fires += 1 end); o:Subscribe()
fires = 0
s:Set(2); s:Set(3)
print(("%s: isState(g)=%s | Observer 발화 %d (기대 2) | g:Get()=%d (값은 항상 최신)"):format(
label, tostring(q.isState(g)), fires, g:Get()))
end
scenario("브랜드 등록됨 ", false)
scenario("브랜드 누락(재현)", true)

View file

@ -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에 대한 통지는 영영 오지 않았다. 마지막 값만 보임")

View file

@ -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(), ")")

View file

@ -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")

View file

@ -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(), "(회복됨)")

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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이어야 함)")

View file

@ -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("하류 먼저")

View file

@ -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

View file

@ -0,0 +1,57 @@
--!strict
-- 레인 A/D: 최종형 Store — §1② 쪼개기 선언(재귀 `Compute -> State<U>` 포함)의 `Source<T>` 필드 +
-- `CheckReservedKeys<keyof<T>>` 팬텀 필드 + 빈 `{}` + `Of<<T>>` — luau-analyze로 한 번에.
-- 기대: (A)(B)(C)(E) 진단 0, (D)(F)(G) 정확히 걸림, (H) 예약 키 print 진단.
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 Source<T> = State<T> & { Set: (self: Source<T>, v: T) -> Source<T>, Revision: number }
type function CheckReservedKeys(keys: type)
local list = if keys:is("union") then keys:components() else { keys }
for _, k in list do
if k:is("singleton") then
local v = k:value()
if v == "Of" or v == "Names" or v == "__reservedCheck" then
print(`quad.Store: "{v}" is a reserved key`)
return types.never
end
end
end
return types.singleton(true)
end
type Store<T> = T & {
Of: <U>(self: any, name: string) -> Source<U>,
Names: (self: any) -> { string },
__reservedCheck: CheckReservedKeys<keyof<T>>,
}
local function Store<T>(defaults: T?): Store<T> return (nil :: any) end
local function Source<T>(v: T): Source<T> return (nil :: any) end
-- (A) 키 있는 최종형
local s = Store({ hp = Source(100), name = Source("x") })
local a1: Source<number> = s.hp
local a2: number = s.hp:Get()
s.hp:Set(5)
-- (B) 무주석 콜백 파라미터 추론 생존
local b1 = s.hp:Compute(function(x) return x:Get() * 2 end)
-- (C) Of<<T>>
local c1: Source<boolean> = s:Of("dyn")
local c2: Source<number> = s:Of("dyn2")
local c3: Source<boolean> = s:Of<<boolean>>("z")
local c4: Source<number> = s:Of<<boolean>>("z2") -- 음성: 명시 인스턴스화면 걸려야
-- (D) 음성: 틀린 타입
local d1: Source<string> = s.hp
local d2: string = s.hp:Get()
-- (E) 빈 Store
local empty = Store({} :: {})
local e1: { string } = empty:Names()
local e2: Source<number> = empty:Of("dyn")
-- (F) 선언 안 된 키
local f1 = s.nope
-- (G) Compute 반환 타입 명시 주석 구멍(§1) — 이 줄은 조용히 통과할 것(기대: 진단 없음 — 알려진 한계)
local g1: State<string> = s.hp:Compute(function(x) return x:Get() * 2 end)
-- (H) 예약 키 충돌 — 타입 함수 진단
local bad = Store({ Of = Source(1) })
local h1 = bad.Of

View file

@ -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)

View file

@ -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)

View file

@ -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})

View file

@ -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) -- 아무것도 안 돌려줌 (가장 흔한 모양)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,143 @@
# 7라운드 손 트레이싱 — 참조 구현과 스파이크 (2026-08-25 실측)
**무엇인가**: `.claude/qa-request/pre-implementation-handtrace-round7.md`
**4·5·6차 패스**가 발견을 재현하는 데 쓴 코드 전량. `audit/`의 다른 폴더와
같은 성격이고(계획이 아니라 **실측 결과 기록**), `type-recursion-issue/`처럼
**스크립트를 같이 두는 구성**이다 — 판정이 "문서대로 짠 것을 돌려본 결과"라
개별 파일을 직접 돌려야 재현된다.
**⚠️⚠️ 이 코드는 문서가 아니라 문서의 *전사물*이다.** `spikes/core.luau` ·
`spikes/dispatch.luau` · `spikes/chain.luau``base/`의 확정 의사코드를
**손으로 옮긴 것**이고, 옮기는 과정에서 틀렸을 수 있다. 그래서 여기서 나온
발견을 검증할 때 **재실행은 검증이 아니다** — 재실행은 "전사물이 그렇게
동작한다"만 말해준다. **반드시 아래 "대조표"를 따라 원문 의사코드와 줄 단위로
대조할 것.** 대조에서 전사 오류가 나오면 그 발견은 quad의 결함이 아니라 이
폴더의 결함이다.
**환경**: `luau` / `luau-analyze`
(`~/.local/share/mise/installs/luau/latest`). 저장소 루트에서
`cd .claude/audit/handtrace-round7-reference-impl/spikes && luau <파일>`.
런타임 스파이크는 `luau`, `ty*``luau-analyze`로 돌린다.
**실행 결과 스냅샷**: `RUN-runtime.txt`(런타임 18개), `RUN-typecheck.txt`
(타입 11개). 2026-08-25 실행분 그대로이고, 재실행이 이와 달라지면 그 자체가
조사 대상이다.
## ⚠️⚠️ [2026-08-26] 이 전사물은 **8라운드 이전 계약**이다
8라운드(`qa-request/pre-implementation-handtrace-round8.md`)가 바로 이
전사물이 돌지 **않은** 경로들에서 결함을 찾아냈고, 그 결과 **여기 옮겨진
계약 몇 개가 바뀌었다**(결정의 소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`). 이 폴더는
2026-08-25 실측의 기록이라 **본문을 소급 수정하지 않는다** — 대신 재실행할 때
아래를 알고 볼 것. **바뀐 계약을 이 코드로 "재확인"하지 말 것.**
| 여기 있는 것 | 지금 확정된 것 | 근거 |
|---|---|---|
| `core.luau``Observer:_receive``self.fn(self, from)`**2-인자**. `t9_gate_chain.luau` 등 Observer를 쓰는 스파이크 전부가 이 위에서 돈다 | **3-인자** `fn(targetState, self, emitFrom)`, 루프는 `sub.fn(sub._state, sub, from)`. `observer._state` 강참조 신설 | `H-109`/`H-110` |
| `core.luau`의 State가 `rawInvalid: boolean` | **캐시 카운터 쌍**(`cacheTargetCount`/`cacheCurrCount`) — 7라운드 `H-85`가 이미 교체했는데 전사물이 옛 필드로 남았다. `t5_recompute_race`/`t5b_fix`는 **의도적**으로 이 필드의 버그·기각안을 보여주는 것이라 예외 | `H-85`(7라운드) |
| `core.luau`/`dispatch.luau`의 부기가 **단일 `bk.invalidAfter`** | **두 필드로 갈라졌다**`bk.offsetCacheValidUpTo`(캐시)와 `bk.offsetSetUpTo`(`:Set` 완료). 옛 단일 필드가 두 뜻을 겸한 게 되감기 신호가 지워지는 원인이었다 | `/code-review high` 4차 |
| `d7_splice_fix.luau:14`의 splice 무효화가 `math.min(bk.invalidAfter, index)` | splice는 **`index - 1`**(커서 위치 splice가 "변경 없음"과 구분이 안 된다). `dispatch.luau:84``setLength``math.min(…, i)`**정정 대상이 아니다** — 그쪽 공식은 원래 `i`다 | `H-113` |
| `Ref`를 쓰는 자리의 콜백 호출 | `fn(value, ref)` — 두 번째 인자가 `Ref` 자신(= `Epoch`). `:Set` 순서는 **값 → `Revision` → 콜백**, 순회는 `.Callbacks` + `.WeakCallbacks` | `H-107`/`H-108` |
| **[2026-08-27 추가, 9라운드]** `dispatch.luau``recompute``lengthList[i]`를 읽고 누적한 **뒤** 되감기를 판정 | 되감기 판정이 **먼저**(`continue`), 읽기·누적은 되감지 않을 때만 — 옛 순서는 커서 뒤 자리 수가 줄면 `sum += nil` | `H-124`/Q1 |
| **[2026-08-27 추가]** `dispatch.luau``setLength``box.pos`(가변 박스)로 위치를 캡처, 8라운드 전사물엔 `bk.tokens`/`indexOfToken` | **토큰 폐기** — 5번째 인자 `element`를 캡처해 `bk.indexOfElement[element]` 조회. `slot._elemIndex`도 이 맵으로 통합 | `H-141`/Q3 |
| **[2026-08-27 추가]** `newSlot``Offset`을 만들고 `materializeSlotTree` 상당이 `setOffsetSource` 뒤에 관측자를 붙임 | `Offset`·`_baseObserver`는 **생성자**에서, 순서는 `blocker:On()``bindLifetime``setOffsetSource` | `H-125`/Q2 |
**`d7_splice_fix.luau`의 결론(`H-102`가 역참조 *조회*로 닫힌다)은 영향받지
않는다** — 그 테스트는 recompute가 끝난 뒤 splice하는 시나리오라 `H-113`
문제 삼은 "루프 커서 위치에서의 splice" 경계를 애초에 안 밟는다. 공식만 낡았다.
(**[2026-08-27 정정]** 여기 한때 *"토큰 역참조로 닫힌다"*였는데, 토큰은 9라운드
`H-141`/Q3로 폐기됐다 — 지금은 **요소 캡처 + `bk.indexOfElement` 조회**다. 결론
("캡처 말고 조회")은 그대로다. 9라운드 전사물(요소 키)은 세션 스크래치패드
`ref9/`에 있고 근거는 `qa-request/pre-implementation-handtrace-round9.md`
인라인 전사돼 있다.)
---
## 참조 구현 3개 — 원문 대조표
각 파일이 어느 절을 옮긴 것인지. **검증 패스는 이 표의 왼쪽(전사물)과
오른쪽(원문)을 대조해야 한다.**
| 파일 | 옮긴 대상 |
|---|---|
| `spikes/core.luau` | `base/state-epoch-plan.md` §2(`Epoch`/리비전 갱신) · §3(`EpochMap`의 `Update`/`Refresh`/`Sync`/`TrackFrom`) · §4(수신 규칙 1~3, 재계산 판정, 재계산 후 처리) · §5(emit 페이로드) / `base/source-state-plan.md`의 전파 모델과 `:With`/`:Compute` 노드 / `base/gate-plan.md` 4·8번(`withheld`, flush 시 스왑, 빈 배치 무통지, 게이트-게이트 unfold) / `base/blocker-plan.md`(`On`/`Off`/`OffWithoutEmit`/`IsOn`/`Policy`) / `ROADMAP.md` M2의 `H-23`(구독자 스냅샷) |
| `spikes/dispatch.luau` | `base/dispatch-core-plan.md``Dispatch.getOffsetAt`(접두합 캐시) · `recompute` · `Dispatch.setLength` · `Dispatch.setOffsetSource` · 배치 게이팅(`getBlocker`) |
| `spikes/chain.luau` | `base/dispatch-core-plan.md``Dispatch.process`(하강 diff (A)/(B) 분기) · `Dispatch.retractFrom` |
### 문서와 **의도적으로 다른** 곳 (전사 오류가 아님)
대조할 때 이 셋은 차이로 세지 말 것. 그 외의 모든 차이는 전사 오류 후보다.
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/`로 갈 수 있고, 그건 그때 판단할 일이다.

View 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

View file

@ -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`

View file

@ -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

View 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

View file

@ -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())

View file

@ -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)")

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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, "| 그러나 다시는 안 울린다")

View file

@ -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, ","))

View file

@ -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에 대한 통지는 영영 오지 않았다. 마지막 값만 보임")

View file

@ -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(), ")")

View file

@ -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")

View file

@ -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(), "(회복됨)")

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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이어야 함)")

Some files were not shown because too many files have changed in this diff Show more