Compare commits

..

No commits in common. "f020f3f36f5d9935582dae0959d80bddac362117" and "068971fd56a0cdd4de5f45849df7f1669ca7033d" have entirely different histories.

198 changed files with 1284 additions and 23906 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,72 +0,0 @@
# [역전됨] `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,13 +3,6 @@
> **⛔ [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,78 +1217,3 @@ 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

@ -1,355 +0,0 @@
# [검토 후 철회] 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

@ -1,98 +0,0 @@
# 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

@ -1,46 +0,0 @@
--!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

@ -1,209 +0,0 @@
--!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

@ -1,476 +0,0 @@
--!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

@ -1,307 +0,0 @@
--!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

@ -1,26 +0,0 @@
--!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

@ -1,26 +0,0 @@
--!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

@ -1,26 +0,0 @@
--!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

@ -1,26 +0,0 @@
--!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

@ -1,24 +0,0 @@
--!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

@ -1,18 +0,0 @@
--!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

@ -1,18 +0,0 @@
--!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

@ -1,13 +0,0 @@
--!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

@ -1,13 +0,0 @@
--!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

@ -1,30 +0,0 @@
--!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

@ -1,40 +0,0 @@
--!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

@ -1,13 +0,0 @@
--!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

@ -1,12 +0,0 @@
--!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

@ -1,12 +0,0 @@
--!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

@ -1,40 +0,0 @@
--!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

@ -1,50 +0,0 @@
--!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

@ -1,34 +0,0 @@
--!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

@ -1,42 +0,0 @@
--!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

@ -1,23 +0,0 @@
--!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

@ -1,31 +0,0 @@
--!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

@ -1,49 +0,0 @@
--!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

@ -1,39 +0,0 @@
--!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

@ -1,11 +0,0 @@
--!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

@ -1,115 +0,0 @@
--!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

@ -1,587 +0,0 @@
--!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

@ -1,289 +0,0 @@
--!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

@ -1,14 +0,0 @@
--!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

@ -1,28 +0,0 @@
--!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

@ -1,17 +0,0 @@
--!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

@ -1,9 +0,0 @@
--!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

@ -1,4 +0,0 @@
--!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

@ -1,4 +0,0 @@
--!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

@ -1,25 +0,0 @@
--!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

@ -1,42 +0,0 @@
--!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

@ -1,26 +0,0 @@
--!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

@ -1,20 +0,0 @@
--!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

@ -1,99 +0,0 @@
--!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

@ -1,53 +0,0 @@
--!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

@ -1,100 +0,0 @@
--!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

@ -1,69 +0,0 @@
--!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

@ -1,37 +0,0 @@
--!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

@ -1,24 +0,0 @@
--!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

@ -1,15 +0,0 @@
--!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

@ -1,17 +0,0 @@
--!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

@ -1,49 +0,0 @@
--!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

@ -1,22 +0,0 @@
--!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

@ -1,26 +0,0 @@
--!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

@ -1,47 +0,0 @@
--!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

@ -1,84 +0,0 @@
--!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

@ -1,21 +0,0 @@
--!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

@ -1,20 +0,0 @@
--!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

@ -1,28 +0,0 @@
--!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

@ -1,40 +0,0 @@
--!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

@ -1,17 +0,0 @@
--!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

@ -1,27 +0,0 @@
--!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

@ -1,20 +0,0 @@
--!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

@ -1,57 +0,0 @@
--!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

@ -1,28 +0,0 @@
--!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

@ -1,23 +0,0 @@
--!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

@ -1,27 +0,0 @@
--!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

@ -1,46 +0,0 @@
--!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

@ -1,23 +0,0 @@
--!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

@ -1,31 +0,0 @@
--!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

@ -1,16 +0,0 @@
--!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

@ -1,12 +0,0 @@
--!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

@ -1,41 +0,0 @@
--!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

@ -1,28 +0,0 @@
--!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

@ -1,143 +0,0 @@
# 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

@ -1,123 +0,0 @@
=== 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

@ -1,94 +0,0 @@
=== 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

@ -1,46 +0,0 @@
--!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

@ -1,209 +0,0 @@
--!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

@ -1,12 +0,0 @@
--!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

@ -1,23 +0,0 @@
--!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

@ -1,31 +0,0 @@
--!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

@ -1,49 +0,0 @@
--!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

@ -1,39 +0,0 @@
--!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

@ -1,11 +0,0 @@
--!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

@ -1,115 +0,0 @@
--!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

@ -1,14 +0,0 @@
--!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

@ -1,25 +0,0 @@
--!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

@ -1,15 +0,0 @@
--!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

@ -1,26 +0,0 @@
--!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

@ -1,47 +0,0 @@
--!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

@ -1,84 +0,0 @@
--!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

@ -1,21 +0,0 @@
--!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

@ -1,20 +0,0 @@
--!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

@ -1,28 +0,0 @@
--!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

@ -1,40 +0,0 @@
--!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

@ -1,17 +0,0 @@
--!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