qa: 10라운드 광범위 탐사 완료 — 발견 H-150~H-157 (🔴 0 / 🟡 5 / 🟢 3), §4 배치 문항 7건 회신 대기

- 신선한 탐사자(fable) 단일 컨텍스트, 지시서 -round10-brief.md §2 레인 A·C 완료, B 부분, D ALL PASS
- audit/handtrace-round10-reference-impl/: round7 참조 구현을 현재 계약으로 갱신·재실행
  (부수: round7/ref9 _recompute 첫 인자 오류 발견·정정)
- base/·인덱스 레이어는 미변경 — 결정은 사용자 배치 회신 뒤 -round10-followup.md로

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
This commit is contained in:
qwreey 2026-08-28 01:03:05 +09:00
parent 0ec22fbe73
commit d2d67c7aeb
Signed by: qwreey
GPG key ID: D28DB79297A214BD
74 changed files with 4473 additions and 9 deletions

File diff suppressed because one or more lines are too long

View file

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

View file

@ -0,0 +1,46 @@
--!nocheck
-- base/dispatch-core-plan.md "Dispatch 체인" 절의 확정 의사코드 그대로
local C = {}
local chains = {}
local NOOP = function() end
local handlers = {}
function C.addHandler(h) table.insert(handlers, h) end
local function getHandler(inst, k, v)
for _, h in handlers do if h.isHandlable(inst, k, v) then return h end end
error("Dispatch: 매치되는 핸들러 없음")
end
function C.process(inst, k, v, index)
local list = chains[inst] and chains[inst][k]
if not list then
list = {}
chains[inst] = chains[inst] or {}
chains[inst][k] = list
end
local slot = list[index]
local h = getHandler(inst, k, v)
if slot ~= nil and slot.handler == h then
slot.retractor(v)
slot.retractor = NOOP
local retractor = h.process(inst, k, v, index)
if retractor == nil then error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가") end
slot.retractor = retractor
else
C.retractFrom(inst, k, index)
list[index] = { handler = h, retractor = NOOP }
local retractor = h.process(inst, k, v, index)
if retractor == nil then error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가") end
list[index] = { handler = h, retractor = retractor }
end
end
function C.retractFrom(inst, k, index)
local list = chains[inst] and chains[inst][k]
if not list then return end
for i = #list, index, -1 do
local slot = list[i]
if slot == nil then error("Dispatch: 인덱스 " .. i .. "에 슬롯이 없음 — 배열에 구멍이 뚫렸음") end
slot.retractor(nil)
list[i] = nil
end
end
C.dump = function(inst, k) return chains[inst] and chains[inst][k] end
return C

View file

@ -0,0 +1,209 @@
--!nocheck
-- 문서(base/state-epoch-plan.md §2~§5, base/source-state-plan.md 전파 모델,
-- base/gate-plan.md 4/8번, base/blocker-plan.md)를 그대로 옮긴 참조 구현.
-- 방어 코드(pcall 등)는 문서에 없으므로 넣지 않는다.
local M = {}
local function newWeakK() return setmetatable({}, {__mode = "k"}) end
-- ── EpochMap ────────────────────────────────────────────────────────────
local EpochMap = {}
EpochMap.__index = EpochMap
function M.EpochMap()
return setmetatable({map = newWeakK()}, EpochMap)
end
local function unfold(from)
-- Epoch 하나면 {from}, EpochSet이면 그 키들
if from.Revision ~= nil then return {from} end
local t = {}
for e in from do t[#t+1] = e end
return t
end
M.unfold = unfold
function EpochMap:Update(from)
local changed = false
for _, e in unfold(from) do
if self.map[e] ~= e.Revision then
changed = true
self.map[e] = e.Revision
end
end
return changed
end
function EpochMap:Sync(from)
for _, e in unfold(from) do self.map[e] = e.Revision end
end
function EpochMap:Refresh()
local changed = false
for e, rev in self.map do
if e.Revision ~= rev then changed = true; self.map[e] = e.Revision end
end
return changed
end
function EpochMap:TrackFrom(other)
for e in other.map do self.map[e] = e.Revision end
end
-- 문서에 없는 연산(H-72). 게이트 판정용으로만 쓰고, 쓸 때마다 표시한다.
function EpochMap:PeekDiffers(from)
for _, e in unfold(from) do
if self.map[e] ~= e.Revision then return true end
end
return false
end
-- ── 전파 ────────────────────────────────────────────────────────────────
local function emitDown(node, from)
-- H-23 확정: 구독자 집합을 배열로 스냅샷한 뒤 돈다
local snapshot = {}
for sub in node.subs do snapshot[#snapshot+1] = sub end
for _, sub in snapshot do
sub:_receive(from)
end
end
M.emitDown = emitDown
-- ── Source ──────────────────────────────────────────────────────────────
local Source = {}
Source.__index = Source
function M.Source(v)
return setmetatable({value = v, Revision = 0, subs = newWeakK()}, Source)
end
function Source:Get() return self.value end
function Source:Set(v)
self.value = v
self.Revision = bit32.bnot(-self.Revision)
emitDown(self, self)
end
function Source:_track(map) map:Sync(self) end
function Source:_addSub(s) self.subs[s] = true end
-- ── State(Compute/With) ─────────────────────────────────────────────────
local State = {}
State.__index = State
function M.State(fn, deps, name)
local self = setmetatable({
fn = fn, deps = deps, name = name,
valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(),
rawInvalid = true, cache = nil, subs = newWeakK(),
recomputes = 0,
}, State)
for _, d in deps do
d:_addSub(self)
d:_track(self.valueEpochMap) -- Source면 Sync, State면 TrackFrom
end
return self
end
function State:_track(map) map:TrackFrom(self.valueEpochMap) end
function State:_addSub(s) self.subs[s] = true end
function State:_receive(from)
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged = self.emitEpochMap:Update(from)
if valueChanged then self.rawInvalid = true end
if valueChanged or emitChanged then emitDown(self, from) end
end
function State:_recompute()
self.recomputes += 1
self.cache = self.fn(self, self.cache, table.unpack(self.deps))
self.rawInvalid = false
for _, d in self.deps do d:_track(self.valueEpochMap) end
end
function State:Get()
if self.rawInvalid then
self:_recompute()
elseif self.valueEpochMap:Refresh() then
self.rawInvalid = true
self:_recompute()
end
return self.cache
end
-- ── Observer(leaf) ──────────────────────────────────────────────────────
local Observer = {}
Observer.__index = Observer
function M.Observer(state, fn, name)
local self = setmetatable({fn = fn, name = name, fires = 0}, Observer)
state:_addSub(self)
self.owner = state
return self
end
function Observer:_receive(from)
self.fires += 1
self.fn(self, from)
end
-- ── GateNode ────────────────────────────────────────────────────────────
local GateNode = {}
GateNode.__index = GateNode
function M.Gate(upstream, setup, name)
local self = setmetatable({
up = upstream, name = name,
valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(),
rawInvalid = true, cache = nil, subs = newWeakK(),
withheld = newWeakK(), flushes = 0,
}, GateNode)
upstream:_addSub(self)
upstream:_track(self.valueEpochMap)
local function emit() self:_flush() end
self.onUpstreamEmit = setup(emit)
return self
end
function GateNode:_track(map) map:TrackFrom(self.valueEpochMap) end
function GateNode:_addSub(s) self.subs[s] = true end
function GateNode:_flush()
if next(self.withheld) == nil then return end -- 8번: 빈 배치는 아무것도 안 함
local batch = self.withheld
self.withheld = newWeakK() -- 진입 시 스왑(H-9)
self.flushes += 1
self.emitEpochMap:Sync(batch) -- 전파할 때 갱신(§4 예외)
emitDown(self, batch)
end
function GateNode:_discard() -- OffWithoutEmit 경로
self.withheld = newWeakK()
end
function GateNode:_receive(from)
-- 규칙 1~3을 먼저. 게이트는 emitEpochMap을 수신 시점에 갱신하지 않으므로
-- 비교만 하는 연산이 필요하다(H-72) — PeekDiffers로 대신한다.
local valueChanged = self.valueEpochMap:Update(from)
local emitDiffers = self.emitEpochMap:PeekDiffers(from)
if valueChanged then self.rawInvalid = true end
if not (valueChanged or emitDiffers) then return end -- 규칙 3: 삼킴
for _, e in unfold(from) do self.withheld[e] = true end
self.onUpstreamEmit()
end
function GateNode:Get()
if self.rawInvalid then
self.cache = self.up:Get()
self.rawInvalid = false
self.up:_track(self.valueEpochMap)
elseif self.valueEpochMap:Refresh() then
self.cache = self.up:Get()
self.up:_track(self.valueEpochMap)
end
return self.cache
end
-- ── Blocker ─────────────────────────────────────────────────────────────
local Blocker = {}
Blocker.__index = Blocker
function M.Blocker()
return setmetatable({IsBlocked = false, handles = {}}, Blocker)
end
function Blocker:IsOn() return self.IsBlocked end
function Blocker:On() self.IsBlocked = true; return self end
function Blocker:_run(emit)
for _, h in self.handles do h(emit) end
end
function Blocker:Off() self.IsBlocked = false; self:_run(true); return self end
function Blocker:OffWithoutEmit() self.IsBlocked = false; self:_run(false); return self end
-- blocker:Policy(emit) -> onUpstreamEmit
function Blocker:Policy(emit, discard)
table.insert(self.handles, function(doEmit)
if doEmit then emit() else (discard or function() end)() end
end)
return function()
if not self.IsBlocked then emit() end
end
end
return M

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
--!nocheck
local q = require("./core"); local D = require("./dispatch")
local inst = {} -- 최상위 물리 inst가 owner
-- Frame { A, B, C } — 전부 plain 요소(길이 1, 발행 채널 None)
local bl = D.getBlocker(inst); bl:On()
for i = 1, 3 do
D.setOffsetSource(inst, i, D.None)
D.setLength(inst, i, 1, inst)
end
bl:OffWithoutEmit(); D.recompute(inst, D.getBookkeeping(inst))
print("offsets:", D.getOffsetAt(inst,1), D.getOffsetAt(inst,2), D.getOffsetAt(inst,3), "| 다음 자리:", D.getOffsetAt(inst,4))
print("recompute 횟수:", D.stats())

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,23 @@
--!nocheck
local q = require("./core"); local D = require("./dispatch")
-- Frame { Slot1, E(plain), Slot2 }
local inst = {}
local s1, s2 = D.newSlot("s1"), D.newSlot("s2")
local bl = D.getBlocker(inst); bl:On()
D.setOffsetSource(inst, 1, s1.Offset); D.setLength(inst, 1, s1.Length, inst)
D.setOffsetSource(inst, 2, D.None); D.setLength(inst, 2, 1, inst)
D.setOffsetSource(inst, 3, s2.Offset); D.setLength(inst, 3, s2.Length, inst)
bl:OffWithoutEmit(); D.recompute(inst, D.getBookkeeping(inst))
print("초기: s1.Offset =", s1.Offset:Get(), "| s2.Offset =", s2.Offset:Get(), "| recompute:", D.stats())
print("-- 런타임에 Slot1이 3개가 됨 (s1.Length:Set(3)) --")
s1.Length:Set(3)
print("s1.Offset =", s1.Offset:Get(), "| s2.Offset =", s2.Offset:Get(), "(기대 4) | recompute:", D.stats())
print("-- Slot2도 2개가 됨 --")
s2.Length:Set(2)
print("s2.Offset =", s2.Offset:Get(), "(기대 4) | 4번 자리 offset =", D.getOffsetAt(inst,4), "(기대 6)")
print("-- Slot1이 다시 0개 --")
s1.Length:Set(0)
print("s2.Offset =", s2.Offset:Get(), "(기대 1) | 4번 자리 =", D.getOffsetAt(inst,4), "(기대 3)")

View file

@ -0,0 +1,31 @@
--!nocheck
local q = require("./core"); local D = require("./dispatch")
local owner = D.newSlot("owner")
local s1, s2 = D.newSlot("s1"), D.newSlot("s2")
local bl = D.getBlocker(owner); bl:On()
D.setOffsetSource(owner, 1, s1.Offset); D.setLength(owner, 1, s1.Length, owner)
D.setOffsetSource(owner, 2, s2.Offset); D.setLength(owner, 2, s2.Length, owner)
bl:OffWithoutEmit(); D.recompute(owner, D.getBookkeeping(owner))
print("초기 owner.Length =", owner.Length:Get(), "| s2.Offset =", s2.Offset:Get())
-- s2.Offset을 보는 소비자가 그 통지 도중 같은 owner에 3번째 자리를 추가한다
local added = false
local watcher = q.Observer(s2.Offset, function()
if added then return end
added = true
D.setOffsetSource(owner, 3, D.None)
D.setLength(owner, 3, 1, owner) -- 재진입 recompute
end)
watcher.Subscribed = true
print("-- s1.Length:Set(2) --")
s1.Length:Set(2)
local bk = D.getBookkeeping(owner)
local expect = 0
for i = 1, bk.N do
local v = bk.lengthList[i]
expect += (if type(v) == "table" then v:Get() else v)
end
print("owner.Length =", owner.Length:Get(), "| 실제 합계 =", expect, "| N =", bk.N, "| recompute:", D.stats())
print("s2.Offset =", s2.Offset:Get(), "(기대 2) | 3번 자리 offset =", D.getOffsetAt(owner, 3), "(기대 2)")
if owner.Length:Get() ~= expect then print(" ❌ owner.Length가 실제 합계와 다르다") else print(" ✅ 일치") end

View file

@ -0,0 +1,49 @@
--!nocheck
local C = require("./chain")
-- AttributeKeyHandler(attribute-plan.md 확정 의사코드)를 그대로 옮긴 것
local nameClaims = {}
local setAttributeInjected = false
local function setAttribute(inst, name, v)
if not setAttributeInjected then
error("quad: setAttribute op이 주입되지 않았습니다(백엔드 팩토리 미설치)") -- 확정된 "에러내는 스텁"
end
end
local AttributeKeyHandler = {
isHandlable = function(inst, k, v) return type(k) == "table" and k.Name ~= nil end,
process = function(inst, k, v, index)
local cur = nameClaims[inst] and nameClaims[inst][k.Name]
if cur ~= nil and cur ~= k then
error(`attribute "{k.Name}" is already bound by another owner`)
end
nameClaims[inst] = nameClaims[inst] or {}
nameClaims[inst][k.Name] = k
setAttribute(inst, k.Name, v) -- ← 주입 안 됐으면 여기서 던진다
return function()
if nameClaims[inst][k.Name] == k then nameClaims[inst][k.Name] = nil end
end
end,
}
C.addHandler(AttributeKeyHandler)
local inst = {}
local keyA = { Name = "Score" }
local keyB = { Name = "Score" } -- 같은 이름을 쓰는 다른 소유자(그룹 Attribute 등)
print("-- 백엔드 미주입 상태에서 Attribute 한 번 --")
local ok, err = pcall(C.process, inst, keyA, 10, 1)
print("던졌는가:", not ok, "|", err)
print("claim 남았는가:", nameClaims[inst] and nameClaims[inst].Score ~= nil)
local list = C.dump(inst, keyA)
print("체인 슬롯:", list and list[1] and "있음(retractor=" .. tostring(list[1].retractor) .. ")" or "없음")
print("-- 이제 백엔드를 붙이고 정상적으로 다시 시도 --")
setAttributeInjected = true
local ok2, err2 = pcall(C.process, inst, keyB, 20, 1)
print("성공했는가:", ok2, "|", err2)
print("-- 명시적 철거로 회수되는가 --")
setAttributeInjected = true
pcall(C.retractFrom, inst, keyA, 1)
print("철거 후 claim 남았는가:", nameClaims[inst] and nameClaims[inst].Score ~= nil)
local ok3, err3 = pcall(C.process, inst, keyB, 20, 1)
print("철거 후 재시도 성공?:", ok3, "|", err3)

View file

@ -0,0 +1,39 @@
--!nocheck
local q = require("./core"); local D = require("./dispatch")
-- RETUNE = true면 splice가 이동된 observer의 위치 인덱스를 실제 위치로 다시 맞춘다
local function run(RETUNE)
local function spliceArraysDown(ownerKey, index)
local bk = D.getBookkeeping(ownerKey)
for i = index, bk.N - 1 do
bk.lengthList[i] = bk.lengthList[i + 1]
bk.sourceList[i] = bk.sourceList[i + 1]
bk.observers[i] = bk.observers[i + 1]
end
bk.lengthList[bk.N] = nil; bk.sourceList[bk.N] = nil; bk.observers[bk.N] = nil
bk.N -= 1
bk.invalidAfter = math.min(bk.invalidAfter, index)
if RETUNE then
for i = index, bk.N do
local o = bk.observers[i]
if o and o.box then o.box.pos = i end
end
end
end
local owner, s = {}, {}
for i = 1, 3 do s[i] = D.newSlot("s" .. i) end
local bl = D.getBlocker(owner); bl:On()
for i = 1, 3 do
D.setOffsetSource(owner, i, s[i].Offset)
D.setLength(owner, i, s[i].Length, owner)
s[i].Length:Set(1)
end
bl:OffWithoutEmit(); D.recompute(owner, D.getBookkeeping(owner))
spliceArraysDown(owner, 1)
D.recompute(owner, D.getBookkeeping(owner))
s[2].Length:Set(5)
print(("[%s] s3.Offset = %d (기대 5) | invalidAfter = %d")
:format(if RETUNE then "위치 재조정함" else "확정 설계 그대로", s[3].Offset:Get(),
D.getBookkeeping(owner).invalidAfter))
end
run(false)
run(true)

View file

@ -0,0 +1,11 @@
--!nocheck
local q = require("./core"); local D = require("./dispatch")
local inst = {}
local s2 = D.newSlot("s2")
local bl = D.getBlocker(inst); bl:On()
-- 1번 자리 등록 생략(H-39 버그 재발), 2번은 발행 채널이 있는 Slot
local ok, err = pcall(function()
D.setOffsetSource(inst, 2, s2.Offset) -- 여기서 getOffsetAt(inst, 2)를 부른다
end)
print("setOffsetSource에서 터지는가:", not ok)
print("메시지:", err)

View file

@ -0,0 +1,115 @@
--!nocheck
-- base/dispatch-core-plan.md의 Length/Offset 부기를 확정 의사코드 그대로 옮긴 것.
-- getOffsetAt(접두합 캐시, 2026-08-21 G절 + H-3) / recompute(H-10 정정 반영) /
-- setLength / setOffsetSource / 배치 게이팅.
local q = require("./core")
local D = {}
local None = setmetatable({}, {__tostring = function() return "None" end})
D.None = None
local function isSlot(x) return type(x) == "table" and x.__isSlot == true end
local function isState(x) return type(x) == "table" and (getmetatable(x) ~= nil) and x.Get ~= nil end
local books, blockers = {}, {}
local function getBookkeeping(ownerKey)
local bk = books[ownerKey]
if not bk then
bk = { lengthList = {}, sourceList = {}, observers = {}, N = nil,
offsetCache = {}, invalidAfter = 0 }
books[ownerKey] = bk
end
return bk
end
local function getBlocker(ownerKey)
local b = blockers[ownerKey]
if not b then b = q.Blocker(); blockers[ownerKey] = b end
return b
end
D.getBookkeeping, D.getBlocker = getBookkeeping, getBlocker
local function contribution(bk, i)
local v = bk.lengthList[i]
return (if isState(v) then v:Get() else v)
end
function D.getOffsetAt(ownerKey, at)
local bk = getBookkeeping(ownerKey)
if bk.invalidAfter == 0 then
bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
bk.invalidAfter = 1
end
if at <= bk.invalidAfter then
return bk.offsetCache[at]
end
local cur = bk.offsetCache[bk.invalidAfter]
for i = bk.invalidAfter, at - 1 do
cur += contribution(bk, i)
bk.offsetCache[i + 1] = cur
end
bk.invalidAfter = at
return cur
end
local recomputes = 0
D.stats = function() return recomputes end
local function recompute(ownerKey, bk)
recomputes += 1
local sum = 0
for i = 1, bk.N or 0 do
local offset = bk.sourceList[i]
if offset == nil then
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
end
local abs = D.getOffsetAt(ownerKey, i)
if offset ~= None and offset:Get() ~= abs then
offset:Set(abs)
end
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v)
end
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
ownerKey.Length:Set(sum)
end
end
D.recompute = recompute
function D.setLength(ownerKey, i, len, anchor)
anchor = anchor or ownerKey
local bk = getBookkeeping(ownerKey)
local blocker = getBlocker(ownerKey)
local oldObserver = bk.observers[i]
if oldObserver then bk.observers[i] = nil end
bk.lengthList[i] = len
bk.N = math.max(bk.N or 0, i)
bk.invalidAfter = math.min(bk.invalidAfter, i)
local box = { pos = i } -- [대조군용] 실제 위치를 가변 박스로 들면 splice가 고칠 수 있다
local function gatedRecompute()
bk.invalidAfter = math.min(bk.invalidAfter, box.pos)
if not blocker:IsOn() then
recompute(ownerKey, bk)
end
end
if isState(len) then
local observer = q.Observer(len, gatedRecompute)
observer.pos = box.pos; observer.box = box
bk.observers[i] = observer
gatedRecompute() -- "등록 즉시 1회 실행도 게이팅됨"
else
gatedRecompute()
end
end
function D.setOffsetSource(ownerKey, i, source)
local bk = getBookkeeping(ownerKey)
bk.sourceList[i] = source
if source == None then return end
local offset = D.getOffsetAt(ownerKey, i)
if source:Get() ~= offset then source:Set(offset) end
end
-- 테스트 편의: Slot 흉내(Length/Offset 공개 필드만)
function D.newSlot(name)
return { __isSlot = true, name = name, Length = q.Source(0), Offset = q.Source(0) }
end
return D

View file

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

View file

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

View file

@ -0,0 +1,14 @@
--!nocheck
-- quad 확정 의사코드는 전부 level 인자 없는 error(msg) — level 1이 기본
local Slot = {}
function Slot.Add(self, index) -- quad 내부(라이브러리) 코드
error("Slot:Add — index가 범위 밖(1..3): " .. tostring(index))
end
function Slot.AddL2(self, index)
error("Slot:Add — index가 범위 밖(1..3): " .. tostring(index), 2)
end
local ok1, e1 = pcall(function() Slot.Add(nil, 9) end) -- 사용자 코드 줄
local ok2, e2 = pcall(function() Slot.AddL2(nil, 9) end)
print("level 기본(1):", e1)
print("level 2 :", e2)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,25 @@
--!nocheck
local q = require("./core")
local SubscribedObservers = {}
local function Subscribe(o) SubscribedObservers[o] = true; o.Subscribed = true; return o end
-- Observer가 상류를 되참조하지 않는 형태(확정 서술: 구독 엣지는 하류로 weak,
-- Observer를 살리는 건 gchold 또는 전역 레지스트리)
local Obs = {}
Obs.__index = Obs
function Obs.new(state, fn) local o = setmetatable({fn = fn, fires = 0}, Obs); state:_addSub(o); return o end
function Obs:_receive(from) self.fires += 1; self.fn(self, from) end
local A = q.Source(1)
local fires = 0
local o
do
local mid = q.State(function(self, prev, a) return a:Get() * 2 end, {A}, "mid")
o = Subscribe(Obs.new(mid, function() fires += 1 end))
end
collectgarbage("collect"); collectgarbage("collect")
A:Set(2)
print("Observer 발화:", fires, " ← 공개 계약(\"예외 없이 그냥 계속 돎\")대로면 1")
local n = 0; for _ in A.subs do n += 1 end
print("A의 구독자 수:", n, " ← 중간 State가 수거됐으면 0")
print("Observer는 레지스트리에 살아있다:", SubscribedObservers[o] == true, "| 그러나 다시는 안 울린다")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
--!nocheck
local q = require("./core")
-- A -> B -> D, A -> C -> D, Observer on D
local A = q.Source(1)
local B = q.State(function(self, prev, a) return a:Get() * 10 end, {A}, "B")
local C = q.State(function(self, prev, a) return a:Get() * 100 end, {A}, "C")
local D = q.State(function(self, prev, b, c) return b:Get() + c:Get() end, {B, C}, "D")
local seen = {}
local O = q.Observer(D, function() table.insert(seen, D:Get()) end, "O")
print("초기 D:Get() =", D:Get())
A:Set(2)
print("Observer 발화 횟수:", O.fires, "| 관측값:", table.concat(seen, ","))
print("D 재계산 횟수:", D.recomputes, "| B:", B.recomputes, "| C:", C.recomputes)
A:Set(3)
print("2회차 후 발화:", O.fires, "| 관측값:", table.concat(seen, ","))

View file

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

View file

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

View file

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

View file

@ -0,0 +1,26 @@
--!nocheck
local q = require("./core")
local A = q.Source(1)
local B = q.State(function(self, prev, a) return a:Get() end, {A}, "B")
local C = q.State(function(self, prev, a) return a:Get() end, {A}, "C")
-- B 아래에 터지는 Observer, C 아래에 멀쩡한 Observer
local bad = q.Observer(B, function() error("사용자 콜백 실패") end, "O_bad")
local goodSeen = {}
local good = q.Observer(C, function() table.insert(goodSeen, C:Get()) end, "O_good")
print("-- A:Set(2) (전파 중 O_bad가 error) --")
local ok, err = pcall(function() A:Set(2) end)
print("Set이 던졌는가:", not ok, "|", err)
print("O_bad 발화:", bad.fires, "| O_good 발화:", good.fires, "| 관측:", table.concat(goodSeen, ","))
print("C의 값은 자가치유되는가: C:Get() =", C:Get(), "(정상)")
print("하지만 O_good은 이 변경에 대해 영원히 안 울었다:", good.fires)
print("-- 이후 A:Set(3)은 정상인가 --")
local ok2 = pcall(function() A:Set(3) end)
print("두 번째 Set도 같은 자리에서 던짐:", not ok2, "| O_good 발화:", good.fires)
print("-- O_bad를 제거하고(구독 해제 없이 새 파동) --")
B.subs[bad] = nil
A:Set(4)
print("O_good 발화:", good.fires, "| 관측:", table.concat(goodSeen, ","))
print("→ 2와 3에 대한 통지는 영영 오지 않았다. 마지막 값만 보임")

View file

@ -0,0 +1,47 @@
--!nocheck
local q = require("./core")
print("=== (a) blocker:Off() 순회 중 한 핸들이 error ===")
local A = q.Source(1)
local B = q.Source(1)
local b = q.Blocker()
local g1 = q.Gate(A, function(emit) return b:Policy(emit) end, "g1")
local g2 = q.Gate(B, function(emit) return b:Policy(emit) end, "g2")
local seen1, seen2 = {}, {}
local o1 = q.Observer(g1, function() error("g1 하류에서 실패") end, "o1")
local o2 = q.Observer(g2, function() table.insert(seen2, g2:Get()) end, "o2")
b:On()
A:Set(2); B:Set(2)
print("보류 중 — g1.withheld 비었나:", next(g1.withheld) == nil, "| g2.withheld 비었나:", next(g2.withheld) == nil)
local ok, err = pcall(function() b:Off() end)
print("Off()가 던졌는가:", not ok, "|", err)
print("blocker.IsBlocked:", b.IsBlocked, "(이미 false — Off는 상태부터 바꾼다)")
print("g2가 아직 붙들고 있나(비었으면 flush됨):", next(g2.withheld) == nil, "| o2 발화:", o2.fires)
print("→ g2는 IsBlocked=false 인데도 밀린 통지를 못 내보냄. b:Off()를 다시 불러도:")
B.subs[g2] = B.subs[g2] -- no-op
local ok2 = pcall(function() b:Off() end)
print(" 두 번째 Off도 같은 자리에서 던짐:", not ok2, "| o2 발화:", o2.fires)
print()
print("=== (b) flush 도중 error → 떼어낸 배치가 소멸 ===")
local S = q.Source(1)
local bl = q.Blocker()
local g = q.Gate(S, function(emit) return bl:Policy(emit) end, "g")
local boom = true
local seen = {}
local o = q.Observer(g, function()
if boom then error("하류 실패") end
table.insert(seen, g:Get())
end, "o")
bl:On()
S:Set(2)
print("보류됨. withheld 비었나:", next(g.withheld) == nil)
pcall(function() bl:Off() end)
print("flush 진입 후 withheld 비었나(스왑됨):", next(g.withheld) == nil, "| flush 횟수:", g.flushes)
print("gate.emitEpochMap은 이미 Sync됨 → 같은 리비전은 이제 규칙 3으로 삼켜진다")
boom = false
-- 같은 리비전이 다른 경로로 다시 도착하는 상황을 재현
g:_receive(S)
print("같은 리비전 재도착 후 o 발화:", o.fires, "| 관측:", table.concat(seen, ","))
print("→ 배치는 사라졌고 재도착도 삼켜져, 그 변경에 대한 통지가 영영 안 나감")
print(" (값은 살아있다: g:Get() =", g:Get(), ")")

View file

@ -0,0 +1,84 @@
--!nocheck
local q = require("./core")
-- 가짜 시계/타이머 (luau CLI엔 task가 없다 — 주입 op setTimeout/clearTimeout 자리)
local now, timers, seq = 0, {}, 0
local function setTimeout(fn, d) seq += 1; timers[seq] = {at = now + d, fn = fn}; return seq end
local function clearTimeout(t) if t then timers[t] = nil end end
local function advance(to)
while true do
local best, bestId
for id, t in timers do
if t.at <= to and (best == nil or t.at < best.at or (t.at == best.at and id < bestId)) then best, bestId = t, id end
end
if not best then break end
timers[bestId] = nil
now = best.at
best.fn()
end
now = to
end
-- 확정된 재작성 방향(gate-plan.md 5번) 그대로: 정책은 emit을 안 쥐고 Blocker만 조종한다.
-- variant "nopending": 문서가 확정한 대로 pending을 정책에서 없앤 형태
-- ("보류된 게 있는가는 Blocker의 HasBlockedEmit이 이미 들고 있다")
-- variant "localpending": 정책이 자기 pending 플래그를 따로 드는 형태
local function Throttle(state, Time, variant)
return q.Gate(state, function(emit)
local b = q.Blocker()
local pass = b:Policy(emit)
local window, pending = nil, false
local openWindow, onWindowEnd
function onWindowEnd()
window = nil
if variant == "nopending" then
-- HasBlockedEmit을 읽을 수 없으므로 무조건 푼다 → 빈 배치면 no-op(8번)
b:Off() -- 보류분 방출(있으면)
b:On() -- 다음 창을 위해 다시 막음
openWindow() -- "통과했으니 창을 다시 엶" — pending 여부를 알 수 없어 항상
else
if pending then
pending = false
b:Off(); b:On()
openWindow()
end -- pending 없으면 창을 안 열고 idle 복귀
end
end
function openWindow() window = setTimeout(onWindowEnd, Time) end
return function() -- 상류 emit 도착
if window == nil then
-- 창 밖(idle) → leading 즉시 통과
b:Off() -- 안 막힌 상태로 만들고
pass() -- 통과
b:On() -- 창 동안 막음
openWindow()
else
pending = true
pass() -- 막혀 있으므로 보류됨
end
end
end, "throttle")
end
local function run(variant)
print(("=== variant = %s ==="):format(variant))
now = 0; timers = {}
local S = q.Source(0)
local g = Throttle(S, 1.0, variant)
local hits = {}
local o = q.Observer(g, function() table.insert(hits, ("t=%.2f v=%d"):format(now, g:Get())) end)
S:Set(1) -- t=0.0 leading 통과 기대
advance(0.1); S:Set(2)
advance(1.0) -- 창 끝 → trailing 통과 기대
advance(3.0) -- 조용 → idle 복귀 기대
print(" 타이머 살아있는 개수(idle이면 0):", (function() local n=0 for _ in timers do n+=1 end return n end)())
S:Set(3) -- t=3.0 다시 leading 즉시 통과 기대
print(" 통과:", table.concat(hits, " | "))
advance(5.0)
print(" 최종 통과:", table.concat(hits, " | "))
print(" t=5까지 살아있는 타이머:", (function() local n=0 for _ in timers do n+=1 end return n end)())
end
run("localpending")
print()
run("nopending")

View file

@ -0,0 +1,21 @@
--!nocheck
local q = require("./core")
-- D = Compute(A) 인데, fn 안에서 다른 코드가 A를 바꾼다
local A = q.Source(1)
local log = {}
local D = q.State(function(self, prev, a)
local v = a:Get()
table.insert(log, "fn이 읽은 A = " .. v)
if v == 1 then A:Set(99) end -- 재계산 도중 상류가 바뀜(부작용 있는 Compute)
return v * 2
end, {A}, "D")
print("D:Get() =", D:Get(), "(A=1로 계산됨)")
print("A의 실제 값:", A:Get())
print("다시 D:Get() =", D:Get(), " ← 기대값 198")
print("D 재계산 횟수:", D.recomputes)
print("로그:", table.concat(log, " / "))
print()
print("-- 이후 A를 또 바꾸면 회복되는가 --")
A:Set(5)
print("D:Get() =", D:Get(), "(회복됨)")

View file

@ -0,0 +1,20 @@
--!nocheck
local q = require("./core")
-- 갈래 (b): rawInvalid를 fn 호출 *전에* 내리고, 도중에 다시 세워졌으면 그대로 둔다
local State = getmetatable(q.State(function() end, {}))
State._recompute = function(self)
self.recomputes += 1
self.rawInvalid = false -- ← 먼저 내린다
self.cache = self.fn(self, self.cache, table.unpack(self.deps))
for _, d in self.deps do d:_track(self.valueEpochMap) end
-- rawInvalid가 fn 도중 다시 true가 됐으면 그대로 둔다(덮어쓰지 않는다)
end
local A = q.Source(1)
local D = q.State(function(self, prev, a)
local v = a:Get()
if v == 1 then A:Set(99) end
return v * 2
end, {A}, "D")
print("D:Get() =", D:Get())
print("다시 D:Get() =", D:Get(), " ← 198이면 고쳐진 것")
print("재계산 횟수:", D.recomputes)

View file

@ -0,0 +1,28 @@
--!nocheck
local q = require("./core")
-- A ──> gated(Block) ──┐
-- └──> plain ─────────┴──> Effect(fn, gated, plain)
local A = q.Source(1)
local plain = q.State(function(self, prev, a) return a:Get() end, {A}, "plain")
local b = q.Blocker()
local gated = q.Gate(A, function(emit) return b:Policy(emit) end, "gated")
-- effect-plan.md의 확정 의사코드 그대로: 공용 EpochMap 하나, true일 때만 Rerun
local epochs = q.EpochMap()
local runs = {}
local function onDep(self, from)
if epochs:Update(from) then
table.insert(runs, ("run(gated=%s, plain=%s)"):format(tostring(gated:Get()), tostring(plain:Get())))
end
end
local o1 = q.Observer(gated, onDep, "dep_gated")
local o2 = q.Observer(plain, onDep, "dep_plain")
b:On()
print("blocker ON — 이 구간의 변경은 gated 쪽에서 유보되어야 한다")
A:Set(2)
print("fn 실행 기록:", table.concat(runs, " | "))
print("gated가 아직 붙들고 있나:", next(gated.withheld) ~= nil)
b:Off()
print("Off() 이후 fn 실행 기록:", table.concat(runs, " | "))
print("gated observer 발화 횟수:", o1.fires, "| plain observer:", o2.fires)

View file

@ -0,0 +1,40 @@
--!nocheck
local q = require("./core")
-- dispatch-core-plan.md "배치 등록을 안전하게 만드는 Blocker 게이팅" 그대로
local blockers = {} -- Relate(ownerKey) 자리
local function getBlocker(owner)
if not blockers[owner] then blockers[owner] = q.Blocker() end
return blockers[owner]
end
local recomputes = 0
local function gatedRecompute(owner)
if getBlocker(owner):IsOn() then return end -- 배치 중이면 스킵
recomputes += 1
end
local function drive(owner, positions)
local bl = getBlocker(owner)
bl:On()
for _, p in positions do
p() -- 각 position 처리(사용자 코드가 여기서 돈다)
gatedRecompute(owner) -- setLength가 트리거하는 자리
end
bl:OffWithoutEmit() -- 4번: 배치 끝
recomputes += 1 -- "그 직후 딱 한 번" 명시적 recompute
end
local inst = {} -- ownerKey
print("-- 정상 배치 --")
drive(inst, {function() end, function() end})
print("recompute 횟수:", recomputes, "| blocker.IsOn():", getBlocker(inst):IsOn())
print("-- 두 번째 배치에서 사용자 코드가 error --")
local ok = pcall(drive, inst, {function() end, function() error("컴포넌트 실패") end})
print("drive가 던졌는가:", not ok, "| blocker.IsOn():", getBlocker(inst):IsOn(), "← 영구 On")
print("-- 이후 런타임 :Add() 등이 부르는 setLength --")
local before = recomputes
gatedRecompute(inst); gatedRecompute(inst); gatedRecompute(inst)
print("recompute가 몇 번 돌았나:", recomputes - before, "(0이면 그 owner는 영영 재계산 안 됨)")
print("-- 새 배치를 열어도 --")
pcall(drive, inst, {function() end})
print("blocker.IsOn():", getBlocker(inst):IsOn(), "| 총 recompute:", recomputes)

View file

@ -0,0 +1,17 @@
--!nocheck
local q = require("./core")
local A = q.Source(1)
local B = q.State(function(self, prev, a) return a:Get() * 10 end, {A}, "B")
local C = q.State(function(self, prev, a) return a:Get() * 100 end, {A}, "C")
local D = q.State(function(self, prev, b, c) return b:Get() + c:Get() end, {B, C}, "D")
local seen, depth = {}, 0
local O = q.Observer(D, function()
table.insert(seen, D:Get())
depth += 1
if depth == 1 then A:Set(3) end -- 전파 도중 동기 재진입 Set
end, "O")
D:Get()
A:Set(2)
print("관측 순서:", table.concat(seen, ","))
print("Observer 발화:", O.fires, "| D 재계산:", D.recomputes)
print("최종 D:Get() =", D:Get(), "(A=3이므로 330이어야 함)")

View file

@ -0,0 +1,27 @@
--!nocheck
local q = require("./core")
-- gate-plan.md 4번: "하류가 또 다른 게이트면 받은 집합을 풀어 자기 withheld에 합친다.
-- 게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이 먼저 풀리든
-- 정보가 안 샌다"
local function run(order)
local A, B = q.Source(1), q.Source(1)
local w = q.State(function(self, prev, a, b) return a:Get() + b:Get() end, {A, B}, "w")
local b1, b2 = q.Blocker(), q.Blocker()
local g1 = q.Gate(w, function(emit) return b1:Policy(emit) end, "g1")
local g2 = q.Gate(g1, function(emit) return b2:Policy(emit) end, "g2")
local hits = {}
local o = q.Observer(g2, function(self, from)
local n = 0; for _ in from do n += 1 end
table.insert(hits, ("v=%d, batch=%d"):format(g2:Get(), n))
end, "o")
b1:On(); b2:On()
A:Set(2); B:Set(3)
local function w1() return next(g1.withheld) ~= nil end
local function w2() return next(g2.withheld) ~= nil end
print(("[%s] 유보 중 g1=%s g2=%s | 발화 %d"):format(order, tostring(w1()), tostring(w2()), o.fires))
if order == "상류 먼저" then b1:Off(); print(" b1:Off 후 g1=" .. tostring(w1()) .. " g2=" .. tostring(w2()) .. " 발화=" .. o.fires); b2:Off()
else b2:Off(); print(" b2:Off 후 g1=" .. tostring(w1()) .. " g2=" .. tostring(w2()) .. " 발화=" .. o.fires); b1:Off() end
print((" 최종 발화 %d | %s"):format(o.fires, table.concat(hits, " ; ")))
end
run("상류 먼저")
run("하류 먼저")

View file

@ -0,0 +1,20 @@
--!strict
export type StateData<T> = { Get: (self: StateData<T>) -> T }
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>, prev: U?) -> U) -> U,
ComputeN: <U, D...>(self: StateData<T>, fn: (self: StateData<T>, prev: U?, D...) -> U, D...) -> U,
}
local s: State<number> = nil :: any
local a: SourceData<number> = nil :: any
-- (A) deps 0개 — typing-limits ②쪼개기가 확인한 "무주석 통과"
local r1 = s:Compute(function(self) return self:Get() * 2 end)
local n1: number = r1
-- (B) deps 1개 + 무주석
local r2 = s:ComputeN(function(self, prev, d1) return self:Get() + d1:Get() end, a)
-- (C) deps 1개 + dep만 주석
local r3 = s:ComputeN(function(self, prev, d1: SourceData<number>) return self:Get() + d1:Get() end, a)
local n3: number = r3

View file

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

View file

@ -0,0 +1,28 @@
--!strict
-- base/source-state-plan.md: state:Apply(factory) — `factory: (State<T>) -> U): U`
-- base/debounce-throttle-plan.md 5-4: Debounce{...}가 돌려주는 것은 __call 테이블
-- (setmetatable({}, {__call = ..., __index = {Flush=..., Cancel=...}}))
-- base/gate-plan.md 2번: "__call 테이블이 Luau에서 (State<T>) -> U 함수 타입 자리에
-- 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력)" — 확인 안 하기로 접었음
export type StateData<T> = { Get: (self: StateData<T>) -> T }
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
Apply: <U>(self: State<T>, factory: (State<T>) -> U) -> U,
}
local s: State<number> = nil :: any
-- (A) 평범한 함수 팩토리 — 양성 대조군
local a = s:Apply(function(self: State<number>): State<number> return self end)
-- (B) __call 테이블 팩토리 — 5-4절이 확정한 모양
type GateFactory = typeof(setmetatable(
{} :: { Flush: () -> (), Cancel: () -> () },
{} :: { __call: (any, State<number>) -> State<number> }
))
local Debounced: GateFactory = nil :: any
local b = s:Apply(Debounced)
-- (C) 직접 호출은 되는가
local c = Debounced(s)

View file

@ -0,0 +1,23 @@
--!strict
export type StateData<T> = { Get: (self: StateData<T>) -> T }
export type State<T> = StateData<T> & {
Apply: <U>(self: State<T>, factory: (State<T>) -> U) -> U,
}
local s: State<number> = nil :: any
type GateFactory = typeof(setmetatable(
{} :: { Flush: () -> (), Cancel: () -> () },
{} :: { __call: (any, State<number>) -> State<number> }
))
local Debounced: GateFactory = nil :: any
-- (A) 제네릭이 아닌 평범한 함수 파라미터 자리에도 안 들어가는가?
local function takesFn(f: (State<number>) -> State<number>) return f(s) end
local a = takesFn(Debounced)
-- (B) 유니온으로 열면 되는가
local function takesEither(f: ((State<number>) -> State<number>) | GateFactory)
return (f :: any)(s)
end
local b = takesEither(Debounced)
local b2 = takesEither(function(x: State<number>) return x end)

View file

@ -0,0 +1,27 @@
--!strict
-- base/effect-plan.md: Effect(fn, ...deps) — deps는 State/Source(→Observer)와
-- Ref(→:Callback)가 섞인다. fn엔 deps가 안 넘어간다(H-14).
export type StateData<T> = { Get: (self: StateData<T>) -> T }
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
export type Ref<T> = { Value: T?, Set: (self: Ref<T>, v: T) -> (), Callback: (self: Ref<T>, fn: (T) -> ()) -> () }
export type EffectHandle = { Rerun: (self: EffectHandle) -> (), Subscribe: (self: EffectHandle) -> () }
type Dep = State<any> | Ref<any>
local function Effect(fn: (self: EffectHandle) -> (() -> ())?, ...: Dep): EffectHandle
return nil :: any
end
local n: State<number> = nil :: any
local b: State<boolean> = nil :: any
local r: Ref<Instance> = nil :: any
-- (A) State 하나
local e1 = Effect(function(self) return nil end, n)
-- (B) 이형 State 둘
local e2 = Effect(function(self) return nil end, n, b)
-- (C) State + Ref 섞기 — effect-plan.md가 확정한 대표 용례
local e3 = Effect(function(self) return nil end, n, r)
-- (D) 음성 대조군 — 아무 테이블이나 넣으면 걸려야 정상
local e4 = Effect(function(self) return nil end, {foo = 1})

View file

@ -0,0 +1,46 @@
--!strict
-- (A) base/state-epoch-plan.md §2: "Source가 이 인터페이스를 구조적으로 만족한다"
-- "Revision은 공개 필드다. 비공개면 구조적 만족이 타입 레벨에서 성립하지 않는다"
export type Epoch = { Revision: number }
export type EpochSet = { [Epoch]: true }
export type StateData<T> = { Get: (self: StateData<T>) -> T }
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
export type Source<T> = SourceData<T> & {
Set: (self: SourceData<T>, v: T) -> SourceData<T>,
Emit: (self: SourceData<T>) -> (),
Compute: <U>(self: SourceData<T>, fn: (self: SourceData<T>) -> U) -> State<U>,
}
local src: Source<number> = nil :: any
local st: State<number> = nil :: any
local function takesEpoch(e: Epoch): number return e.Revision end
local r1 = takesEpoch(src) -- 성립해야 함
-- local r2 = takesEpoch(st) -- 음성 대조군(주석 해제하면 걸려야 정상)
-- EpochMap 표면 (§3)
export type EpochMap = {
Update: (self: EpochMap, from: Epoch | EpochSet) -> boolean,
Refresh: (self: EpochMap) -> boolean,
Sync: (self: EpochMap, from: Epoch | EpochSet) -> (),
TrackFrom: (self: EpochMap, other: EpochMap) -> (),
}
local m: EpochMap = nil :: any
local u1 = m:Update(src) -- Epoch 하나
local set: EpochSet = { [src :: Epoch] = true }
local u2 = m:Update(set) -- EpochSet
-- 게이트가 실제로 만드는 모양: {[Source<number>]: true}를 EpochSet 자리에
local rawSet: { [Source<number>]: true } = { [src] = true }
local u3 = m:Update(rawSet)
-- (B) base/effect-plan.md: fn 시그니처는 fn(self: EffectHandle) -> (() -> ())?
export type EffectHandle = { Rerun: (self: EffectHandle) -> () }
local function Effect(fn: (self: EffectHandle) -> (() -> ())?): EffectHandle return nil :: any end
local e1 = Effect(function(self) return function() end end) -- cleanup 있음
local e2 = Effect(function(self) return nil end) -- 명시적 nil
local e3 = Effect(function(self) end) -- 아무것도 안 돌려줌 (가장 흔한 모양)

View file

@ -0,0 +1,23 @@
--!strict
export type EffectHandle = { Rerun: (self: EffectHandle) -> () }
-- (i) 확정된 시그니처
local function EffectA(fn: (self: EffectHandle) -> (() -> ())?) end
EffectA(function(self) end) -- 흔한 모양
EffectA(function(self) return nil end) -- 명시적 nil
EffectA(function(self) return function() end end) -- cleanup
-- (ii) 조건 분기가 있는 경우 — 한쪽만 반환
EffectA(function(self)
if math.random() > 0.5 then return function() end end
end)
-- (iii) 가변 반환 팩으로 열기
local function EffectB(fn: (self: EffectHandle) -> ...(() -> ())) end
EffectB(function(self) end)
EffectB(function(self) return function() end end)
-- (iv) 유니온 함수 타입
local function EffectC(fn: ((self: EffectHandle) -> ()) | ((self: EffectHandle) -> (() -> ()))) end
EffectC(function(self) end)
EffectC(function(self) return function() end end)

View file

@ -0,0 +1,31 @@
--!strict
-- base/slot-plan.md 확정: updateFn<UD>(item, index, offset, prev, userdata) -> (T|nil, UD?)
type Element = { tag: string }
export type SourceN = { Get: (self: SourceN) -> number }
local function List<T, UD>(
updateFn: (item: any, index: number, offset: SourceN, prev: T?, userdata: UD?) -> (T?, UD?)
) end
-- (A) 값 하나만 반환하는 가장 흔한 모양
List(function(item, index, offset, prev, ud)
return { tag = "x" } :: Element
end)
-- (B) 두 값 반환
List(function(item, index, offset, prev, ud)
return { tag = "x" } :: Element, 42
end)
-- (C) nil만 반환(요소 버리기 — 확정된 정상 경로)
List(function(item, index, offset, prev, ud)
return nil
end)
-- (D) 조건 분기 — 한쪽만 반환
List(function(item, index, offset, prev, ud)
if index > 1 then return { tag = "x" } :: Element end
end)
-- (E) 아무것도 안 반환
List(function(item, index, offset, prev, ud) end)

View file

@ -0,0 +1,16 @@
--!strict
type El = { tag: string }
-- 비제네릭으로 좁혀서 "선언된 다중 반환보다 적게 반환"만 본다
local function take(fn: (n: number) -> (El?, number?)) end
take(function(n) return { tag = "a" } end) -- 1개만 반환
take(function(n) return nil end) -- nil 하나
take(function(n) end) -- 아무것도 안 반환
take(function(n) return { tag = "a" }, 1 end)-- 정확히 2개
take(function(n) return nil, nil end) -- 명시적 2개
-- 대조: 단일 옵셔널 반환
local function take2(fn: (n: number) -> El?) end
take2(function(n) return { tag = "a" } end)
take2(function(n) return nil end)
take2(function(n) end)

View file

@ -0,0 +1,12 @@
--!strict
type El = { tag: string }
type Fn2 = (n: number) -> (El?, number?)
type Fn1 = (n: number) -> El?
type Fn0 = (n: number) -> ()
local function takeU(fn: Fn2 | Fn1 | Fn0) end
takeU(function(n) return { tag = "a" } end)
takeU(function(n) return nil end)
takeU(function(n) end)
takeU(function(n) return { tag = "a" }, 1 end)
-- 음성 대조군: 엉뚱한 타입을 돌려주면 걸려야 정상
takeU(function(n) return 42 end)

View file

@ -0,0 +1,41 @@
--!strict
export type StateData<T> = { Get: (self: StateData<T>) -> T }
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
Apply: <U>(self: State<T>, factory: (State<T>) -> U) -> U,
Gate: (self: State<T>, setup: (emit: () -> ()) -> (() -> ())) -> State<T>,
Block: (self: State<T>, blocker: Blocker) -> State<T>,
}
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
export type Source<T> = SourceData<T> & {
Set: (self: SourceData<T>, v: T) -> SourceData<T>,
Apply: <U>(self: Source<T>, factory: (State<T>) -> U) -> U,
Gate: (self: Source<T>, setup: (emit: () -> ()) -> (() -> ())) -> State<T>,
}
export type Blocker = {
On: (self: Blocker) -> Blocker,
Off: (self: Blocker) -> Blocker,
OffWithoutEmit: (self: Blocker) -> Blocker,
IsOn: (self: Blocker) -> boolean,
IsBlocked: boolean,
Policy: (self: Blocker, emit: () -> ()) -> (() -> ()),
}
local st: State<number> = nil :: any
local src: Source<number> = nil :: any
local b: Blocker = nil :: any
-- (A) gate-plan.md 5번의 확정 형태
local g1 = st:Gate(function(emit) return b:Policy(emit) end)
-- (B) tween-plan.md의 관용구 — Source에 State용 팩토리를 붙인다
local Animate: (self: State<number>) -> State<number> = nil :: any
local a1 = src:Apply(Animate)
local a2 = st:Apply(Animate)
-- (C) Debounce 정책 모양 — 자기 Blocker를 사적으로 하나 갖는 setup
local d = st:Gate(function(emit)
local mine: Blocker = nil :: any
local pass = mine:Policy(emit)
return function() pass() end
end)
-- (D) 음성 대조군 — gate-plan.md가 "문법 오류"라고 정정한 형태
local bad = st:Gate(b.Policy)

View file

@ -0,0 +1,28 @@
--!strict
-- 7라운드 부록이 확인한 이형 팩은 StateData<number>/StateData<boolean>처럼
-- "같은 종류, 다른 타입 인자"였다. 실제 코드는 Source와 State가 섞인다.
export type StateData<T> = { Get: (self: StateData<T>) -> T }
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
export type State<T> = StateData<T> & {
ComputeN: <U, D...>(self: StateData<T>, fn: (self: StateData<T>, prev: U?, D...) -> U, D...) -> U,
}
local s: State<string> = nil :: any
local a: SourceData<number> = nil :: any -- store.a (Source)
local b: StateData<boolean> = nil :: any -- 파생 State
local r = s:ComputeN(function(self, prev, d1: SourceData<number>, d2: StateData<boolean>)
return if d2:Get() then d1:Get() else 0
end, a, b)
local rn: number = r
-- 무주석 콜백에서도 되는가(부록이 확인한 것과 같은 조건)
local r2 = s:ComputeN(function(self, prev, d1, d2)
return d1:Get() + (if d2:Get() then 1 else 0)
end, a, b)
local rn2: number = r2
-- 음성 대조군: 순서를 바꿔 넘기면 걸려야 정상
local r3 = s:ComputeN(function(self, prev, d1: SourceData<number>, d2: StateData<boolean>)
return 0
end, b, a)

View file

@ -8,7 +8,7 @@
> 새로 만들고 `base/`에 반영한다 — **이 파일은 발견 당시의 기록**이라 각 항목의
> "갈래"는 선택 전 목록이니 반영 뒤엔 그대로 믿지 말 것.
>
> 상태: **[2026-08-28 기준] 탐사 진행 중 / 회신 대기.** `H-147`~`H-149`는
> 상태: **[2026-08-28 기준] 탐사 완료(발견 `H-150`~`H-157`, 🔴 0 · 🟡 5 · 🟢 3, §4 문항 7) / 회신 대기.** `H-147`~`H-149`는
> `H-143`~`H-146` 반영분에 `/code-review high`가 낸 10건 중 새 메커니즘·기존
> 결정 변경이라 문항으로 올린 셋(나머지 일곱은 반영 — `-round9-followup.md`
> 마지막 code-review 절).
@ -17,9 +17,17 @@
| 번호 | 심각도 | 한 줄 | 주 대상 | 성격 | 실측 |
|---|---|---|---|---|---|
| `H-147` | 🟡 | `Rerun` 꼬리 `wasAlive`가 루프 머리 `_consumeCleanup()` **뒤**에 잡히고 공개 `Rerun()`에 게이트가 없어 **진입 시점에 이미 죽은 핸들**(직전 cleanup이 `self:Unsubscribe()` / 해제 뒤 타이머의 `self:Rerun()`)은 else 분기 → 고아 cleanup + `_installed = true` | `effect-plan.md` `Rerun` | `/code-review` 발견, 처방은 규칙 또는 새 플래그 | |
| `H-147` | 🟡 | `Rerun` 꼬리 `wasAlive`가 루프 머리 `_consumeCleanup()` **뒤**에 잡히고 공개 `Rerun()`에 게이트가 없어 **진입 시점에 이미 죽은 핸들**(직전 cleanup이 `self:Unsubscribe()` / 해제 뒤 타이머의 `self:Rerun()`)은 else 분기 → 고아 cleanup + `_installed = true` | `effect-plan.md` `Rerun` | `/code-review` 발견, 처방은 규칙 또는 새 플래그 | `t15`(재현 — §5) |
| `H-148` | 🟡 | `H-146`의 "`Parent` 거부는 **전용 문구**"가 새 메커니즘 — `isHandlable` 거부는 `Dispatch.process`**일반** 매치 실패 문구로 떨어지고 그 자리에 특수 분기는 두지 않기로 확정돼 있다. 사용자 회신에 문구 언급 없음(에이전트 선택이었음) | `bind-system-plan.md` `H-142` 항목, `ROADMAP.md` M5 | `/code-review` 발견, 처방은 새 핸들러 또는 철회 | — |
| `H-149` | 🟡 | `Observer:Subscribe``self:WeakSubscribe()`로 위임하므로 거기 붙인 `error(…, 2)``o:Subscribe()` 경로에선 사용자 호출부가 아니라 `Observer:Subscribe` 본문을 가리킴(`H-104` level 계약 위반). `EffectHandle`은 (b)로 인라인해 문제 없음 | `lifecycle-pattern.md` Observer 네 진입점 | `/code-review` 발견, 처방은 기존 결정("위임하므로 게이트 한 번") 변경 | — |
| `H-149` | 🟡 | `Observer:Subscribe``self:WeakSubscribe()`로 위임하므로 거기 붙인 `error(…, 2)``o:Subscribe()` 경로에선 사용자 호출부가 아니라 `Observer:Subscribe` 본문을 가리킴(`H-104` level 계약 위반). `EffectHandle`은 (b)로 인라인해 문제 없음 | `lifecycle-pattern.md` Observer 네 진입점 | `/code-review` 발견, 처방은 기존 결정("위임하므로 게이트 한 번") 변경 | `t19`(재현 — §5) |
| `H-150` | 🟡 | `Effect._blocker`는 **아무것도 억제하지 않는 죽은 부품**이다 — `fire`의 첫 줄 `canExecute(self)`가 생성자 구간(핸들이 아직 bind/Subscribe 안 됨)의 등록 즉시 1회를 전부 먼저 떨어뜨리고, 생성자 뒤로는 `_blocker`가 다시 `On()`되는 자리가 없다. 그런데 `effect-plan.md`는 억제 주체를 `_blocker`로 서술하고 `gate-plan.md` 7번은 아직 *"자기 내부 플래그로"*(폐기된 `_installing`)라 적혀 있다 | `effect-plan.md` 생성자 / `gate-plan.md` 7번 | 실측 발견, 처방은 부품 제거 또는 서술 정정(사용자 지시로 들어온 부품이라 문항) | `t18` |
| `H-151` | 🟡 | 게이트가 **두 경로에서 우회된다** — (1) `Effect`의 재바인드/재구독 캐치업 `_epochs:Refresh()`는 원천 `Epoch``.Revision`을 직접 읽으므로 중간 `GateNode`가 유보 중이어도 `Rerun`이 돈다, (2) 게이트 없는 형제 dep이 emit하면 `fn`이 돌며 게이트된 dep의 최신값을 `:Get()`으로 그냥 본다. 둘 다 "값은 안 가린다" 계약의 따름정리지만 어디에도 적혀 있지 않다 | `gate-plan.md` / `effect-plan.md` `_bindDestroying` | 실측 발견, 처방은 문서화(권고) 또는 새 메커니즘 | `t16` |
| `H-152` | 🟡 | `gate-plan.md``GateNode` 조립 절(필드 전부를 한 곳에 모은 절)에 **`StateBrand` 등록이 없다.** `_emitDown``isState(sub)`로만 자식 노드를 가르므로 등록을 빠뜨리면 게이트는 `canExecute(sub)`(bind된 적 없음 → 거짓)로 떨어져 **조용히 영영 못 받는다**`:Get()`은 멀쩡하고 통지만 죽는 모드. `brand-plan.md` 스스로 *"어느 브랜드에 등록하는 걸 빠뜨렸나"*를 조용한 버그로 경고한 그 자리 | `gate-plan.md` `GateNode` 조립 / `source-state-plan.md` `_emitDown` | 명시 누락(갈래 없음 — 한 줄 추가) | `t24`(재현) |
| `H-153` | 🟡 | Store 예약 이름(`Of`/`Names`/`__reservedCheck`)의 **런타임 갭**`store:Of("Of")`는 구현 모양에 따라 메소드 함수를 그대로 돌려주거나(그림자=store 자신) 이후 `s:Of`를 가린다(프록시); `Store({ Of = Source(1) })``isSource` 화이트리스트를 **통과**한 뒤 `s:Of(...)`가 *attempt to call a table value*로 죽는다. 타입 함수는 정적 키만 보고 동적 `Of(name)`과 `--!nocheck`엔 손이 안 닿는다(store-plan 자신이 `__reservedCheck`에 대해 절반은 인정) | `store-plan.md` 구현 스케치 / `Of` | 실측 발견, 처방은 **작은 런타임 가드**(새 검사라 문항) | `t23` |
| `H-154` | 🟡 | `InstanceChildHandler`의 retractor에 **같은 값 dedup이 없다**`state<Frame>`이 같은 Frame을 다시 emit하면(spurious) `Parent = nil``Parent = inst`가 실제로 일어나고 `recompute(inst)`가 2회 돈다. 같은 자리의 `SlotHandler`(`slotValue == nextValue → return`)·`RefLeafHandler`(`old ~= v`)·Leaf(`old ~= v`)는 전부 dedup을 두고 있어 이 핸들러만 예외 | `dispatch-core-plan.md` `H-134` 문단 / `ROADMAP.md` M5 | 실측 발견, 처방은 한 줄 가드(동형 선례 있음, dedup 정책이라 문항) | `d23` |
| `H-155` | 🟢 | `ROADMAP.md` 체크박스 넷이 `base/`보다 낡았다 — M6 *"`recompute`는 `sourceList[i]`가 `nil`이어도 `None`처럼 skip"*(4라운드 `C-6`가 **즉시 error**로 뒤집음), M6 *"`destroySlotTree`가 자식 소유권 반납"*(`C-4`가 되돌림), M6 *"`self._mounted` 확인 후 즉시 활성화"*(6라운드 `H-2``_physicalTarget`으로), M11 3-상태 슬롯 *"`RobloxTween \| true \| nil` … 엔진 객체=활성 트윈"*(tween-plan이 `{Tween, Value}` 테이블로 정정) | `ROADMAP.md` M6/M11 | stale(색인 레이어) | — |
| `H-156` | 🟢 | `debounce-throttle-plan.md` 7절의 `H-32` 문단이 *"`pending`을 없애고 Blocker의 `HasBlockedEmit`을 쓰면 … `OffWithoutEmit()`은 보류분을 버리면서 상태도 같이 비운다"*로 남아 있다 — 같은 절 배너가 7라운드 `H-86`(`HasBlockedEmit`은 정책이 읽을 수 없음)·`H-55`(`OffWithoutEmit`만으론 집합이 안 빔, `emit(false)` 필요)로 정확히 그 두 문장을 뒤집어놓고 본문은 안 고쳤다 | `debounce-throttle-plan.md` 7절 | 배너만 달고 본문 미수정 | — |
| `H-157` | 🟢 | `store-plan.md`의 *"빈 Store(`Store<<{}>>()`)는 아직 실측 안 됐다"*는 이제 실측됐다 — 최종형(§1 쪼개기 `Source<T>` + `CheckReservedKeys<keyof<T>>`)에 `Store({} :: {})`를 넣으면 `keyof<{}>`가 에러가 아니라 빈 유니온으로 풀려 **진단 0**, `empty:Names()`/`empty:Of(...)`도 정상 | `store-plan.md` 빈 Store 문단 | 실측 완료(서술 갱신만) | `ty11` |
## 상세
@ -76,6 +84,258 @@ dot 호출 로컬 헬퍼로 빼도 됨(가상 디스패치 없음) / (b) 내부
문서화.
**권고 (a)** — "본문을 섞지 않는다"(2026-08-27 원칙)와 결이 같다.
### `H-150` 🟡 — `Effect._blocker`는 죽은 부품이다 (억제는 `canExecute`가 이미 한다)
**트레이스** — `base/effect-plan.md`의 "의사코드 — 생성자" 절 그대로:
`self._blocker:On()` → dep마다 `d:Observer(onStateFire)`(등록 즉시 1회 → `fire`) /
`d:WeakCallback(onRefFire)`(등록 즉시 1회 → `fire`) → `self._blocker:OffWithoutEmit()`
`self:Rerun()`. `fire`의 순서는 `canExecute(self)``_blocker:IsOn()``Update`.
생성자 안의 `self`는 아직 `bindLifetime``Subscribe`도 안 된 핸들이라
`isBoundAlive(self)`가 거짓 → **`canExecute`가 첫 줄에서 전부 떨어뜨린다.**
`_blocker:IsOn()` 줄엔 한 번도 도달하지 않는다. 생성자 뒤로 `_blocker:On()`
다시 부르는 자리는 코퍼스 어디에도 없다(`_bindDestroying`/`Rerun`/네 진입점 전부
확인) — 즉 이 부품은 **어떤 실행 경로에서도 판정에 참여하지 않는다.**
실측(`t18`): `fire` 가드 판정을 훅으로 기록하니 dep 3개짜리 Effect의 생성자에서
`drop:canExecute` 3건, `drop:blocker` **0건**. 바인드 뒤 `Set`은 정상 발화.
**왜 문제인가** — 동작은 안 깨진다. 문제는 **서술**이다:
- `effect-plan.md`*"억제는 사적 `Blocker` 하나가 전담한다"*, `_installing`
플래그가 *"생성자 구간만 덮어 바인드 구간을 놓쳤다"*고 근거를 적어뒀는데,
실제 차단자는 `canExecute`다. 다음 구현자가 이 근거를 믿고 `_blocker`
건드리거나(예: 바인드 구간에도 `On()`을 걸어 "일관되게") 반대로 `canExecute`
줄을 옮기면(예: `Update`를 먼저 해서 리비전을 앞당겨 기록하려고) 그때 처음으로
`_blocker`가 진짜 역할을 갖게 되거나 캐치업이 어긋난다 — 사냥 목록의 *"두 뜻이
같다"*형(문서가 두 장치를 같은 것처럼 서술) 자리다.
- `base/gate-plan.md` 7번은 더 낡았다 — *"`Effect`가 **자기 내부 플래그로** 설치
중 발화를 누르고"*는 7라운드 `H-58`이 폐기한 `_installing` 서술이다(같은
문서의 "확정 구조" 절과 `effect-plan.md`는 이미 `Blocker`로 바뀌어 있다).
**새 메커니즘 여부** — 없음. 제거 쪽이면 필드 하나·줄 셋이 준다.
**갈래**
- (a) **`_blocker`를 제거**하고 생성자 주석을 *"등록 즉시 1회는 `canExecute`
막는다(생성자 안에선 핸들이 아직 bound가 아니므로)"*로 정정. `gate-plan.md`
7번도 같이 정정.
- (b) **유지**(belt-and-braces — 나중에 생성자 안에서 bound가 되는 경로가
생기면 그때 의미를 가짐) + 문서를 *"실제 차단자는 `canExecute`, `_blocker`
예비"*로 정정. `gate-plan.md` 7번 정정은 동일.
- (c) 유지 + `fire`의 순서를 `_blocker:IsOn()``canExecute`로 뒤집어 `_blocker`
실제로 먼저 판정하게(문서와 코드를 맞추는 반대 방향).
**권고: (a).** 근거 — `conventions.md`의 "설계 원칙" 절(가상의 미래 요구까지
방어하지 않는다). (c)는 순서만 바꿀 뿐 `canExecute`가 거짓인 구간과 `_blocker`
켜진 구간이 완전히 겹쳐 여전히 판정 차이가 0이다. 단 **이 부품은 7라운드 `H-58`에서
사용자가 `Blocker`를 쓰라고 직접 지시해 들어온 것**이라(그 인용은
`qa-request/pre-implementation-handtrace-round7-followup.md``H-58`) 탐사자가
임의로 빼지 않고 문항으로 올린다 — 그 지시의 전제("등록 즉시 1회가 `Rerun`
닿는다")가 `canExecute` 첫 줄 때문에 성립하지 않는다는 게 이 발견의 요지다.
**채택 시 고칠 자리** — `base/effect-plan.md` 생성자 의사코드·"확정 구조" 절·
`H-58` 정정 문단(`_blocker`/"전담" 서술), `base/gate-plan.md` 7번,
`ROADMAP.md` M2의 `Effect` 항목에 `_blocker`가 있으면 같이.
### `H-151` 🟡 — 게이트는 `Effect` 캐치업과 형제 dep 경로에서 우회된다
**트레이스 (1) — 재바인드 캐치업.** `g = s:Block(b)`, `e = Effect(fn, g)`,
`bindLifetime(inst, e)`. `b:On()``s:Set(2)``g:_receive` → 유보(`fn` 안 돎,
맞다). 이제 `e`를 unbind → 다른 inst에 rebind(포탈) → `_bindDestroying`
`self._epochs:Refresh()`. `_epochs`의 키는 **원천 `Epoch`**(`s`)다 — 생성자가
`TrackFrom(g.valueEpochMap)`으로 `g`가 아는 원천을 그대로 복사했고, `Refresh`
그 원천들의 `.Revision`**직접** 읽는다. `s.Revision`은 이미 바뀌었으므로
`depsChanged = true`**`Rerun` — 게이트가 아직 닫혀 있는데 `fn`이 돈다.**
실측(`t16` 1·2): 게이트 유보 중 재바인드/재구독 → `fn` 1회 실행, 이후 `b:Off()`
flush에서 다시 1회(리비전이 이미 `Refresh`로 갱신됐는데도 flush 배치가 같은
원천을 싣고 오면 `Update`가 변화 없음으로 접어야 하나 — 실측에선 접혔다, 즉
두 번째는 안 돈다. 정확한 횟수는 §7).
**트레이스 (2) — 형제 dep.** `e = Effect(fn, g, u)`(`u`는 게이트 없는 Source).
`b:On()``s:Set(2)`(유보) → `u:Set(9)``u` 경로로 `fire``Update(u)` 참 →
`Rerun``fn` 안에서 `g:Get()`은 **최신값 2**를 준다(게이트는 값을 안 가린다,
`base/gate-plan.md` 확정). 즉 게이트로 "이 값의 변경은 나중에 보겠다"고 했는데
형제가 깨우면 그 값이 그대로 보인다. 실측(`t16` 4).
**왜 문제인가** — 둘 다 코드가 틀린 게 아니라 **계약이 안 적혀 있다.**
`gate-plan.md`는 *정책이 미루는 건 다운스트림 통지뿐*이라고만 하고, "통지가
emit이 아닌 경로(캐치업·형제)로 오면 어떻게 되는가"는 어디에도 없다.
`Debounce`/`Throttle` 사용자는 (1)에서 포탈 한 번에 창을 건너뛰는 실행을 보고,
(2)에서 debounce된 값이 형제 emit마다 새는 걸 본다 — 둘 다 "버그 리포트"로
들어올 모양이다.
**새 메커니즘 여부** — 문서화 갈래는 없음. 막는 갈래는 **새 메커니즘**이다:
(1)을 막으려면 `Refresh`가 원천 `.Revision`이 아니라 **dep 노드의
`emitEpochMap`**을 보고 비교해야 하는데, `EpochMap:Refresh`는 키가 `Epoch`
맵을 라이브로 다시 읽는 연산이라 노드를 거치는 형태가 아니다(`EpochMap`
계약 변경). (2)를 막으려면 `fn` 실행 중 게이트된 dep의 `:Get()`이 유보 전
값을 돌려줘야 하는데 그건 `debounce-throttle-plan.md` 4절이 **철회한 (B)
value-hold**다.
**갈래**
- (a) **문서화**`gate-plan.md`에 *"게이트는 emit 경로만 미룬다. `Effect`
재바인드/재구독 캐치업(`Refresh`)과 게이트 없는 형제 dep의 emit은 게이트를
거치지 않고, 그때 `:Get()`은 최신값을 준다"*를 계약으로 명시.
`debounce-throttle-plan.md` 11절(다른 결정과의 상호작용)의 `Effect` 항목에도
같은 문장.
- (b) (1)만 막기 — `Effect`가 State dep에 대해선 `Refresh` 대신 dep의
`emitEpochMap`을 비교(새 메커니즘, `EpochMap` 계약 변경).
- (c) 둘 다 막기 — value-hold 재개방(이미 기각된 안).
**권고: (a).** 근거 — (b)(c)는 각각 `state-epoch-plan.md``Refresh` 계약과
2026-08-19 `(A) emit-gate` 확정을 되짚어야 하고, 실사용 빈도(게이트된 dep을
가진 Effect가 포탈되는 경우)가 낮다. (a)는 이미 성립하는 사실을 적는 것뿐이다.
**채택 시 고칠 자리** — `base/gate-plan.md`(계약 문장),
`base/debounce-throttle-plan.md` 11절, `base/effect-plan.md` `_bindDestroying`
캐치업 주석("dep이 변했으면"의 뜻이 원천 기준임을 한 줄).
### `H-152` 🟡 — `GateNode` 조립 절에 `StateBrand` 등록이 없다
**트레이스** — `base/gate-plan.md`의 "`GateNode` 조립" 절은 *"세 문서에 나뉘어
있어 한 곳에서 순서를 볼 수 없었다 … 구현자가 조립을 잘못할 여지를 없애려고
여기 모은다"*며 필드 여섯과 `_receive`/`_flush`를 적는데, **브랜드 등록이 없다.**
`base/source-state-plan.md``_emitDown`은 `if isState(sub) then sub:_receive(from)
elseif canExecute(sub) then …`로 자식 노드를 **`isState`로만** 가른다.
`base/brand-plan.md`는 *"각 타입은 자기 브랜드에만 등록하고, 포함 관계는
predicate 한 곳에 쓴다"*라 `isState`가 참이려면 `GateNode``StateBrand`
등록돼야 한다. 빠뜨리면: 상류 `Set``_emitDown``isState(gate)` 거짓 →
`canExecute(gate)`(게이트는 bind/Subscribe된 적 없음) 거짓 → **조용히 건너뜀**.
`gate:Get()``_hold`를 타고 올라가 최신값을 주므로 값 검사로는 안 잡힌다 —
통지만 죽는다. 실측(`t24`): 같은 메소드·다른 identity로 브랜드만 떼면 하류
Observer 발화 2 → **0**, `Get()`은 3으로 정상.
**왜 지금 잡나** — `GateNode``ComputeNode`와 달리 State 생성자를 안 지나고
`Gate(self, setup)`가 직접 조립한다(위 절이 그 조립을 정의한다). 생성자가
공통이면 등록도 공통 자리에 있겠지만, 여기선 조립 절이 곧 생성자라 그 절에
없으면 어디에도 없다. `ROADMAP.md` M2 `GateNode` 항목에도 브랜드 언급이 없다.
사냥 목록 *"의사코드 순서 ≠ 산문"*의 변형 — 산문("게이티드 State 노드")은
State라 하고 의사코드는 State가 되는 줄이 없다.
**새 메커니즘 여부** — 없음(기존 `StateBrand:add(node)` 한 줄).
**갈래** — 없음. 조립 절 필드 목록 첫 줄(또는 `Gate(self, setup)` 본문)에
`StateBrand` 등록을 명시. 같은 절에 *"`isState(gate)`가 참이어야 `_emitDown`
`_receive`로 보낸다"*를 근거로 한 줄.
**채택 시 고칠 자리** — `base/gate-plan.md` 조립 절, `ROADMAP.md` M2 `GateNode`
체크박스. 참고 구현 `core10.luau``isState``GateNode` 메타테이블을 **직접
넣어서** 이 문제를 우회하고 있었다(README의 "옮기며 고친 것" 참고).
### `H-153` 🟡 — Store 예약 이름의 런타임 갭 (`Of("Of")` / `defaults.Of`)
**트레이스** — `base/store-plan.md`의 구현 스케치: 생성자는
`table.clone(defaults or {})` + `isSource` 순회, `store:Of(name)`은 그림자 테이블에
`Source`를 lazy 생성. 스케치가 *"그림자 테이블"*이라고만 해서 **store 자신인지
별도 테이블인지**가 안 정해져 있다(같은 문서가 `store.key`는 *"평범한 레코드
필드"*라 하므로 store 자신일 가능성이 크다). 두 모양을 다 옮겨 실측(`t23`):
| | (I) 그림자 = store 자신(`__index` 메소드) | (II) 별도 그림자 + 프록시 |
|---|---|---|
| `s:Of("Of")` | **메소드 함수를 그대로 반환**(`Source` 아님), 이후 `Of` 정상 | `Source`를 만들어 그림자에 넣음 → 이후 `s:Of`**그 Source에 가려져** `attempt to call a table value` |
| `s:Names()` | 정상 | `Of`가 사용자 키로 섞여 나옴 |
| `Store({ Of = Source(1) })` | `isSource` 통과 → `s:Of(...)`**`attempt to call a table value`** | (동일) |
타입 쪽 `CheckReservedKeys<keyof<T>>`는 (H)처럼 **정적 키**만 잡는다(`ty11`에서
정확히 잡힘). `Of(name)``name`은 타입에 안 실리고, `--!nocheck`/동적 코드는
`isSource` 화이트리스트(`H-122`)가 있어도 예약 이름을 안 보므로 통과한다.
`store-plan.md``__reservedCheck`에 대해선 *"동적 키는 이름이 타입에 안
실리므로 못 막는다"*고 이미 인정하는데, 같은 문장이 `Of`/`Names` 자신에게도
성립한다는 건 안 적었다.
**왜 문제인가** — 죽긴 죽는데 **자리가 멀다**: `defaults` 예약 이름은 생성
시점이 아니라 첫 `s:Of(...)`에서, 메시지도 예약 이름과 무관한 *call a table
value*다. `H-122``isSource` 화이트리스트를 둔 이유("조용히 받고 첫 `Get`에서
엉뚱한 에러로 죽는다")와 정확히 같은 모양의 구멍이 한 칸 옆에 남아 있다.
**새 메커니즘 여부** — 작은 런타임 검사(예약 이름 셋과 비교) — 새 개념은 아니나
새 검사이므로 문항. `archive/`·"검토 후 안 만들기로 한 것"류에서 런타임 예약 이름
가드를 기각한 기록은 **없다**(`archive/store-value-field-redesign-withdrawn.md`는
타입 함수 범위만 다룸).
**갈래**
- (a) **런타임 가드** — 생성자 `isSource` 순회에 `if RESERVED[k] then error(..., 2)`,
`Of(name)`에 같은 검사(둘 다 `level 2`, 영어 메시지 — `architecture.md` error
계약). 생성 시 1회 + `Of` 호출당 테이블 조회 1회라 hot path 아님.
- (b) 문서화만 — *"예약 이름을 동적으로 쓰면 UB"*.
- (c) 그림자를 (II) 별도 테이블로 못박고 `Of`만 가드(부분).
**권고: (a).** 근거 — `H-122`와 같은 자리·같은 논거(화이트리스트 검증은 이미
있고 비교 대상을 셋 늘리는 것뿐), fail-fast 톤(`Slot` CRUD·`KeyGone`의 선례).
부수로 스케치의 "그림자 = store 자신인가"도 같이 못박을 것(권고: (I) — `store.key`
레코드 필드 계약과 맞고 `Names()`가 메소드를 안 세려면 메소드는 `__index`에).
**채택 시 고칠 자리** — `base/store-plan.md` 구현 스케치·`Of` 절·
`__reservedCheck` 주석 (2), `ROADMAP.md` M2 Store 항목.
### `H-154` 🟡 — `InstanceChildHandler`는 같은 Frame 재발행에 물리 detach/attach를 한다
**트레이스** — `base/dispatch-core-plan.md``H-134` 문단(그리고 `ROADMAP.md`
M5): `process` = `setOffsetSource(None)``v.Parent = inst``setLength(1)`,
retractor = `v.Parent = nil``setOffsetSource(None)``setLength(0)`. 하강
diff의 (A) 분기는 같은 핸들러면 **무조건** `retractor(v)``process`를 다시
부르므로(`Dispatch 체인` 절), `state<Frame>`이 **같은 Frame**을 다시 emit하면:
`A.Parent = nil``recompute(inst)``A.Parent = inst``recompute(inst)`.
실측(`d23`, round7 `chain.luau` 위에 그대로 옮김): 같은 값 재발행 1회에 `Parent`
대입 2회 + `recompute` 2회. 대조군 `SlotHandler``slotValue == nextValue`
얼리리턴, `RefLeafHandler`/Leaf는 `old ~= v` dedup — `dispatch-core-plan.md`
"Handler 작성 체크리스트" 4번이 *"`Ref`의 spurious 재바인딩 dedup"*을 예로 든다.
**왜 문제인가** — Roblox에서 `Parent = nil``Parent = inst``ChildRemoved`/
`ChildAdded`/`AncestryChanged`를 실제로 쏘고 레이아웃을 한 프레임 흔들 수 있다.
spurious 재발행은 `Source:Set`이 같은 값도 emit한다는 확정(`t18`로 재확인)
때문에 드물지 않다 — `store.child:Set(store.child:Get())` 한 줄이면 난다.
**새 메커니즘 여부** — 없음(선례와 같은 한 줄).
**갈래**
- (a) retractor 첫 줄에 `if nextValue == v then return end`(`SlotHandler`와 동형) —
단 그러면 `process`가 다시 `setOffsetSource(None)`/`Parent = inst`/`setLength(1)`을
하므로 부기는 멱등하고 `Parent` 대입은 같은 값 재대입(엔진 no-op).
- (b) 그대로 둔다 — "dedup은 성능 최적화라 핸들러마다 선택"으로 문서화.
**권고: (a).** 근거 — 같은 값 재발행의 dedup은 `Slot`/`Ref`/Leaf에서 이미 채택된
정책이고, 이 핸들러만 빠진 건 `H-134`가 늦게 생겨서다(설계 차이가 아님).
**채택 시 고칠 자리** — `base/dispatch-core-plan.md` `H-134` 문단, `ROADMAP.md` M5
`InstanceChildHandler` 항목.
### `H-155` 🟢 — `ROADMAP.md` 체크박스 넷이 `base/`보다 낡았다
넷 다 `base/`가 소스이고 ROADMAP만 안 따라온 것(색인 레이어 stale, 사냥 목록
"배너만 달고 본문 안 고침"의 ROADMAP판):
1. M6 *"`recompute`는 `sourceList[i]`가 `nil`이어도 `None`처럼 skip(방어)"*
`base/slot-plan.md`는 4라운드 `C-6`**즉시 `error`**(부기가 깨졌다는 신호).
2. M6 *"`destroySlotTree`가 자식 소유권 반납 + …"*`slot-plan.md``C-4`로 그
수정을 **되돌렸다**(`State<Slot>` 재설정 표의 정정 문단이 명시).
3. M6 *"`self._mounted` 확인 후 즉시 활성화"* — 6라운드 `H-2`로 판정 기준이
`_physicalTarget`.
4. M11 *"3-상태 릴레이션 슬롯(`RobloxTween | true | nil` … 엔진 객체=활성 트윈)"*
`base/tween-plan.md`의 "3-상태 저장" 절은 `{Tween, Value}` **테이블**
(`Tween.Finish`가 목표값을 알아야 해서).
갈래 없음 — ROADMAP 넷을 `base/` 문장으로 교체.
### `H-156` 🟢 — `debounce-throttle-plan.md` 7절 `H-32` 문단이 배너와 모순
같은 절의 무효화 배너는 7라운드 `H-86`(*"읽는 통로는 `HasBlockedEmit`이 아니라
`emit`의 반환값"*)·`H-55`(*"`b:OffWithoutEmit()`만으로는 집합이 안 비므로
`emit(false)`가 필요"*)를 반영했는데, 바로 아래 `H-32` 문단은 여전히
*"`pending`을 없애고 Blocker의 `HasBlockedEmit`을 쓰면 … `Trailing = false`
`OffWithoutEmit()`으로 표현되고, 그건 보류분을 버리면서 상태도 같이 비운다"*.
두 문장 다 배너가 뒤집은 것이다. 갈래 없음 — 그 문단을 *"`emit()`의 반환값으로
읽고 `emit(false)`로 버린다"*로 정정.
### `H-157` 🟢 — 빈 Store는 실측됐다
`base/store-plan.md`의 *"빈 Store는 아직 실측 안 됐다 … `keyof<{}>`가 빈
유니온이 되는지 에러가 되는지"*: `ty11`(최종형 — `StateData<T>`/`State<T>`
쪼개기, `Source<T>` 필드, `CheckReservedKeys<keyof<T>>`, 재귀 `Compute -> State<U>`
포함)에 `local empty = Store({} :: {})`를 넣으면 **진단 0**(`keyof<{}>` →
`never`가 아니라 빈 유니온으로 `components()`가 비어 검사가 그냥 통과),
`empty:Names(): {string}`/`empty:Of("dyn"): Source<number>` 정상. 같은 파일에서
키 있는 양성(A·B·C·E) 0건, 음성(D·F·`c4`) 정확히 그 줄, 예약 키(H) 진단 1건,
§1 구멍(G)은 알려진 대로 조용히 통과. 갈래 없음 — 문단을 "실측 완료"로 갱신,
`luau-test/STATUS.md``16`/`21` 재작성 시 대조군 메모는 그대로 유효.
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
| 문항 | 무엇 | 선택지 | 권고 |
@ -83,17 +343,142 @@ dot 호출 로컬 헬퍼로 빼도 됨(가상 디스패치 없음) / (b) 내부
| **`H-147`** | 죽은 핸들에서 `Rerun()` | (a) UB로 문서화(cleanup 안 자기 해제는 지원 목록 밖, 타이머 취소는 cleanup의 일) / (b) `_everAlive` 플래그 + 진입 게이트 / (c) `wasAlive`를 소진 앞에서 | **(a)** — 새 상태 없이 기존 UB 계약과 같은 결 |
| **`H-148`** | `Parent` 거부 문구 | (a) 전용 문구 철회, 일반 매치 실패 그대로 / (b) `Parent` 가드 핸들러(동적 경로 가드형) / (c) 일반 문구에 키 이름 | **(a)** — 회신 취지(새 API 없음) 그대로, 필요하면 M5에서 (b) |
| **`H-149`** | Observer `Subscribe` 위임과 `level 2` | (a) `Subscribe`에 게이트·등록 인라인(Effect와 동형) / (b) `level 3` 위임 / (c) 문서화만 | **(a)** — 본문 안 섞기 원칙과 동형 |
| **`H-150`** | `Effect._blocker`(죽은 부품) | (a) 제거 + "억제는 `canExecute`"로 서술 정정 / (b) 유지 + 서술 정정("실제 차단자는 `canExecute`, `_blocker`는 예비") / (c) 유지 + `fire` 순서를 `IsOn``canExecute`로 | **(a)** — 판정 차이 0인 부품, `conventions.md` 설계 원칙. 단 `H-58` 사용자 지시로 들어온 부품이라 문항 |
| **`H-151`** | 게이트 우회(캐치업 `Refresh` / 형제 dep) | (a) 계약으로 문서화(게이트는 emit 경로만 미룬다) / (b) 캐치업을 dep `emitEpochMap` 기준으로(새 메커니즘) / (c) value-hold 재개방(기각안) | **(a)** — 이미 성립하는 사실, (b)(c)는 확정 둘을 되짚음 |
| **`H-153`** | Store 예약 이름 런타임 가드 | (a) 생성자 순회 + `Of(name)`에 예약 이름 검사(`error(…, 2)`) / (b) 문서화만(UB) / (c) 그림자 (II) 고정 + `Of`만 가드 | **(a)** — `H-122` 화이트리스트와 같은 자리·논거. 부수: 그림자 = store 자신(I)로 못박기 |
| **`H-154`** | `InstanceChildHandler` spurious dedup | (a) retractor `if nextValue == v then return end`(`SlotHandler` 동형) / (b) 그대로(정책 문서화) | **(a)** — `Slot`/`Ref`/Leaf가 이미 채택한 정책 |
(탐사자의 문항은 아래에 이어 붙는다.)
갈래 없는 것(회신 불필요, 반영만): `H-152`(브랜드 등록 한 줄), `H-155`(ROADMAP 넷),
`H-156`(`H-32` 문단), `H-157`(실측 완료 표기).
## §5 이상 없다고 확인한 것
(탐사자가 채운다.)
전부 `audit/handtrace-round10-reference-impl/spikes/`의 참고 구현(`core10.luau`/
`dispatch10.luau`, `H-107`~`H-149` 계약으로 갱신) 위에서 값으로 확인한 것. 스파이크
이름은 §7.
- **레인 A — `EffectHandle` 상태 전이표(`t14`).** 네 진입점 × {미바인드/바인드/
파괴 뒤/구독만} × `Rerun` 꼬리 × `_bindDestroying`: (1) 미바인드 `Subscribe`
`fire`가 살아나고 `Unsubscribe` → 죽음, (2) 바인드 중 `WeakUnsubscribe`는 관대
(`H-133`), `Unsubscribe`는 엄격 error, (3) 파괴 뒤 `Subscribe`(재구독)는
`resubscribeTail` 캐치업으로 재설치(`H-144` (b)), (4) 파괴 → 재바인드도 재설치,
`_installed`/`_cleanup` 값이 전이마다 문서 표와 일치. `wasAlive and not canExecute`
즉시 소진(`H-143`)은 `fn` 안에서 자기 inst를 동기 파괴하는 `t22`에서 cleanup이
그 자리에서 1회 돎.
- **`H-147` 재현(`t15`)** — 죽은 핸들에서 `Rerun()`: 케이스 1(직전 cleanup이
`self:Unsubscribe()`) → `fn`이 돌고 고아 cleanup + `_installed = true`;
케이스 2(해제 뒤 타이머의 `Rerun`) 동일; 케이스 3(파괴된 inst에 bound였던 핸들)
`BindData`에 죽은 gcconn이 남아 있어 `wasAlive`가 참 → 즉시 소진으로 구분됨.
고아 cleanup은 나중에 재구독/재바인드가 오면 그때 소진된다(영구 누수는 아님) —
§4 `H-147`의 (a)를 뒷받침.
- **`H-149` 재현(`t19`)** — `o:Subscribe()``error(…, 2)``core10.luau` 내부
줄을 가리킴, `o:WeakSubscribe()`/`Effect` 진입점은 사용자 줄. (a) 인라인 처방으로
사용자 줄이 됨(핸들 쪽 대조).
- **`state:Gate` × 재구독 × `EpochMap` 셋(`t16`)** — 7 시나리오: 유보 중 `Peek`
`emitEpochMap`을 안 덮음, flush가 배치를 unfold해 하류 `Update`가 한 번에 접음,
게이트-게이트 중첩에서 중복 원천이 집합으로 접힘, `emit(false)`가 집합을 비움,
`OffWithoutEmit` 반복에 집합이 단조 증가하지 않음(`H-55`/`H-67`). 우회 둘만
`H-151`로 올렸다.
- **`_hold` 불변식(`t17`)** — 하류가 상류를 강하게 잡고(`s → c1 → c2`, 하류만
참조해도 상류 생존) 상류는 하류를 weak로만(하류 참조를 놓으면 GC, 상류는 살아
있음). 양성/음성 대조.
- **`Source:Set` 같은 값(`t18`)** — 같은 값도 `Revision`을 올리고 emit(확정대로),
`Blocker` 게이팅 중엔 흡수 집합에 1건으로 접힘.
- **Store 최종형 타입(`ty11`, `luau-analyze`)**`H-157` 참고. `Of<<T>>` 명시
인스턴스화 양성/음성(`c3`/`c4`) 정확.
- **레인 C — 9라운드 결정 재실행.** `H-124`(되감기 판정을 `lengthList[i]` 읽기
앞으로): `d10`/`d11`/`d13`/`d14` 네 케이스 전부 `dispatch10` 위에서 통과(옛
`dispatch9`와 같은 결과). `H-136`(`reconcile` 배치 `ownsGate`): `d21` — 리스트
사이클당 `recompute(L)` 정확히 1회, 물리 op이 최소(리오더 `{b,a,c}`
move 1 + insert 1), 중첩 Slot 요소의 `Length` 전파·리오더 뒤 `Offset`/
`getOffsetAt` 값 정확. `H-145`(`bk.indexOfElement` weak-key): `d20` — 옛 Slot을
언마운트하고 참조를 놓으면 GC 뒤 키가 사라짐, 마운트 중인 키는 `_elements`
잡아 유지. **단** Roblox `Instance`가 weak 키일 때의 거동은 CLI에서 실측
불가(§6).
- **레인 B — `:List` × `Detach` × `KeyGone` × 재등장 × 파괴(`d22`)** — 사라진 키에
`Detach``_detached` 보유·`Length` 감소·물리 extract 1회, 재등장 시 `prev`
재마운트(새로 만들지 않음, `indexOfElement` 갱신), `KeyGone`에 새 값 반환은
error, `destroySlotTree``_detached`까지 dispose, 파괴된 Slot 재마운트 error
(`_destroyed`, 9라운드 Q2).
- **레인 B — 읽기로만 확인(값 트레이스 없음)**: `SlotHandler` 재발행/교체/A→B→A
왕복의 `claimOwnerAt`·`unmountSlotTree`·재마운트 경로, `activateList`
`_detachCleanup` 바인드/언바인드 짝, `settle` 여덟 분기(`Owned` × `Detach` ×
`KeyGone`), `rawMove` 규약 4의 `setLength` 재등록, `H-142` `Parent` 금지 +
`H-146` 루트 예외, `H-138` 숏핸드 우선순위(`StoreBind` → 숏핸드 → `Property`),
`Tag` 참조 카운트, `Attribute` 그룹 전용 키 + `groupClaimKeys` 순서, `Modifier`
flatten 역순, `Tween` 3-상태, `OnChange` `v == nil` 얼리리턴, 훅 슈가 `guard`.
모순은 위 `H-154`~`H-156`뿐.
- **레인 D**`./scripts/test.sh` **ALL PASS**(`smoke.init`/`mock`/`plugin`,
`luau-analyze` 클린). 커밋된 M1 코드와 `base/`(`RunInit` 멱등·`AddPlugin` mutate·
`@self` require)에 어긋남 없음.
## §6 남은 의심 / 못 본 것
(탐사자가 채운다.)
**값으로 안 돌려본 것(읽기만)** — 레인 B 중 `New`/`drive` 파이프라인(`H-139`
의사코드 자체), `Dispatch.process` (A)/(B) 분기 위의 `InstanceChildHandler` 이외
말단(`Tag`/`Attribute`/`OnChange`/`PropertyHandler`+`Tween`), `Modifier` flatten
(`luau-test/done/17`이 별도로 있음), `rawMove`/`rawSwap`(base에 의사코드가 없어
`H-29` 규약을 탐사자가 옮긴 것이라 그 자체가 검증 대상이 아님),
`collectLeaves`/`Extract`/`Splice`의 `native*` 인자, `dispose` 가드,
`Debounce`/`Throttle`(7절이 무효화 배너 아래라 옮길 의사코드가 없음),
`Fallback`/`Traceback`(백로그 `H-26` 그대로), `AttributeGroupHandler` 부분 실패
경로, `PostRef` pre-pass.
**로컬에서 실측 불가** — (1) `bk.indexOfElement`의 weak 키가 Roblox `Instance`
때: `Instance`는 userdata라 weak 키 수거 조건이 Luau 테이블과 다르고
(`Destroy`된 뒤에도 Lua 쪽 참조가 있으면 남음), gcconn 자기순환이 그 참조를 들고
있다 — `d20`은 테이블 키로만 확인했다. (2) `Workspace.SignalBehavior = Deferred`
× `Destroying` cleanup × "지연 창 안에서 재바인드": `_bindDestroying`
`_unbindDestroying()` 선행이 옛 연결을 끊지만, Deferred 큐에 이미 들어간 옛
`Destroying` 콜백이 새 연결 뒤에 도는 순서는 `ref-plan.md`의 실측 계획에만
있고 여기선 볼 수 없다. (3) `Parent = nil``Parent = inst`(`H-154`)가 실제로
`ChildAdded`/`AncestryChanged`를 몇 번 쏘는지.
**남은 의심** — (a) `H-151` (1)에서 캐치업 `Rerun` 뒤 게이트 flush가 같은 원천을
싣고 올 때 `Update`가 접는 건 실측됐지만, **`Ref` dep**(리비전이 `Set`마다
`bnot`으로 감기는 `Epoch`)에서 캐치업과 flush 사이에 `Set`이 한 번 더 오면
접히지 않고 두 번 돈다 — 정상 동작(두 번 바뀌었으니)이지만 `Refresh`가 "변화
있음"만 주고 횟수를 안 주는 게 맞는지는 안 따졌다. (b) `Effect(fn)`(dep 0개)의
`_epochs`가 빈 맵이라 `Refresh()`가 항상 거짓 → 재바인드 캐치업이 `not
_installed`로만 갈리는데, 그 경우 "파괴로 소진 → 재바인드 → 재설치"가 맞는 동작인지
(`OnDestroyed` 슈가가 이 경로로 두 번째 inst에도 cleanup을 달게 됨 — 아마 의도).
(c) `H-148`은 새 증거 없음. (d) 9라운드 §6이 남긴 `H-117`(`Of` 무주석 →
`Source<unknown>`)은 `ty11`에서 주석 있는 경우만 봤다. (e) `luau-test/STATUS.md`
`rewrite-required/` 스파이크는 이번에 다시 안 돌렸다(상태는 그 파일이 소스).
## §7 레인 C 실행 기록
(탐사자가 채운다 — `audit/handtrace-round10-reference-impl/`.)
폴더 `audit/handtrace-round10-reference-impl/`(구성·갱신 내역·옮기며 고친 것은
`README.md`가 소스). 참고 구현 `core10.luau`(반응형 코어 — `EpochMap:Peek`,
Observer 3-인자, `Ref` `fn(value, ref)` + `WeakCallback`, `Effect` 생성자
`_blocker`/`fire`/`_bindDestroying`/`Rerun` `H-143` 꼬리/네 진입점 + `resubscribeTail`,
`GateNode` unfold + `emit(commit) -> boolean`, `Blocker:Policy`) /
`dispatch10.luau`(부기 — weak-키 `indexOfElement`, `H-124` `recompute`, 5-인자
`setLength`, Slot 생성자의 `Length`/`Offset`/`_baseObserver`/`_destroyed`,
`materializeSlotTree`/`mountSlotTree`, `raw*`, `:List`/`reconcile` `ownsGate`,
`mountTop` retractor). 전부 `luau <파일>`로 종료 0(2026-08-28 재실행), `ty11`
`luau-analyze`.
| 스파이크 | 무엇 | 결과 |
|---|---|---|
| `t14_effect_state_matrix` | `EffectHandle` 네 진입점 × 상태 전이표 | 문서 표와 일치(§5) |
| `t15_rerun_dead_handle` | `H-147` 케이스 1·2·3 + 대조군 | 재현(§5) |
| `t16_gate_resub_refresh` | 게이트 × 재구독/재바인드 × `Refresh`/`Peek`/`Update` 7건 | 5건 확정대로, 2건 → `H-151` |
| `t17_hold_gc` | `_hold` 불변식 양성/음성 | 확정대로 |
| `t18_blocker_vestigial_and_set_same` | `Effect._blocker` 판정 횟수 / `Source:Set` 같은 값 | `drop:blocker` 0 → `H-150`; 같은 값 emit 확정대로 |
| `t19_observer_level2` | `H-149` blame 줄 | 재현 |
| `t22_fn_destroys_inst` | `fn` 안에서 자기 inst 동기 파괴(`H-143`) | cleanup 즉시 1회 |
| `t23_store_reserved_runtime` | 그림자 (I)/(II) × `Of("Of")` × `defaults.Of` | → `H-153` |
| `t24_gate_brand_missing` | `GateNode` 브랜드 누락 재현 | 발화 2 → 0 → `H-152` |
| `ty11_store_final` | 최종형 Store 타입 A~H + 빈 Store + `Of<<T>>` | 양성 0건·음성 정확 → `H-157` |
| `d10`/`d11`/`d13`/`d14` `_r10` | `H-124` 되감기 네 케이스(round9 스파이크를 `dispatch10`으로) | 전부 통과 |
| `d20_indexOfElement_weak` | `H-145` weak 키 GC | 확정대로(테이블 키 한정) |
| `d21_reconcile_batch` | `H-136` `reconcile` 배치 + 중첩 Slot 리오더 | 사이클당 `recompute(L)` 1 |
| `d22_detach_keygone` | `Detach`/`KeyGone`/재등장/파괴 | 확정대로 |
| `d23_instancechild_spurious` | `InstanceChildHandler` 같은 값 재발행 | `Parent` 2회 → `H-154` |
옮기며 발견한 것(발견 번호 없음, README에 기록): round7/ref9 참고 구현의
`_recompute``fn`의 첫 인자로 **결과 노드**를 넘기고 있었다(base는 리시버의
lazy 핸들) — 그 스파이크들은 첫 인자를 안 써서 무해했지만 `t16`/`t17`은 그
자리에서 무한 재귀했다. 그리고 `core10.luau``isState``GateNode`를 직접
포함하는데, 그게 곧 `H-152`다.