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:
parent
0ec22fbe73
commit
d2d67c7aeb
74 changed files with 4473 additions and 9 deletions
File diff suppressed because one or more lines are too long
98
.claude/audit/handtrace-round10-reference-impl/README.md
Normal file
98
.claude/audit/handtrace-round10-reference-impl/README.md
Normal 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`의 체인만 쓴다.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
--!nocheck
|
||||
-- base/dispatch-core-plan.md "Dispatch 체인" 절의 확정 의사코드 그대로
|
||||
local C = {}
|
||||
local chains = {}
|
||||
local NOOP = function() end
|
||||
local handlers = {}
|
||||
function C.addHandler(h) table.insert(handlers, h) end
|
||||
local function getHandler(inst, k, v)
|
||||
for _, h in handlers do if h.isHandlable(inst, k, v) then return h end end
|
||||
error("Dispatch: 매치되는 핸들러 없음")
|
||||
end
|
||||
function C.process(inst, k, v, index)
|
||||
local list = chains[inst] and chains[inst][k]
|
||||
if not list then
|
||||
list = {}
|
||||
chains[inst] = chains[inst] or {}
|
||||
chains[inst][k] = list
|
||||
end
|
||||
local slot = list[index]
|
||||
local h = getHandler(inst, k, v)
|
||||
if slot ~= nil and slot.handler == h then
|
||||
slot.retractor(v)
|
||||
slot.retractor = NOOP
|
||||
local retractor = h.process(inst, k, v, index)
|
||||
if retractor == nil then error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가") end
|
||||
slot.retractor = retractor
|
||||
else
|
||||
C.retractFrom(inst, k, index)
|
||||
list[index] = { handler = h, retractor = NOOP }
|
||||
local retractor = h.process(inst, k, v, index)
|
||||
if retractor == nil then error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가") end
|
||||
list[index] = { handler = h, retractor = retractor }
|
||||
end
|
||||
end
|
||||
function C.retractFrom(inst, k, index)
|
||||
local list = chains[inst] and chains[inst][k]
|
||||
if not list then return end
|
||||
for i = #list, index, -1 do
|
||||
local slot = list[i]
|
||||
if slot == nil then error("Dispatch: 인덱스 " .. i .. "에 슬롯이 없음 — 배열에 구멍이 뚫렸음") end
|
||||
slot.retractor(nil)
|
||||
list[i] = nil
|
||||
end
|
||||
end
|
||||
C.dump = function(inst, k) return chains[inst] and chains[inst][k] end
|
||||
return C
|
||||
209
.claude/audit/handtrace-round10-reference-impl/spikes/core.luau
Normal file
209
.claude/audit/handtrace-round10-reference-impl/spikes/core.luau
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
--!nocheck
|
||||
-- 문서(base/state-epoch-plan.md §2~§5, base/source-state-plan.md 전파 모델,
|
||||
-- base/gate-plan.md 4/8번, base/blocker-plan.md)를 그대로 옮긴 참조 구현.
|
||||
-- 방어 코드(pcall 등)는 문서에 없으므로 넣지 않는다.
|
||||
|
||||
local M = {}
|
||||
|
||||
local function newWeakK() return setmetatable({}, {__mode = "k"}) end
|
||||
|
||||
-- ── EpochMap ────────────────────────────────────────────────────────────
|
||||
local EpochMap = {}
|
||||
EpochMap.__index = EpochMap
|
||||
function M.EpochMap()
|
||||
return setmetatable({map = newWeakK()}, EpochMap)
|
||||
end
|
||||
local function unfold(from)
|
||||
-- Epoch 하나면 {from}, EpochSet이면 그 키들
|
||||
if from.Revision ~= nil then return {from} end
|
||||
local t = {}
|
||||
for e in from do t[#t+1] = e end
|
||||
return t
|
||||
end
|
||||
M.unfold = unfold
|
||||
function EpochMap:Update(from)
|
||||
local changed = false
|
||||
for _, e in unfold(from) do
|
||||
if self.map[e] ~= e.Revision then
|
||||
changed = true
|
||||
self.map[e] = e.Revision
|
||||
end
|
||||
end
|
||||
return changed
|
||||
end
|
||||
function EpochMap:Sync(from)
|
||||
for _, e in unfold(from) do self.map[e] = e.Revision end
|
||||
end
|
||||
function EpochMap:Refresh()
|
||||
local changed = false
|
||||
for e, rev in self.map do
|
||||
if e.Revision ~= rev then changed = true; self.map[e] = e.Revision end
|
||||
end
|
||||
return changed
|
||||
end
|
||||
function EpochMap:TrackFrom(other)
|
||||
for e in other.map do self.map[e] = e.Revision end
|
||||
end
|
||||
-- 문서에 없는 연산(H-72). 게이트 판정용으로만 쓰고, 쓸 때마다 표시한다.
|
||||
function EpochMap:PeekDiffers(from)
|
||||
for _, e in unfold(from) do
|
||||
if self.map[e] ~= e.Revision then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ── 전파 ────────────────────────────────────────────────────────────────
|
||||
local function emitDown(node, from)
|
||||
-- H-23 확정: 구독자 집합을 배열로 스냅샷한 뒤 돈다
|
||||
local snapshot = {}
|
||||
for sub in node.subs do snapshot[#snapshot+1] = sub end
|
||||
for _, sub in snapshot do
|
||||
sub:_receive(from)
|
||||
end
|
||||
end
|
||||
M.emitDown = emitDown
|
||||
|
||||
-- ── Source ──────────────────────────────────────────────────────────────
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function M.Source(v)
|
||||
return setmetatable({value = v, Revision = 0, subs = newWeakK()}, Source)
|
||||
end
|
||||
function Source:Get() return self.value end
|
||||
function Source:Set(v)
|
||||
self.value = v
|
||||
self.Revision = bit32.bnot(-self.Revision)
|
||||
emitDown(self, self)
|
||||
end
|
||||
function Source:_track(map) map:Sync(self) end
|
||||
function Source:_addSub(s) self.subs[s] = true end
|
||||
|
||||
-- ── State(Compute/With) ─────────────────────────────────────────────────
|
||||
local State = {}
|
||||
State.__index = State
|
||||
function M.State(fn, deps, name)
|
||||
local self = setmetatable({
|
||||
fn = fn, deps = deps, name = name,
|
||||
valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(),
|
||||
rawInvalid = true, cache = nil, subs = newWeakK(),
|
||||
recomputes = 0,
|
||||
}, State)
|
||||
for _, d in deps do
|
||||
d:_addSub(self)
|
||||
d:_track(self.valueEpochMap) -- Source면 Sync, State면 TrackFrom
|
||||
end
|
||||
return self
|
||||
end
|
||||
function State:_track(map) map:TrackFrom(self.valueEpochMap) end
|
||||
function State:_addSub(s) self.subs[s] = true end
|
||||
function State:_receive(from)
|
||||
local valueChanged = self.valueEpochMap:Update(from)
|
||||
local emitChanged = self.emitEpochMap:Update(from)
|
||||
if valueChanged then self.rawInvalid = true end
|
||||
if valueChanged or emitChanged then emitDown(self, from) end
|
||||
end
|
||||
function State:_recompute()
|
||||
self.recomputes += 1
|
||||
self.cache = self.fn(self, self.cache, table.unpack(self.deps))
|
||||
self.rawInvalid = false
|
||||
for _, d in self.deps do d:_track(self.valueEpochMap) end
|
||||
end
|
||||
function State:Get()
|
||||
if self.rawInvalid then
|
||||
self:_recompute()
|
||||
elseif self.valueEpochMap:Refresh() then
|
||||
self.rawInvalid = true
|
||||
self:_recompute()
|
||||
end
|
||||
return self.cache
|
||||
end
|
||||
|
||||
-- ── Observer(leaf) ──────────────────────────────────────────────────────
|
||||
local Observer = {}
|
||||
Observer.__index = Observer
|
||||
function M.Observer(state, fn, name)
|
||||
local self = setmetatable({fn = fn, name = name, fires = 0}, Observer)
|
||||
state:_addSub(self)
|
||||
self.owner = state
|
||||
return self
|
||||
end
|
||||
function Observer:_receive(from)
|
||||
self.fires += 1
|
||||
self.fn(self, from)
|
||||
end
|
||||
|
||||
-- ── GateNode ────────────────────────────────────────────────────────────
|
||||
local GateNode = {}
|
||||
GateNode.__index = GateNode
|
||||
function M.Gate(upstream, setup, name)
|
||||
local self = setmetatable({
|
||||
up = upstream, name = name,
|
||||
valueEpochMap = M.EpochMap(), emitEpochMap = M.EpochMap(),
|
||||
rawInvalid = true, cache = nil, subs = newWeakK(),
|
||||
withheld = newWeakK(), flushes = 0,
|
||||
}, GateNode)
|
||||
upstream:_addSub(self)
|
||||
upstream:_track(self.valueEpochMap)
|
||||
local function emit() self:_flush() end
|
||||
self.onUpstreamEmit = setup(emit)
|
||||
return self
|
||||
end
|
||||
function GateNode:_track(map) map:TrackFrom(self.valueEpochMap) end
|
||||
function GateNode:_addSub(s) self.subs[s] = true end
|
||||
function GateNode:_flush()
|
||||
if next(self.withheld) == nil then return end -- 8번: 빈 배치는 아무것도 안 함
|
||||
local batch = self.withheld
|
||||
self.withheld = newWeakK() -- 진입 시 스왑(H-9)
|
||||
self.flushes += 1
|
||||
self.emitEpochMap:Sync(batch) -- 전파할 때 갱신(§4 예외)
|
||||
emitDown(self, batch)
|
||||
end
|
||||
function GateNode:_discard() -- OffWithoutEmit 경로
|
||||
self.withheld = newWeakK()
|
||||
end
|
||||
function GateNode:_receive(from)
|
||||
-- 규칙 1~3을 먼저. 게이트는 emitEpochMap을 수신 시점에 갱신하지 않으므로
|
||||
-- 비교만 하는 연산이 필요하다(H-72) — PeekDiffers로 대신한다.
|
||||
local valueChanged = self.valueEpochMap:Update(from)
|
||||
local emitDiffers = self.emitEpochMap:PeekDiffers(from)
|
||||
if valueChanged then self.rawInvalid = true end
|
||||
if not (valueChanged or emitDiffers) then return end -- 규칙 3: 삼킴
|
||||
for _, e in unfold(from) do self.withheld[e] = true end
|
||||
self.onUpstreamEmit()
|
||||
end
|
||||
function GateNode:Get()
|
||||
if self.rawInvalid then
|
||||
self.cache = self.up:Get()
|
||||
self.rawInvalid = false
|
||||
self.up:_track(self.valueEpochMap)
|
||||
elseif self.valueEpochMap:Refresh() then
|
||||
self.cache = self.up:Get()
|
||||
self.up:_track(self.valueEpochMap)
|
||||
end
|
||||
return self.cache
|
||||
end
|
||||
|
||||
-- ── Blocker ─────────────────────────────────────────────────────────────
|
||||
local Blocker = {}
|
||||
Blocker.__index = Blocker
|
||||
function M.Blocker()
|
||||
return setmetatable({IsBlocked = false, handles = {}}, Blocker)
|
||||
end
|
||||
function Blocker:IsOn() return self.IsBlocked end
|
||||
function Blocker:On() self.IsBlocked = true; return self end
|
||||
function Blocker:_run(emit)
|
||||
for _, h in self.handles do h(emit) end
|
||||
end
|
||||
function Blocker:Off() self.IsBlocked = false; self:_run(true); return self end
|
||||
function Blocker:OffWithoutEmit() self.IsBlocked = false; self:_run(false); return self end
|
||||
-- blocker:Policy(emit) -> onUpstreamEmit
|
||||
function Blocker:Policy(emit, discard)
|
||||
table.insert(self.handles, function(doEmit)
|
||||
if doEmit then emit() else (discard or function() end)() end
|
||||
end)
|
||||
return function()
|
||||
if not self.IsBlocked then emit() end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -0,0 +1,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
|
||||
307
.claude/audit/handtrace-round10-reference-impl/spikes/core9.luau
Normal file
307
.claude/audit/handtrace-round10-reference-impl/spikes/core9.luau
Normal 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
|
||||
|
|
@ -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, " "))
|
||||
|
|
@ -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, " "))
|
||||
|
|
@ -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, " "))
|
||||
|
|
@ -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, " "))
|
||||
|
|
@ -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
|
||||
|
|
@ -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)")
|
||||
|
|
@ -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)")
|
||||
|
|
@ -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, " "))
|
||||
|
|
@ -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, " "))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)")
|
||||
|
|
@ -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)")
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
--!nocheck
|
||||
local q = require("./core"); local D = require("./dispatch")
|
||||
local inst = {} -- 최상위 물리 inst가 owner
|
||||
-- Frame { A, B, C } — 전부 plain 요소(길이 1, 발행 채널 None)
|
||||
local bl = D.getBlocker(inst); bl:On()
|
||||
for i = 1, 3 do
|
||||
D.setOffsetSource(inst, i, D.None)
|
||||
D.setLength(inst, i, 1, inst)
|
||||
end
|
||||
bl:OffWithoutEmit(); D.recompute(inst, D.getBookkeeping(inst))
|
||||
print("offsets:", D.getOffsetAt(inst,1), D.getOffsetAt(inst,2), D.getOffsetAt(inst,3), "| 다음 자리:", D.getOffsetAt(inst,4))
|
||||
print("recompute 횟수:", D.stats())
|
||||
|
|
@ -0,0 +1,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)")
|
||||
|
|
@ -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)")
|
||||
|
|
@ -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)))
|
||||
|
|
@ -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))
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
--!nocheck
|
||||
local q = require("./core"); local D = require("./dispatch")
|
||||
-- Frame { Slot1, E(plain), Slot2 }
|
||||
local inst = {}
|
||||
local s1, s2 = D.newSlot("s1"), D.newSlot("s2")
|
||||
local bl = D.getBlocker(inst); bl:On()
|
||||
D.setOffsetSource(inst, 1, s1.Offset); D.setLength(inst, 1, s1.Length, inst)
|
||||
D.setOffsetSource(inst, 2, D.None); D.setLength(inst, 2, 1, inst)
|
||||
D.setOffsetSource(inst, 3, s2.Offset); D.setLength(inst, 3, s2.Length, inst)
|
||||
bl:OffWithoutEmit(); D.recompute(inst, D.getBookkeeping(inst))
|
||||
print("초기: s1.Offset =", s1.Offset:Get(), "| s2.Offset =", s2.Offset:Get(), "| recompute:", D.stats())
|
||||
|
||||
print("-- 런타임에 Slot1이 3개가 됨 (s1.Length:Set(3)) --")
|
||||
s1.Length:Set(3)
|
||||
print("s1.Offset =", s1.Offset:Get(), "| s2.Offset =", s2.Offset:Get(), "(기대 4) | recompute:", D.stats())
|
||||
|
||||
print("-- Slot2도 2개가 됨 --")
|
||||
s2.Length:Set(2)
|
||||
print("s2.Offset =", s2.Offset:Get(), "(기대 4) | 4번 자리 offset =", D.getOffsetAt(inst,4), "(기대 6)")
|
||||
|
||||
print("-- Slot1이 다시 0개 --")
|
||||
s1.Length:Set(0)
|
||||
print("s2.Offset =", s2.Offset:Get(), "(기대 1) | 4번 자리 =", D.getOffsetAt(inst,4), "(기대 3)")
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
--!nocheck
|
||||
local q = require("./core"); local D = require("./dispatch")
|
||||
local owner = D.newSlot("owner")
|
||||
local s1, s2 = D.newSlot("s1"), D.newSlot("s2")
|
||||
local bl = D.getBlocker(owner); bl:On()
|
||||
D.setOffsetSource(owner, 1, s1.Offset); D.setLength(owner, 1, s1.Length, owner)
|
||||
D.setOffsetSource(owner, 2, s2.Offset); D.setLength(owner, 2, s2.Length, owner)
|
||||
bl:OffWithoutEmit(); D.recompute(owner, D.getBookkeeping(owner))
|
||||
print("초기 owner.Length =", owner.Length:Get(), "| s2.Offset =", s2.Offset:Get())
|
||||
|
||||
-- s2.Offset을 보는 소비자가 그 통지 도중 같은 owner에 3번째 자리를 추가한다
|
||||
local added = false
|
||||
local watcher = q.Observer(s2.Offset, function()
|
||||
if added then return end
|
||||
added = true
|
||||
D.setOffsetSource(owner, 3, D.None)
|
||||
D.setLength(owner, 3, 1, owner) -- 재진입 recompute
|
||||
end)
|
||||
watcher.Subscribed = true
|
||||
|
||||
print("-- s1.Length:Set(2) --")
|
||||
s1.Length:Set(2)
|
||||
local bk = D.getBookkeeping(owner)
|
||||
local expect = 0
|
||||
for i = 1, bk.N do
|
||||
local v = bk.lengthList[i]
|
||||
expect += (if type(v) == "table" then v:Get() else v)
|
||||
end
|
||||
print("owner.Length =", owner.Length:Get(), "| 실제 합계 =", expect, "| N =", bk.N, "| recompute:", D.stats())
|
||||
print("s2.Offset =", s2.Offset:Get(), "(기대 2) | 3번 자리 offset =", D.getOffsetAt(owner, 3), "(기대 2)")
|
||||
if owner.Length:Get() ~= expect then print(" ❌ owner.Length가 실제 합계와 다르다") else print(" ✅ 일치") end
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
--!nocheck
|
||||
local C = require("./chain")
|
||||
-- AttributeKeyHandler(attribute-plan.md 확정 의사코드)를 그대로 옮긴 것
|
||||
local nameClaims = {}
|
||||
local setAttributeInjected = false
|
||||
local function setAttribute(inst, name, v)
|
||||
if not setAttributeInjected then
|
||||
error("quad: setAttribute op이 주입되지 않았습니다(백엔드 팩토리 미설치)") -- 확정된 "에러내는 스텁"
|
||||
end
|
||||
end
|
||||
local AttributeKeyHandler = {
|
||||
isHandlable = function(inst, k, v) return type(k) == "table" and k.Name ~= nil end,
|
||||
process = function(inst, k, v, index)
|
||||
local cur = nameClaims[inst] and nameClaims[inst][k.Name]
|
||||
if cur ~= nil and cur ~= k then
|
||||
error(`attribute "{k.Name}" is already bound by another owner`)
|
||||
end
|
||||
nameClaims[inst] = nameClaims[inst] or {}
|
||||
nameClaims[inst][k.Name] = k
|
||||
setAttribute(inst, k.Name, v) -- ← 주입 안 됐으면 여기서 던진다
|
||||
return function()
|
||||
if nameClaims[inst][k.Name] == k then nameClaims[inst][k.Name] = nil end
|
||||
end
|
||||
end,
|
||||
}
|
||||
C.addHandler(AttributeKeyHandler)
|
||||
|
||||
local inst = {}
|
||||
local keyA = { Name = "Score" }
|
||||
local keyB = { Name = "Score" } -- 같은 이름을 쓰는 다른 소유자(그룹 Attribute 등)
|
||||
|
||||
print("-- 백엔드 미주입 상태에서 Attribute 한 번 --")
|
||||
local ok, err = pcall(C.process, inst, keyA, 10, 1)
|
||||
print("던졌는가:", not ok, "|", err)
|
||||
print("claim 남았는가:", nameClaims[inst] and nameClaims[inst].Score ~= nil)
|
||||
local list = C.dump(inst, keyA)
|
||||
print("체인 슬롯:", list and list[1] and "있음(retractor=" .. tostring(list[1].retractor) .. ")" or "없음")
|
||||
|
||||
print("-- 이제 백엔드를 붙이고 정상적으로 다시 시도 --")
|
||||
setAttributeInjected = true
|
||||
local ok2, err2 = pcall(C.process, inst, keyB, 20, 1)
|
||||
print("성공했는가:", ok2, "|", err2)
|
||||
|
||||
print("-- 명시적 철거로 회수되는가 --")
|
||||
setAttributeInjected = true
|
||||
pcall(C.retractFrom, inst, keyA, 1)
|
||||
print("철거 후 claim 남았는가:", nameClaims[inst] and nameClaims[inst].Score ~= nil)
|
||||
local ok3, err3 = pcall(C.process, inst, keyB, 20, 1)
|
||||
print("철거 후 재시도 성공?:", ok3, "|", err3)
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
--!nocheck
|
||||
local q = require("./core"); local D = require("./dispatch")
|
||||
-- RETUNE = true면 splice가 이동된 observer의 위치 인덱스를 실제 위치로 다시 맞춘다
|
||||
local function run(RETUNE)
|
||||
local function spliceArraysDown(ownerKey, index)
|
||||
local bk = D.getBookkeeping(ownerKey)
|
||||
for i = index, bk.N - 1 do
|
||||
bk.lengthList[i] = bk.lengthList[i + 1]
|
||||
bk.sourceList[i] = bk.sourceList[i + 1]
|
||||
bk.observers[i] = bk.observers[i + 1]
|
||||
end
|
||||
bk.lengthList[bk.N] = nil; bk.sourceList[bk.N] = nil; bk.observers[bk.N] = nil
|
||||
bk.N -= 1
|
||||
bk.invalidAfter = math.min(bk.invalidAfter, index)
|
||||
if RETUNE then
|
||||
for i = index, bk.N do
|
||||
local o = bk.observers[i]
|
||||
if o and o.box then o.box.pos = i end
|
||||
end
|
||||
end
|
||||
end
|
||||
local owner, s = {}, {}
|
||||
for i = 1, 3 do s[i] = D.newSlot("s" .. i) end
|
||||
local bl = D.getBlocker(owner); bl:On()
|
||||
for i = 1, 3 do
|
||||
D.setOffsetSource(owner, i, s[i].Offset)
|
||||
D.setLength(owner, i, s[i].Length, owner)
|
||||
s[i].Length:Set(1)
|
||||
end
|
||||
bl:OffWithoutEmit(); D.recompute(owner, D.getBookkeeping(owner))
|
||||
spliceArraysDown(owner, 1)
|
||||
D.recompute(owner, D.getBookkeeping(owner))
|
||||
s[2].Length:Set(5)
|
||||
print(("[%s] s3.Offset = %d (기대 5) | invalidAfter = %d")
|
||||
:format(if RETUNE then "위치 재조정함" else "확정 설계 그대로", s[3].Offset:Get(),
|
||||
D.getBookkeeping(owner).invalidAfter))
|
||||
end
|
||||
run(false)
|
||||
run(true)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
--!nocheck
|
||||
local q = require("./core"); local D = require("./dispatch")
|
||||
local inst = {}
|
||||
local s2 = D.newSlot("s2")
|
||||
local bl = D.getBlocker(inst); bl:On()
|
||||
-- 1번 자리 등록 생략(H-39 버그 재발), 2번은 발행 채널이 있는 Slot
|
||||
local ok, err = pcall(function()
|
||||
D.setOffsetSource(inst, 2, s2.Offset) -- 여기서 getOffsetAt(inst, 2)를 부른다
|
||||
end)
|
||||
print("setOffsetSource에서 터지는가:", not ok)
|
||||
print("메시지:", err)
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
--!nocheck
|
||||
-- base/dispatch-core-plan.md의 Length/Offset 부기를 확정 의사코드 그대로 옮긴 것.
|
||||
-- getOffsetAt(접두합 캐시, 2026-08-21 G절 + H-3) / recompute(H-10 정정 반영) /
|
||||
-- setLength / setOffsetSource / 배치 게이팅.
|
||||
local q = require("./core")
|
||||
local D = {}
|
||||
|
||||
local None = setmetatable({}, {__tostring = function() return "None" end})
|
||||
D.None = None
|
||||
local function isSlot(x) return type(x) == "table" and x.__isSlot == true end
|
||||
local function isState(x) return type(x) == "table" and (getmetatable(x) ~= nil) and x.Get ~= nil end
|
||||
|
||||
local books, blockers = {}, {}
|
||||
local function getBookkeeping(ownerKey)
|
||||
local bk = books[ownerKey]
|
||||
if not bk then
|
||||
bk = { lengthList = {}, sourceList = {}, observers = {}, N = nil,
|
||||
offsetCache = {}, invalidAfter = 0 }
|
||||
books[ownerKey] = bk
|
||||
end
|
||||
return bk
|
||||
end
|
||||
local function getBlocker(ownerKey)
|
||||
local b = blockers[ownerKey]
|
||||
if not b then b = q.Blocker(); blockers[ownerKey] = b end
|
||||
return b
|
||||
end
|
||||
D.getBookkeeping, D.getBlocker = getBookkeeping, getBlocker
|
||||
|
||||
local function contribution(bk, i)
|
||||
local v = bk.lengthList[i]
|
||||
return (if isState(v) then v:Get() else v)
|
||||
end
|
||||
|
||||
function D.getOffsetAt(ownerKey, at)
|
||||
local bk = getBookkeeping(ownerKey)
|
||||
if bk.invalidAfter == 0 then
|
||||
bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
|
||||
bk.invalidAfter = 1
|
||||
end
|
||||
if at <= bk.invalidAfter then
|
||||
return bk.offsetCache[at]
|
||||
end
|
||||
local cur = bk.offsetCache[bk.invalidAfter]
|
||||
for i = bk.invalidAfter, at - 1 do
|
||||
cur += contribution(bk, i)
|
||||
bk.offsetCache[i + 1] = cur
|
||||
end
|
||||
bk.invalidAfter = at
|
||||
return cur
|
||||
end
|
||||
|
||||
local recomputes = 0
|
||||
D.stats = function() return recomputes end
|
||||
local function recompute(ownerKey, bk)
|
||||
recomputes += 1
|
||||
local sum = 0
|
||||
for i = 1, bk.N or 0 do
|
||||
local offset = bk.sourceList[i]
|
||||
if offset == nil then
|
||||
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
|
||||
end
|
||||
local abs = D.getOffsetAt(ownerKey, i)
|
||||
if offset ~= None and offset:Get() ~= abs then
|
||||
offset:Set(abs)
|
||||
end
|
||||
local v = bk.lengthList[i]
|
||||
sum += (if isState(v) then v:Get() else v)
|
||||
end
|
||||
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
|
||||
ownerKey.Length:Set(sum)
|
||||
end
|
||||
end
|
||||
D.recompute = recompute
|
||||
|
||||
function D.setLength(ownerKey, i, len, anchor)
|
||||
anchor = anchor or ownerKey
|
||||
local bk = getBookkeeping(ownerKey)
|
||||
local blocker = getBlocker(ownerKey)
|
||||
local oldObserver = bk.observers[i]
|
||||
if oldObserver then bk.observers[i] = nil end
|
||||
bk.lengthList[i] = len
|
||||
bk.N = math.max(bk.N or 0, i)
|
||||
bk.invalidAfter = math.min(bk.invalidAfter, i)
|
||||
|
||||
local box = { pos = i } -- [대조군용] 실제 위치를 가변 박스로 들면 splice가 고칠 수 있다
|
||||
local function gatedRecompute()
|
||||
bk.invalidAfter = math.min(bk.invalidAfter, box.pos)
|
||||
if not blocker:IsOn() then
|
||||
recompute(ownerKey, bk)
|
||||
end
|
||||
end
|
||||
if isState(len) then
|
||||
local observer = q.Observer(len, gatedRecompute)
|
||||
observer.pos = box.pos; observer.box = box
|
||||
bk.observers[i] = observer
|
||||
gatedRecompute() -- "등록 즉시 1회 실행도 게이팅됨"
|
||||
else
|
||||
gatedRecompute()
|
||||
end
|
||||
end
|
||||
|
||||
function D.setOffsetSource(ownerKey, i, source)
|
||||
local bk = getBookkeeping(ownerKey)
|
||||
bk.sourceList[i] = source
|
||||
if source == None then return end
|
||||
local offset = D.getOffsetAt(ownerKey, i)
|
||||
if source:Get() ~= offset then source:Set(offset) end
|
||||
end
|
||||
|
||||
-- 테스트 편의: Slot 흉내(Length/Offset 공개 필드만)
|
||||
function D.newSlot(name)
|
||||
return { __isSlot = true, name = name, Length = q.Source(0), Offset = q.Source(0) }
|
||||
end
|
||||
return D
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
--!nocheck
|
||||
-- quad 확정 의사코드는 전부 level 인자 없는 error(msg) — level 1이 기본
|
||||
local Slot = {}
|
||||
function Slot.Add(self, index) -- quad 내부(라이브러리) 코드
|
||||
error("Slot:Add — index가 범위 밖(1..3): " .. tostring(index))
|
||||
end
|
||||
function Slot.AddL2(self, index)
|
||||
error("Slot:Add — index가 범위 밖(1..3): " .. tostring(index), 2)
|
||||
end
|
||||
|
||||
local ok1, e1 = pcall(function() Slot.Add(nil, 9) end) -- 사용자 코드 줄
|
||||
local ok2, e2 = pcall(function() Slot.AddL2(nil, 9) end)
|
||||
print("level 기본(1):", e1)
|
||||
print("level 2 :", e2)
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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 -- 더 받는 쪽: 에러여야 정상
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
local SubscribedObservers = {}
|
||||
local function Subscribe(o) SubscribedObservers[o] = true; o.Subscribed = true; return o end
|
||||
|
||||
-- Observer가 상류를 되참조하지 않는 형태(확정 서술: 구독 엣지는 하류로 weak,
|
||||
-- Observer를 살리는 건 gchold 또는 전역 레지스트리)
|
||||
local Obs = {}
|
||||
Obs.__index = Obs
|
||||
function Obs.new(state, fn) local o = setmetatable({fn = fn, fires = 0}, Obs); state:_addSub(o); return o end
|
||||
function Obs:_receive(from) self.fires += 1; self.fn(self, from) end
|
||||
|
||||
local A = q.Source(1)
|
||||
local fires = 0
|
||||
local o
|
||||
do
|
||||
local mid = q.State(function(self, prev, a) return a:Get() * 2 end, {A}, "mid")
|
||||
o = Subscribe(Obs.new(mid, function() fires += 1 end))
|
||||
end
|
||||
collectgarbage("collect"); collectgarbage("collect")
|
||||
A:Set(2)
|
||||
print("Observer 발화:", fires, " ← 공개 계약(\"예외 없이 그냥 계속 돎\")대로면 1")
|
||||
local n = 0; for _ in A.subs do n += 1 end
|
||||
print("A의 구독자 수:", n, " ← 중간 State가 수거됐으면 0")
|
||||
print("Observer는 레지스트리에 살아있다:", SubscribedObservers[o] == true, "| 그러나 다시는 안 울린다")
|
||||
|
|
@ -0,0 +1,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)")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)")
|
||||
|
|
@ -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 — 통과 모드)")
|
||||
|
|
@ -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)")
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
-- A -> B -> D, A -> C -> D, Observer on D
|
||||
local A = q.Source(1)
|
||||
local B = q.State(function(self, prev, a) return a:Get() * 10 end, {A}, "B")
|
||||
local C = q.State(function(self, prev, a) return a:Get() * 100 end, {A}, "C")
|
||||
local D = q.State(function(self, prev, b, c) return b:Get() + c:Get() end, {B, C}, "D")
|
||||
local seen = {}
|
||||
local O = q.Observer(D, function() table.insert(seen, D:Get()) end, "O")
|
||||
print("초기 D:Get() =", D:Get())
|
||||
A:Set(2)
|
||||
print("Observer 발화 횟수:", O.fires, "| 관측값:", table.concat(seen, ","))
|
||||
print("D 재계산 횟수:", D.recomputes, "| B:", B.recomputes, "| C:", C.recomputes)
|
||||
A:Set(3)
|
||||
print("2회차 후 발화:", O.fires, "| 관측값:", table.concat(seen, ","))
|
||||
|
|
@ -0,0 +1,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)")
|
||||
|
|
@ -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)))
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
local A = q.Source(1)
|
||||
local B = q.State(function(self, prev, a) return a:Get() end, {A}, "B")
|
||||
local C = q.State(function(self, prev, a) return a:Get() end, {A}, "C")
|
||||
-- B 아래에 터지는 Observer, C 아래에 멀쩡한 Observer
|
||||
local bad = q.Observer(B, function() error("사용자 콜백 실패") end, "O_bad")
|
||||
local goodSeen = {}
|
||||
local good = q.Observer(C, function() table.insert(goodSeen, C:Get()) end, "O_good")
|
||||
|
||||
print("-- A:Set(2) (전파 중 O_bad가 error) --")
|
||||
local ok, err = pcall(function() A:Set(2) end)
|
||||
print("Set이 던졌는가:", not ok, "|", err)
|
||||
print("O_bad 발화:", bad.fires, "| O_good 발화:", good.fires, "| 관측:", table.concat(goodSeen, ","))
|
||||
print("C의 값은 자가치유되는가: C:Get() =", C:Get(), "(정상)")
|
||||
print("하지만 O_good은 이 변경에 대해 영원히 안 울었다:", good.fires)
|
||||
|
||||
print("-- 이후 A:Set(3)은 정상인가 --")
|
||||
local ok2 = pcall(function() A:Set(3) end)
|
||||
print("두 번째 Set도 같은 자리에서 던짐:", not ok2, "| O_good 발화:", good.fires)
|
||||
|
||||
print("-- O_bad를 제거하고(구독 해제 없이 새 파동) --")
|
||||
B.subs[bad] = nil
|
||||
A:Set(4)
|
||||
print("O_good 발화:", good.fires, "| 관측:", table.concat(goodSeen, ","))
|
||||
print("→ 2와 3에 대한 통지는 영영 오지 않았다. 마지막 값만 보임")
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
|
||||
print("=== (a) blocker:Off() 순회 중 한 핸들이 error ===")
|
||||
local A = q.Source(1)
|
||||
local B = q.Source(1)
|
||||
local b = q.Blocker()
|
||||
local g1 = q.Gate(A, function(emit) return b:Policy(emit) end, "g1")
|
||||
local g2 = q.Gate(B, function(emit) return b:Policy(emit) end, "g2")
|
||||
local seen1, seen2 = {}, {}
|
||||
local o1 = q.Observer(g1, function() error("g1 하류에서 실패") end, "o1")
|
||||
local o2 = q.Observer(g2, function() table.insert(seen2, g2:Get()) end, "o2")
|
||||
b:On()
|
||||
A:Set(2); B:Set(2)
|
||||
print("보류 중 — g1.withheld 비었나:", next(g1.withheld) == nil, "| g2.withheld 비었나:", next(g2.withheld) == nil)
|
||||
local ok, err = pcall(function() b:Off() end)
|
||||
print("Off()가 던졌는가:", not ok, "|", err)
|
||||
print("blocker.IsBlocked:", b.IsBlocked, "(이미 false — Off는 상태부터 바꾼다)")
|
||||
print("g2가 아직 붙들고 있나(비었으면 flush됨):", next(g2.withheld) == nil, "| o2 발화:", o2.fires)
|
||||
print("→ g2는 IsBlocked=false 인데도 밀린 통지를 못 내보냄. b:Off()를 다시 불러도:")
|
||||
B.subs[g2] = B.subs[g2] -- no-op
|
||||
local ok2 = pcall(function() b:Off() end)
|
||||
print(" 두 번째 Off도 같은 자리에서 던짐:", not ok2, "| o2 발화:", o2.fires)
|
||||
|
||||
print()
|
||||
print("=== (b) flush 도중 error → 떼어낸 배치가 소멸 ===")
|
||||
local S = q.Source(1)
|
||||
local bl = q.Blocker()
|
||||
local g = q.Gate(S, function(emit) return bl:Policy(emit) end, "g")
|
||||
local boom = true
|
||||
local seen = {}
|
||||
local o = q.Observer(g, function()
|
||||
if boom then error("하류 실패") end
|
||||
table.insert(seen, g:Get())
|
||||
end, "o")
|
||||
bl:On()
|
||||
S:Set(2)
|
||||
print("보류됨. withheld 비었나:", next(g.withheld) == nil)
|
||||
pcall(function() bl:Off() end)
|
||||
print("flush 진입 후 withheld 비었나(스왑됨):", next(g.withheld) == nil, "| flush 횟수:", g.flushes)
|
||||
print("gate.emitEpochMap은 이미 Sync됨 → 같은 리비전은 이제 규칙 3으로 삼켜진다")
|
||||
boom = false
|
||||
-- 같은 리비전이 다른 경로로 다시 도착하는 상황을 재현
|
||||
g:_receive(S)
|
||||
print("같은 리비전 재도착 후 o 발화:", o.fires, "| 관측:", table.concat(seen, ","))
|
||||
print("→ 배치는 사라졌고 재도착도 삼켜져, 그 변경에 대한 통지가 영영 안 나감")
|
||||
print(" (값은 살아있다: g:Get() =", g:Get(), ")")
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
|
||||
-- 가짜 시계/타이머 (luau CLI엔 task가 없다 — 주입 op setTimeout/clearTimeout 자리)
|
||||
local now, timers, seq = 0, {}, 0
|
||||
local function setTimeout(fn, d) seq += 1; timers[seq] = {at = now + d, fn = fn}; return seq end
|
||||
local function clearTimeout(t) if t then timers[t] = nil end end
|
||||
local function advance(to)
|
||||
while true do
|
||||
local best, bestId
|
||||
for id, t in timers do
|
||||
if t.at <= to and (best == nil or t.at < best.at or (t.at == best.at and id < bestId)) then best, bestId = t, id end
|
||||
end
|
||||
if not best then break end
|
||||
timers[bestId] = nil
|
||||
now = best.at
|
||||
best.fn()
|
||||
end
|
||||
now = to
|
||||
end
|
||||
|
||||
-- 확정된 재작성 방향(gate-plan.md 5번) 그대로: 정책은 emit을 안 쥐고 Blocker만 조종한다.
|
||||
-- variant "nopending": 문서가 확정한 대로 pending을 정책에서 없앤 형태
|
||||
-- ("보류된 게 있는가는 Blocker의 HasBlockedEmit이 이미 들고 있다")
|
||||
-- variant "localpending": 정책이 자기 pending 플래그를 따로 드는 형태
|
||||
local function Throttle(state, Time, variant)
|
||||
return q.Gate(state, function(emit)
|
||||
local b = q.Blocker()
|
||||
local pass = b:Policy(emit)
|
||||
local window, pending = nil, false
|
||||
local openWindow, onWindowEnd
|
||||
function onWindowEnd()
|
||||
window = nil
|
||||
if variant == "nopending" then
|
||||
-- HasBlockedEmit을 읽을 수 없으므로 무조건 푼다 → 빈 배치면 no-op(8번)
|
||||
b:Off() -- 보류분 방출(있으면)
|
||||
b:On() -- 다음 창을 위해 다시 막음
|
||||
openWindow() -- "통과했으니 창을 다시 엶" — pending 여부를 알 수 없어 항상
|
||||
else
|
||||
if pending then
|
||||
pending = false
|
||||
b:Off(); b:On()
|
||||
openWindow()
|
||||
end -- pending 없으면 창을 안 열고 idle 복귀
|
||||
end
|
||||
end
|
||||
function openWindow() window = setTimeout(onWindowEnd, Time) end
|
||||
return function() -- 상류 emit 도착
|
||||
if window == nil then
|
||||
-- 창 밖(idle) → leading 즉시 통과
|
||||
b:Off() -- 안 막힌 상태로 만들고
|
||||
pass() -- 통과
|
||||
b:On() -- 창 동안 막음
|
||||
openWindow()
|
||||
else
|
||||
pending = true
|
||||
pass() -- 막혀 있으므로 보류됨
|
||||
end
|
||||
end
|
||||
end, "throttle")
|
||||
end
|
||||
|
||||
local function run(variant)
|
||||
print(("=== variant = %s ==="):format(variant))
|
||||
now = 0; timers = {}
|
||||
local S = q.Source(0)
|
||||
local g = Throttle(S, 1.0, variant)
|
||||
local hits = {}
|
||||
local o = q.Observer(g, function() table.insert(hits, ("t=%.2f v=%d"):format(now, g:Get())) end)
|
||||
S:Set(1) -- t=0.0 leading 통과 기대
|
||||
advance(0.1); S:Set(2)
|
||||
advance(1.0) -- 창 끝 → trailing 통과 기대
|
||||
advance(3.0) -- 조용 → idle 복귀 기대
|
||||
print(" 타이머 살아있는 개수(idle이면 0):", (function() local n=0 for _ in timers do n+=1 end return n end)())
|
||||
S:Set(3) -- t=3.0 다시 leading 즉시 통과 기대
|
||||
print(" 통과:", table.concat(hits, " | "))
|
||||
advance(5.0)
|
||||
print(" 최종 통과:", table.concat(hits, " | "))
|
||||
print(" t=5까지 살아있는 타이머:", (function() local n=0 for _ in timers do n+=1 end return n end)())
|
||||
end
|
||||
|
||||
run("localpending")
|
||||
print()
|
||||
run("nopending")
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
-- D = Compute(A) 인데, fn 안에서 다른 코드가 A를 바꾼다
|
||||
local A = q.Source(1)
|
||||
local log = {}
|
||||
local D = q.State(function(self, prev, a)
|
||||
local v = a:Get()
|
||||
table.insert(log, "fn이 읽은 A = " .. v)
|
||||
if v == 1 then A:Set(99) end -- 재계산 도중 상류가 바뀜(부작용 있는 Compute)
|
||||
return v * 2
|
||||
end, {A}, "D")
|
||||
|
||||
print("D:Get() =", D:Get(), "(A=1로 계산됨)")
|
||||
print("A의 실제 값:", A:Get())
|
||||
print("다시 D:Get() =", D:Get(), " ← 기대값 198")
|
||||
print("D 재계산 횟수:", D.recomputes)
|
||||
print("로그:", table.concat(log, " / "))
|
||||
print()
|
||||
print("-- 이후 A를 또 바꾸면 회복되는가 --")
|
||||
A:Set(5)
|
||||
print("D:Get() =", D:Get(), "(회복됨)")
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
-- 갈래 (b): rawInvalid를 fn 호출 *전에* 내리고, 도중에 다시 세워졌으면 그대로 둔다
|
||||
local State = getmetatable(q.State(function() end, {}))
|
||||
State._recompute = function(self)
|
||||
self.recomputes += 1
|
||||
self.rawInvalid = false -- ← 먼저 내린다
|
||||
self.cache = self.fn(self, self.cache, table.unpack(self.deps))
|
||||
for _, d in self.deps do d:_track(self.valueEpochMap) end
|
||||
-- rawInvalid가 fn 도중 다시 true가 됐으면 그대로 둔다(덮어쓰지 않는다)
|
||||
end
|
||||
local A = q.Source(1)
|
||||
local D = q.State(function(self, prev, a)
|
||||
local v = a:Get()
|
||||
if v == 1 then A:Set(99) end
|
||||
return v * 2
|
||||
end, {A}, "D")
|
||||
print("D:Get() =", D:Get())
|
||||
print("다시 D:Get() =", D:Get(), " ← 198이면 고쳐진 것")
|
||||
print("재계산 횟수:", D.recomputes)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
-- A ──> gated(Block) ──┐
|
||||
-- └──> plain ─────────┴──> Effect(fn, gated, plain)
|
||||
local A = q.Source(1)
|
||||
local plain = q.State(function(self, prev, a) return a:Get() end, {A}, "plain")
|
||||
local b = q.Blocker()
|
||||
local gated = q.Gate(A, function(emit) return b:Policy(emit) end, "gated")
|
||||
|
||||
-- effect-plan.md의 확정 의사코드 그대로: 공용 EpochMap 하나, true일 때만 Rerun
|
||||
local epochs = q.EpochMap()
|
||||
local runs = {}
|
||||
local function onDep(self, from)
|
||||
if epochs:Update(from) then
|
||||
table.insert(runs, ("run(gated=%s, plain=%s)"):format(tostring(gated:Get()), tostring(plain:Get())))
|
||||
end
|
||||
end
|
||||
local o1 = q.Observer(gated, onDep, "dep_gated")
|
||||
local o2 = q.Observer(plain, onDep, "dep_plain")
|
||||
|
||||
b:On()
|
||||
print("blocker ON — 이 구간의 변경은 gated 쪽에서 유보되어야 한다")
|
||||
A:Set(2)
|
||||
print("fn 실행 기록:", table.concat(runs, " | "))
|
||||
print("gated가 아직 붙들고 있나:", next(gated.withheld) ~= nil)
|
||||
b:Off()
|
||||
print("Off() 이후 fn 실행 기록:", table.concat(runs, " | "))
|
||||
print("gated observer 발화 횟수:", o1.fires, "| plain observer:", o2.fires)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
-- dispatch-core-plan.md "배치 등록을 안전하게 만드는 Blocker 게이팅" 그대로
|
||||
local blockers = {} -- Relate(ownerKey) 자리
|
||||
local function getBlocker(owner)
|
||||
if not blockers[owner] then blockers[owner] = q.Blocker() end
|
||||
return blockers[owner]
|
||||
end
|
||||
local recomputes = 0
|
||||
local function gatedRecompute(owner)
|
||||
if getBlocker(owner):IsOn() then return end -- 배치 중이면 스킵
|
||||
recomputes += 1
|
||||
end
|
||||
local function drive(owner, positions)
|
||||
local bl = getBlocker(owner)
|
||||
bl:On()
|
||||
for _, p in positions do
|
||||
p() -- 각 position 처리(사용자 코드가 여기서 돈다)
|
||||
gatedRecompute(owner) -- setLength가 트리거하는 자리
|
||||
end
|
||||
bl:OffWithoutEmit() -- 4번: 배치 끝
|
||||
recomputes += 1 -- "그 직후 딱 한 번" 명시적 recompute
|
||||
end
|
||||
|
||||
local inst = {} -- ownerKey
|
||||
print("-- 정상 배치 --")
|
||||
drive(inst, {function() end, function() end})
|
||||
print("recompute 횟수:", recomputes, "| blocker.IsOn():", getBlocker(inst):IsOn())
|
||||
|
||||
print("-- 두 번째 배치에서 사용자 코드가 error --")
|
||||
local ok = pcall(drive, inst, {function() end, function() error("컴포넌트 실패") end})
|
||||
print("drive가 던졌는가:", not ok, "| blocker.IsOn():", getBlocker(inst):IsOn(), "← 영구 On")
|
||||
|
||||
print("-- 이후 런타임 :Add() 등이 부르는 setLength --")
|
||||
local before = recomputes
|
||||
gatedRecompute(inst); gatedRecompute(inst); gatedRecompute(inst)
|
||||
print("recompute가 몇 번 돌았나:", recomputes - before, "(0이면 그 owner는 영영 재계산 안 됨)")
|
||||
print("-- 새 배치를 열어도 --")
|
||||
pcall(drive, inst, {function() end})
|
||||
print("blocker.IsOn():", getBlocker(inst):IsOn(), "| 총 recompute:", recomputes)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
local A = q.Source(1)
|
||||
local B = q.State(function(self, prev, a) return a:Get() * 10 end, {A}, "B")
|
||||
local C = q.State(function(self, prev, a) return a:Get() * 100 end, {A}, "C")
|
||||
local D = q.State(function(self, prev, b, c) return b:Get() + c:Get() end, {B, C}, "D")
|
||||
local seen, depth = {}, 0
|
||||
local O = q.Observer(D, function()
|
||||
table.insert(seen, D:Get())
|
||||
depth += 1
|
||||
if depth == 1 then A:Set(3) end -- 전파 도중 동기 재진입 Set
|
||||
end, "O")
|
||||
D:Get()
|
||||
A:Set(2)
|
||||
print("관측 순서:", table.concat(seen, ","))
|
||||
print("Observer 발화:", O.fires, "| D 재계산:", D.recomputes)
|
||||
print("최종 D:Get() =", D:Get(), "(A=3이므로 330이어야 함)")
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
--!nocheck
|
||||
local q = require("./core")
|
||||
-- gate-plan.md 4번: "하류가 또 다른 게이트면 받은 집합을 풀어 자기 withheld에 합친다.
|
||||
-- 게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이 먼저 풀리든
|
||||
-- 정보가 안 샌다"
|
||||
local function run(order)
|
||||
local A, B = q.Source(1), q.Source(1)
|
||||
local w = q.State(function(self, prev, a, b) return a:Get() + b:Get() end, {A, B}, "w")
|
||||
local b1, b2 = q.Blocker(), q.Blocker()
|
||||
local g1 = q.Gate(w, function(emit) return b1:Policy(emit) end, "g1")
|
||||
local g2 = q.Gate(g1, function(emit) return b2:Policy(emit) end, "g2")
|
||||
local hits = {}
|
||||
local o = q.Observer(g2, function(self, from)
|
||||
local n = 0; for _ in from do n += 1 end
|
||||
table.insert(hits, ("v=%d, batch=%d"):format(g2:Get(), n))
|
||||
end, "o")
|
||||
b1:On(); b2:On()
|
||||
A:Set(2); B:Set(3)
|
||||
local function w1() return next(g1.withheld) ~= nil end
|
||||
local function w2() return next(g2.withheld) ~= nil end
|
||||
print(("[%s] 유보 중 g1=%s g2=%s | 발화 %d"):format(order, tostring(w1()), tostring(w2()), o.fires))
|
||||
if order == "상류 먼저" then b1:Off(); print(" b1:Off 후 g1=" .. tostring(w1()) .. " g2=" .. tostring(w2()) .. " 발화=" .. o.fires); b2:Off()
|
||||
else b2:Off(); print(" b2:Off 후 g1=" .. tostring(w1()) .. " g2=" .. tostring(w2()) .. " 발화=" .. o.fires); b1:Off() end
|
||||
print((" 최종 발화 %d | %s"):format(o.fires, table.concat(hits, " ; ")))
|
||||
end
|
||||
run("상류 먼저")
|
||||
run("하류 먼저")
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
--!strict
|
||||
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
|
||||
export type State<T> = StateData<T> & {
|
||||
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>, prev: U?) -> U) -> U,
|
||||
ComputeN: <U, D...>(self: StateData<T>, fn: (self: StateData<T>, prev: U?, D...) -> U, D...) -> U,
|
||||
}
|
||||
local s: State<number> = nil :: any
|
||||
local a: SourceData<number> = nil :: any
|
||||
|
||||
-- (A) deps 0개 — typing-limits ②쪼개기가 확인한 "무주석 통과"
|
||||
local r1 = s:Compute(function(self) return self:Get() * 2 end)
|
||||
local n1: number = r1
|
||||
|
||||
-- (B) deps 1개 + 무주석
|
||||
local r2 = s:ComputeN(function(self, prev, d1) return self:Get() + d1:Get() end, a)
|
||||
|
||||
-- (C) deps 1개 + dep만 주석
|
||||
local r3 = s:ComputeN(function(self, prev, d1: SourceData<number>) return self:Get() + d1:Get() end, a)
|
||||
local n3: number = r3
|
||||
|
|
@ -0,0 +1,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
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
--!strict
|
||||
-- base/source-state-plan.md: state:Apply(factory) — `factory: (State<T>) -> U): U`
|
||||
-- base/debounce-throttle-plan.md 5-4: Debounce{...}가 돌려주는 것은 __call 테이블
|
||||
-- (setmetatable({}, {__call = ..., __index = {Flush=..., Cancel=...}}))
|
||||
-- base/gate-plan.md 2번: "__call 테이블이 Luau에서 (State<T>) -> U 함수 타입 자리에
|
||||
-- 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력)" — 확인 안 하기로 접었음
|
||||
|
||||
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||
export type State<T> = StateData<T> & {
|
||||
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
|
||||
Apply: <U>(self: State<T>, factory: (State<T>) -> U) -> U,
|
||||
}
|
||||
|
||||
local s: State<number> = nil :: any
|
||||
|
||||
-- (A) 평범한 함수 팩토리 — 양성 대조군
|
||||
local a = s:Apply(function(self: State<number>): State<number> return self end)
|
||||
|
||||
-- (B) __call 테이블 팩토리 — 5-4절이 확정한 모양
|
||||
type GateFactory = typeof(setmetatable(
|
||||
{} :: { Flush: () -> (), Cancel: () -> () },
|
||||
{} :: { __call: (any, State<number>) -> State<number> }
|
||||
))
|
||||
local Debounced: GateFactory = nil :: any
|
||||
local b = s:Apply(Debounced)
|
||||
|
||||
-- (C) 직접 호출은 되는가
|
||||
local c = Debounced(s)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
--!strict
|
||||
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||
export type State<T> = StateData<T> & {
|
||||
Apply: <U>(self: State<T>, factory: (State<T>) -> U) -> U,
|
||||
}
|
||||
local s: State<number> = nil :: any
|
||||
|
||||
type GateFactory = typeof(setmetatable(
|
||||
{} :: { Flush: () -> (), Cancel: () -> () },
|
||||
{} :: { __call: (any, State<number>) -> State<number> }
|
||||
))
|
||||
local Debounced: GateFactory = nil :: any
|
||||
|
||||
-- (A) 제네릭이 아닌 평범한 함수 파라미터 자리에도 안 들어가는가?
|
||||
local function takesFn(f: (State<number>) -> State<number>) return f(s) end
|
||||
local a = takesFn(Debounced)
|
||||
|
||||
-- (B) 유니온으로 열면 되는가
|
||||
local function takesEither(f: ((State<number>) -> State<number>) | GateFactory)
|
||||
return (f :: any)(s)
|
||||
end
|
||||
local b = takesEither(Debounced)
|
||||
local b2 = takesEither(function(x: State<number>) return x end)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
--!strict
|
||||
-- base/effect-plan.md: Effect(fn, ...deps) — deps는 State/Source(→Observer)와
|
||||
-- Ref(→:Callback)가 섞인다. fn엔 deps가 안 넘어간다(H-14).
|
||||
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||
export type State<T> = StateData<T> & {
|
||||
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
|
||||
}
|
||||
export type Ref<T> = { Value: T?, Set: (self: Ref<T>, v: T) -> (), Callback: (self: Ref<T>, fn: (T) -> ()) -> () }
|
||||
export type EffectHandle = { Rerun: (self: EffectHandle) -> (), Subscribe: (self: EffectHandle) -> () }
|
||||
|
||||
type Dep = State<any> | Ref<any>
|
||||
local function Effect(fn: (self: EffectHandle) -> (() -> ())?, ...: Dep): EffectHandle
|
||||
return nil :: any
|
||||
end
|
||||
|
||||
local n: State<number> = nil :: any
|
||||
local b: State<boolean> = nil :: any
|
||||
local r: Ref<Instance> = nil :: any
|
||||
|
||||
-- (A) State 하나
|
||||
local e1 = Effect(function(self) return nil end, n)
|
||||
-- (B) 이형 State 둘
|
||||
local e2 = Effect(function(self) return nil end, n, b)
|
||||
-- (C) State + Ref 섞기 — effect-plan.md가 확정한 대표 용례
|
||||
local e3 = Effect(function(self) return nil end, n, r)
|
||||
-- (D) 음성 대조군 — 아무 테이블이나 넣으면 걸려야 정상
|
||||
local e4 = Effect(function(self) return nil end, {foo = 1})
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
--!strict
|
||||
-- (A) base/state-epoch-plan.md §2: "Source가 이 인터페이스를 구조적으로 만족한다"
|
||||
-- "Revision은 공개 필드다. 비공개면 구조적 만족이 타입 레벨에서 성립하지 않는다"
|
||||
export type Epoch = { Revision: number }
|
||||
export type EpochSet = { [Epoch]: true }
|
||||
|
||||
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||
export type State<T> = StateData<T> & {
|
||||
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
|
||||
}
|
||||
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
|
||||
export type Source<T> = SourceData<T> & {
|
||||
Set: (self: SourceData<T>, v: T) -> SourceData<T>,
|
||||
Emit: (self: SourceData<T>) -> (),
|
||||
Compute: <U>(self: SourceData<T>, fn: (self: SourceData<T>) -> U) -> State<U>,
|
||||
}
|
||||
|
||||
local src: Source<number> = nil :: any
|
||||
local st: State<number> = nil :: any
|
||||
|
||||
local function takesEpoch(e: Epoch): number return e.Revision end
|
||||
local r1 = takesEpoch(src) -- 성립해야 함
|
||||
-- local r2 = takesEpoch(st) -- 음성 대조군(주석 해제하면 걸려야 정상)
|
||||
|
||||
-- EpochMap 표면 (§3)
|
||||
export type EpochMap = {
|
||||
Update: (self: EpochMap, from: Epoch | EpochSet) -> boolean,
|
||||
Refresh: (self: EpochMap) -> boolean,
|
||||
Sync: (self: EpochMap, from: Epoch | EpochSet) -> (),
|
||||
TrackFrom: (self: EpochMap, other: EpochMap) -> (),
|
||||
}
|
||||
local m: EpochMap = nil :: any
|
||||
local u1 = m:Update(src) -- Epoch 하나
|
||||
local set: EpochSet = { [src :: Epoch] = true }
|
||||
local u2 = m:Update(set) -- EpochSet
|
||||
-- 게이트가 실제로 만드는 모양: {[Source<number>]: true}를 EpochSet 자리에
|
||||
local rawSet: { [Source<number>]: true } = { [src] = true }
|
||||
local u3 = m:Update(rawSet)
|
||||
|
||||
-- (B) base/effect-plan.md: fn 시그니처는 fn(self: EffectHandle) -> (() -> ())?
|
||||
export type EffectHandle = { Rerun: (self: EffectHandle) -> () }
|
||||
local function Effect(fn: (self: EffectHandle) -> (() -> ())?): EffectHandle return nil :: any end
|
||||
|
||||
local e1 = Effect(function(self) return function() end end) -- cleanup 있음
|
||||
local e2 = Effect(function(self) return nil end) -- 명시적 nil
|
||||
local e3 = Effect(function(self) end) -- 아무것도 안 돌려줌 (가장 흔한 모양)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
--!strict
|
||||
export type EffectHandle = { Rerun: (self: EffectHandle) -> () }
|
||||
|
||||
-- (i) 확정된 시그니처
|
||||
local function EffectA(fn: (self: EffectHandle) -> (() -> ())?) end
|
||||
EffectA(function(self) end) -- 흔한 모양
|
||||
EffectA(function(self) return nil end) -- 명시적 nil
|
||||
EffectA(function(self) return function() end end) -- cleanup
|
||||
|
||||
-- (ii) 조건 분기가 있는 경우 — 한쪽만 반환
|
||||
EffectA(function(self)
|
||||
if math.random() > 0.5 then return function() end end
|
||||
end)
|
||||
|
||||
-- (iii) 가변 반환 팩으로 열기
|
||||
local function EffectB(fn: (self: EffectHandle) -> ...(() -> ())) end
|
||||
EffectB(function(self) end)
|
||||
EffectB(function(self) return function() end end)
|
||||
|
||||
-- (iv) 유니온 함수 타입
|
||||
local function EffectC(fn: ((self: EffectHandle) -> ()) | ((self: EffectHandle) -> (() -> ()))) end
|
||||
EffectC(function(self) end)
|
||||
EffectC(function(self) return function() end end)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
--!strict
|
||||
-- base/slot-plan.md 확정: updateFn<UD>(item, index, offset, prev, userdata) -> (T|nil, UD?)
|
||||
type Element = { tag: string }
|
||||
export type SourceN = { Get: (self: SourceN) -> number }
|
||||
|
||||
local function List<T, UD>(
|
||||
updateFn: (item: any, index: number, offset: SourceN, prev: T?, userdata: UD?) -> (T?, UD?)
|
||||
) end
|
||||
|
||||
-- (A) 값 하나만 반환하는 가장 흔한 모양
|
||||
List(function(item, index, offset, prev, ud)
|
||||
return { tag = "x" } :: Element
|
||||
end)
|
||||
|
||||
-- (B) 두 값 반환
|
||||
List(function(item, index, offset, prev, ud)
|
||||
return { tag = "x" } :: Element, 42
|
||||
end)
|
||||
|
||||
-- (C) nil만 반환(요소 버리기 — 확정된 정상 경로)
|
||||
List(function(item, index, offset, prev, ud)
|
||||
return nil
|
||||
end)
|
||||
|
||||
-- (D) 조건 분기 — 한쪽만 반환
|
||||
List(function(item, index, offset, prev, ud)
|
||||
if index > 1 then return { tag = "x" } :: Element end
|
||||
end)
|
||||
|
||||
-- (E) 아무것도 안 반환
|
||||
List(function(item, index, offset, prev, ud) end)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
--!strict
|
||||
type El = { tag: string }
|
||||
-- 비제네릭으로 좁혀서 "선언된 다중 반환보다 적게 반환"만 본다
|
||||
local function take(fn: (n: number) -> (El?, number?)) end
|
||||
|
||||
take(function(n) return { tag = "a" } end) -- 1개만 반환
|
||||
take(function(n) return nil end) -- nil 하나
|
||||
take(function(n) end) -- 아무것도 안 반환
|
||||
take(function(n) return { tag = "a" }, 1 end)-- 정확히 2개
|
||||
take(function(n) return nil, nil end) -- 명시적 2개
|
||||
|
||||
-- 대조: 단일 옵셔널 반환
|
||||
local function take2(fn: (n: number) -> El?) end
|
||||
take2(function(n) return { tag = "a" } end)
|
||||
take2(function(n) return nil end)
|
||||
take2(function(n) end)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
--!strict
|
||||
type El = { tag: string }
|
||||
type Fn2 = (n: number) -> (El?, number?)
|
||||
type Fn1 = (n: number) -> El?
|
||||
type Fn0 = (n: number) -> ()
|
||||
local function takeU(fn: Fn2 | Fn1 | Fn0) end
|
||||
takeU(function(n) return { tag = "a" } end)
|
||||
takeU(function(n) return nil end)
|
||||
takeU(function(n) end)
|
||||
takeU(function(n) return { tag = "a" }, 1 end)
|
||||
-- 음성 대조군: 엉뚱한 타입을 돌려주면 걸려야 정상
|
||||
takeU(function(n) return 42 end)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
--!strict
|
||||
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||
export type State<T> = StateData<T> & {
|
||||
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
|
||||
Apply: <U>(self: State<T>, factory: (State<T>) -> U) -> U,
|
||||
Gate: (self: State<T>, setup: (emit: () -> ()) -> (() -> ())) -> State<T>,
|
||||
Block: (self: State<T>, blocker: Blocker) -> State<T>,
|
||||
}
|
||||
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
|
||||
export type Source<T> = SourceData<T> & {
|
||||
Set: (self: SourceData<T>, v: T) -> SourceData<T>,
|
||||
Apply: <U>(self: Source<T>, factory: (State<T>) -> U) -> U,
|
||||
Gate: (self: Source<T>, setup: (emit: () -> ()) -> (() -> ())) -> State<T>,
|
||||
}
|
||||
export type Blocker = {
|
||||
On: (self: Blocker) -> Blocker,
|
||||
Off: (self: Blocker) -> Blocker,
|
||||
OffWithoutEmit: (self: Blocker) -> Blocker,
|
||||
IsOn: (self: Blocker) -> boolean,
|
||||
IsBlocked: boolean,
|
||||
Policy: (self: Blocker, emit: () -> ()) -> (() -> ()),
|
||||
}
|
||||
|
||||
local st: State<number> = nil :: any
|
||||
local src: Source<number> = nil :: any
|
||||
local b: Blocker = nil :: any
|
||||
|
||||
-- (A) gate-plan.md 5번의 확정 형태
|
||||
local g1 = st:Gate(function(emit) return b:Policy(emit) end)
|
||||
-- (B) tween-plan.md의 관용구 — Source에 State용 팩토리를 붙인다
|
||||
local Animate: (self: State<number>) -> State<number> = nil :: any
|
||||
local a1 = src:Apply(Animate)
|
||||
local a2 = st:Apply(Animate)
|
||||
-- (C) Debounce 정책 모양 — 자기 Blocker를 사적으로 하나 갖는 setup
|
||||
local d = st:Gate(function(emit)
|
||||
local mine: Blocker = nil :: any
|
||||
local pass = mine:Policy(emit)
|
||||
return function() pass() end
|
||||
end)
|
||||
-- (D) 음성 대조군 — gate-plan.md가 "문법 오류"라고 정정한 형태
|
||||
local bad = st:Gate(b.Policy)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
--!strict
|
||||
-- 7라운드 부록이 확인한 이형 팩은 StateData<number>/StateData<boolean>처럼
|
||||
-- "같은 종류, 다른 타입 인자"였다. 실제 코드는 Source와 State가 섞인다.
|
||||
export type StateData<T> = { Get: (self: StateData<T>) -> T }
|
||||
export type SourceData<T> = { Get: (self: SourceData<T>) -> T, Revision: number }
|
||||
export type State<T> = StateData<T> & {
|
||||
ComputeN: <U, D...>(self: StateData<T>, fn: (self: StateData<T>, prev: U?, D...) -> U, D...) -> U,
|
||||
}
|
||||
|
||||
local s: State<string> = nil :: any
|
||||
local a: SourceData<number> = nil :: any -- store.a (Source)
|
||||
local b: StateData<boolean> = nil :: any -- 파생 State
|
||||
|
||||
local r = s:ComputeN(function(self, prev, d1: SourceData<number>, d2: StateData<boolean>)
|
||||
return if d2:Get() then d1:Get() else 0
|
||||
end, a, b)
|
||||
local rn: number = r
|
||||
|
||||
-- 무주석 콜백에서도 되는가(부록이 확인한 것과 같은 조건)
|
||||
local r2 = s:ComputeN(function(self, prev, d1, d2)
|
||||
return d1:Get() + (if d2:Get() then 1 else 0)
|
||||
end, a, b)
|
||||
local rn2: number = r2
|
||||
|
||||
-- 음성 대조군: 순서를 바꿔 넘기면 걸려야 정상
|
||||
local r3 = s:ComputeN(function(self, prev, d1: SourceData<number>, d2: StateData<boolean>)
|
||||
return 0
|
||||
end, b, a)
|
||||
|
|
@ -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`다.
|
||||
|
|
|
|||
Loading…
Reference in a new issue