Compare commits
No commits in common. "f020f3f36f5d9935582dae0959d80bddac362117" and "068971fd56a0cdd4de5f45849df7f1669ca7033d" have entirely different histories.
f020f3f36f
...
068971fd56
198 changed files with 1284 additions and 23906 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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는 등록 안 됨" 상태가 생기면
|
|
||||||
안 됨).
|
|
||||||
|
|
||||||
|
|
@ -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가 자기가 만든 트리에 대해서만 성립한다고 전제하고 세운 부기를
|
||||||
|
|
|
||||||
|
|
@ -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 배너에도 있다.
|
|
||||||
|
|
|
||||||
|
|
@ -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`에 그대로 있다.
|
|
||||||
|
|
@ -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`의 체인만 쓴다.
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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, " "))
|
|
||||||
|
|
@ -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, " "))
|
|
||||||
|
|
@ -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, " "))
|
|
||||||
|
|
@ -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, " "))
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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, " "))
|
|
||||||
|
|
@ -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, " "))
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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)))
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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 -- 더 받는 쪽: 에러여야 정상
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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, "| 그러나 다시는 안 울린다")
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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 — 통과 모드)")
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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, ","))
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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)))
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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에 대한 통지는 영영 오지 않았다. 마지막 값만 보임")
|
|
||||||
|
|
@ -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(), ")")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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(), "(회복됨)")
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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이어야 함)")
|
|
||||||
|
|
@ -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("하류 먼저")
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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})
|
|
||||||
|
|
@ -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) -- 아무것도 안 돌려줌 (가장 흔한 모양)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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/`로 갈 수 있고, 그건 그때 판단할 일이다.
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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)")
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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, "| 그러나 다시는 안 울린다")
|
|
||||||
|
|
@ -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, ","))
|
|
||||||
|
|
@ -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에 대한 통지는 영영 오지 않았다. 마지막 값만 보임")
|
|
||||||
|
|
@ -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(), ")")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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(), "(회복됨)")
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue