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

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01546hjsYNLSMZdHdPyTZaGb
2026-08-28 01:03:05 +09:00

289 lines
12 KiB
Text

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