- H-198(사용자 안): 상류 스탬프(dep:_track)를 fn 직전으로 + Get을 수렴까지의 재시작 루프로 — 계약 강화: 모든 Get이 fn 도중 변경(재진입·게이트 유보)을 같은 호출 안에서 수렴시켜 항상 최신 반환. 매 패스 자기 dep Set은 UB. state-epoch-plan §4 세 절 + H-85 bullet 정정, spec.state 6 새 계약, spec.gate 10(리뷰의 영구 stale 재현 → 같은 Get에서 99 + flush 통지만) - H-186(b): 교차 인스턴스 값 혼용 UB 문서화 — architecture 13번 + content-map §4 22번(H-116 이웃), 코드 주석. M5 재검토 - H-205(a): Modifier 가드 level 2→3(직접 Get이면 유저 호출부), spec.state 12 단언 - H-208: Ref:Set 스냅샷을 table.clone + 집합 병합으로(사용자 — 더 싸고 dedup 공짜) - H-209: src 전 파일 pairs/ipairs → generalized iteration(사용자 — 최적화로 더 빠름; 메타테이블 있는 테이블의 raw 순회 실측 확인). 문서 의사코드 표기는 H-178과 같은 급으로 무변경 - H-211: Relate:SetWeak의 캐스트 없는 setmetatable 대입이 IDE(strict)에서 TypeError(플레인 luau-analyze는 솔버 차로 조용) → 로컬 주석 + :: any 경유 - §4 열린 문항 0, 코드 마커 0. 감사 2라운드 수렴(확실 1·의심 1 → 0) Co-authored-by: qwreey <me@qwreey.moe> Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
325 lines
14 KiB
Text
325 lines
14 KiB
Text
--[[
|
|
State — derived reactive node (`:Compute` / `:With` / `:Apply`), the
|
|
propagation loop, and the epoch-based recompute judgement.
|
|
|
|
Assembly (`H-174`, user-confirmed 2026-08-28): `State.Init(module)` builds
|
|
one implementation table per `quad` instance and keeps `module` in its
|
|
closure, so later units (`Observer`, `Gate`) can read lifetime gates as
|
|
`module.canExecute(...)` AT FIRE TIME (never captured at init — the
|
|
backend overwrites those fields after `New()`). `implFor(module)` hands
|
|
the same table to `Source.luau`'s init (its only caller — `Store.luau`
|
|
never touches it; it reaches State only through the `Source` values it
|
|
creates).
|
|
|
|
There is no runtime `State` constructor — nodes exist only as results of
|
|
`:Compute`/`:With` (and `:Gate`, unit 4). `State` is NOT an `Epoch`
|
|
(`state-epoch-plan.md` §4): its counters are private recompute
|
|
bookkeeping, and upstream tracking goes through `valueEpochMap`.
|
|
|
|
Sources, transcribed as-is:
|
|
- `.claude/base/source-state-plan.md` "전파 루프 — 확정 의사코드"
|
|
(`_emitDown`: snapshot, then `sub:_receive(from)` — every subscriber
|
|
is an `EmitReceive`, `H-163`), "`:With`도 새 State 노드로 확정"
|
|
(pass-through node, wider subscription list), "self 인자도 lazy
|
|
핸들로 통일", "`:Compute(fn, ...)`" (trailing deps: one node),
|
|
"trailing deps를 `fn`에 lazy positional 인자로도 노출"
|
|
(`fn(self, previous?, ...deps)`), "`state:Apply(factory)`"
|
|
(`__apply` method form, `H-158`), "`_hold`로 살아남는다"
|
|
(downstream → upstream strong, upstream → downstream weak).
|
|
- `.claude/base/state-epoch-plan.md` §4: seeding (`emitEpochMap`
|
|
empty, `valueEpochMap` filled from every dep — `:Sync(dep)` if the
|
|
dep is an `Epoch`, `:TrackFrom(dep.valueEpochMap)` otherwise),
|
|
receive rules 1~3, the `cacheTargetCount`/`cacheCurrCount` pair
|
|
(`H-85`, `curr = nil` initially), recompute (`d:_track`, then
|
|
`cacheCurrCount = gen` only on success), `Get` (`Refresh` walk
|
|
advances the value only — never notifies).
|
|
- `.claude/base/modifier-plan.md` 7번: a `:Compute` result that is a
|
|
Modifier errors before it is cached.
|
|
- `.claude/base/gate-plan.md` "`GateNode` 조립" (unit 4): `state:Gate(setup)`
|
|
makes a `GateNode` — a pass-through State node whose `_receive` judges
|
|
with `Peek` on the emit map, merges the source into a weak-key
|
|
`_withheld` set, and hands control to the policy; `_flush(commit)` is
|
|
the `emit` the policy holds (empty → false; swap → `Sync(batch)` →
|
|
`_emitDown(batch)`; `emit(false)` discards). Value is never gated (3번).
|
|
]]
|
|
|
|
local Brand = require("./Brand")
|
|
local EpochMap = require("./EpochMap")
|
|
local ImplRegistry = require("./ImplRegistry")
|
|
local Observer = require("./Observer")
|
|
local Void = require("./Void")
|
|
local QuadTypes = require("../roblox_packages/quad_types")
|
|
|
|
export type StateData<T> = QuadTypes.StateData<T>
|
|
export type State<T> = QuadTypes.State<T>
|
|
type EpochMap = EpochMap.EpochMap
|
|
|
|
local StateBrand = Brand.StateBrand
|
|
local isEpoch = Brand.isEpoch
|
|
local isState = Brand.isState
|
|
local isModifier = Brand.isModifier
|
|
|
|
local WEAK_KEY_MT = { __mode = "k" }
|
|
|
|
-- One implementation per quad instance (`H-174`); storage scheme in `ImplRegistry.luau`.
|
|
local implsOf = ImplRegistry.implsOf
|
|
|
|
local function createImpl(module: any)
|
|
module:RunInit(Observer.Init) -- `:Observer` needs the per-instance Observer impl (idempotent pull, `H-177`)
|
|
local ObserverImpl = Observer.implFor(module)
|
|
|
|
local Impl = {}
|
|
Impl.__index = Impl
|
|
|
|
-- ── propagation loop ──────────────────────────────────────────────
|
|
-- Shared by every node type that has `_subs` (Source nodes too).
|
|
local function emitDown(self: any, from: any)
|
|
-- H-23: adding a key during iteration is undefined, so snapshot first.
|
|
-- "이번 파동 중에 붙은 구독자는 다음 파동부터"가 계약.
|
|
local snap = {}
|
|
for sub in self._subs do
|
|
snap[#snap + 1] = sub
|
|
end
|
|
for _, sub in snap do
|
|
(sub :: any):_receive(from) -- every subscriber is an `EmitReceive` (`H-163`)
|
|
end
|
|
end
|
|
Impl._emitDown = emitDown
|
|
|
|
-- ── node creation ─────────────────────────────────────────────────
|
|
local function newNode(deps: { any }, fn: any, mt: any?): any
|
|
for i, dep in deps do
|
|
if not isState(dep) then
|
|
error(`State: dep #{i} is not a State/Source`, 3) -- 3: past Compute/With/Gate to the user's call
|
|
end
|
|
end
|
|
local self = setmetatable({
|
|
_fn = fn,
|
|
_hold = deps, -- strong: downstream → upstream (`_hold` invariant)
|
|
_subs = setmetatable({}, WEAK_KEY_MT), -- weak-key: upstream → downstream
|
|
_valueEpochMap = EpochMap(),
|
|
_emitEpochMap = EpochMap(), -- starts EMPTY on purpose (§4 seeding)
|
|
_cacheTargetCount = 0,
|
|
_cacheCurrCount = nil, -- never computed yet → always differs
|
|
_cache = nil,
|
|
}, mt or Impl)
|
|
StateBrand:register(self) -- `H-152`: a GateNode is a State too — first line of its assembly
|
|
-- §4 seeding: valueEpochMap takes every upstream epoch, live.
|
|
for _, dep in deps do
|
|
dep._subs[self] = true
|
|
if isEpoch(dep) then
|
|
self._valueEpochMap:Sync(dep)
|
|
else
|
|
self._valueEpochMap:TrackFrom(dep._valueEpochMap)
|
|
end
|
|
end
|
|
return self
|
|
end
|
|
Impl._newNode = newNode
|
|
|
|
-- ── EmitReceive + epoch bookkeeping ──────────────────────────────
|
|
function Impl._track(self: any, map: EpochMap)
|
|
map:TrackFrom(self._valueEpochMap) -- a State is not an Epoch: hand over what it tracks
|
|
end
|
|
|
|
function Impl._invalidate(self: any)
|
|
self._cacheTargetCount = bit32.bnot(-self._cacheTargetCount)
|
|
end
|
|
|
|
-- §4 "emit을 받았을 때": two maps, two booleans, three cases.
|
|
function Impl._receive(self: any, from: any)
|
|
local valueChanged = self._valueEpochMap:Update(from)
|
|
local emitChanged = self._emitEpochMap:Update(from)
|
|
if valueChanged then
|
|
self:_invalidate() -- rule 1: value is stale
|
|
end
|
|
if valueChanged or emitChanged then
|
|
emitDown(self, from) -- rules 1/2: forward the SAME source; rule 3: swallow
|
|
end
|
|
end
|
|
|
|
function Impl._recompute(self: any)
|
|
local gen = self._cacheTargetCount -- snapshot right before fn (`H-85`)
|
|
local hold = self._hold
|
|
-- `H-198` (user-confirmed 2026-08-31): stamp upstream revisions BEFORE fn —
|
|
-- anything that moves DURING fn (a reentrant Set, or one withheld by a
|
|
-- closed gate, which never reaches our `_receive`) then shows up afterwards
|
|
-- as `Refresh` drift and `Get`'s loop recomputes. Stamping after fn marked
|
|
-- those moves as already seen (permanent stale cache).
|
|
for _, dep in hold do
|
|
dep:_track(self._valueEpochMap)
|
|
end
|
|
-- `fn(self, previous?, ...deps)` — `self` is the RECEIVER's lazy handle
|
|
-- (`hold[1]`), never this result node.
|
|
local result = self._fn(hold[1], self._cache, table.unpack(hold, 2))
|
|
if isModifier(result) then
|
|
-- `H-205`: level 3 — `_recompute`'s only callers are Get's own lines, so 2
|
|
-- always blamed quad internals; 3 reaches the user on a direct `Get` and is
|
|
-- never worse through a pass-through chain (no fixed level reaches user code there).
|
|
error("State: a Compute function returned a Modifier — State/Source cannot hold Modifiers", 3)
|
|
end
|
|
self._cache = result
|
|
self._cacheCurrCount = gen -- only on success: a thrown fn never marks the cache valid
|
|
end
|
|
|
|
function Impl.Get(self: any): any
|
|
-- `H-198` (user-confirmed): a LOOP, not a single pass — restart until one
|
|
-- recompute completes with nothing moving under it, so every Get returns a
|
|
-- value computed from the latest reads ("항상 최신 값만 읽게되어"). Counter
|
|
-- mismatch catches Sets our `_receive` saw (`H-85`); `Refresh` drift catches
|
|
-- the ones it never did (behind a closed gate). Values only — notification
|
|
-- still waits for the real emit. An fn that re-Sets its own (transitive)
|
|
-- dep on every pass never converges: UB (user-confirmed).
|
|
while true do
|
|
if self._cacheCurrCount ~= self._cacheTargetCount then
|
|
self:_recompute()
|
|
elseif self._valueEpochMap:Refresh() then
|
|
self:_invalidate()
|
|
self:_recompute()
|
|
else
|
|
return self._cache
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ── derived-node methods (shared by Source through `__index`) ─────
|
|
local function passThrough(s: any)
|
|
return s:Get()
|
|
end
|
|
|
|
-- `H-199`: a nil dep must error, not vanish — `deps[#deps + 1]` would drop it
|
|
-- silently AND shift later deps left, so `fn`'s positional lazy-dep args land
|
|
-- in the wrong slots. Index numbering matches `newNode`'s (self is dep #1).
|
|
local function collectDeps(self: any, ...: any): { any }
|
|
local deps = { self }
|
|
for i = 1, select("#", ...) do
|
|
local dep = (select(i, ...))
|
|
if dep == nil then
|
|
error(`State: dep #{i + 1} is nil`, 3) -- 3: past With/Compute to the user's call
|
|
end
|
|
deps[i + 1] = dep
|
|
end
|
|
return deps
|
|
end
|
|
|
|
function Impl.With(self: any, ...: any): any
|
|
return newNode(collectDeps(self, ...), passThrough) -- value is `self`'s, subscriptions are wider
|
|
end
|
|
|
|
function Impl.Compute(self: any, fn: any, ...: any): any
|
|
if type(fn) ~= "function" then
|
|
-- `H-202`: without this the crash lands at the first `Get` deep inside
|
|
-- `_recompute` — sibling surfaces (Gate/Observer/Apply) all validate here.
|
|
error("State: Compute fn must be a function", 2)
|
|
end
|
|
return newNode(collectDeps(self, ...), fn) -- one node: edges only, no combining node
|
|
end
|
|
|
|
-- ── GateNode (`gate-plan.md` "`GateNode` 조립") — same layer as a ComputeNode ──
|
|
local GateImpl = setmetatable({}, { __index = Impl })
|
|
GateImpl.__index = GateImpl
|
|
|
|
local function newWithheld()
|
|
return setmetatable({}, WEAK_KEY_MT) -- `H-9`: every withheld table is weak-keyed, the swapped-in one too
|
|
end
|
|
|
|
-- The ONLY exception to §4: the emit map is Peeked at receive time and
|
|
-- written at flush (`Sync(batch)`), so "what I have sent downstream" stays true.
|
|
function GateImpl._receive(self: any, from: any)
|
|
local valueChanged = self._valueEpochMap:Update(from)
|
|
local emitChanged = self._emitEpochMap:Peek(from) -- Peek, not Update (`H-72`)
|
|
if valueChanged then
|
|
self:_invalidate()
|
|
end
|
|
if not (valueChanged or emitChanged) then
|
|
return -- rule 3: swallowed — no policy, not added to the set
|
|
end
|
|
-- Merge the source(s) into the withheld set. A batch is UNFOLDED (never held
|
|
-- by reference — an upstream gate's batch is a one-shot snapshot).
|
|
local withheld = self._withheld
|
|
if isEpoch(from) then
|
|
withheld[from] = true
|
|
else
|
|
for epoch in from do
|
|
withheld[epoch] = true
|
|
end
|
|
end
|
|
self._onUpstreamEmit() -- the policy decides: emit() / emit(false) / nothing
|
|
end
|
|
|
|
-- This IS the `emit(commit) -> boolean` the policy holds.
|
|
function GateImpl._flush(self: any, commit: boolean?): boolean
|
|
local batch = self._withheld
|
|
if next(batch) == nil then
|
|
return false -- (1) empty batch: nothing at all (8번)
|
|
end
|
|
self._withheld = newWithheld() -- (2) swap, never clear — nested waves get a fresh table
|
|
if commit == false then
|
|
return true -- discard (`H-55`): no Sync, no propagation
|
|
end
|
|
self._emitEpochMap:Sync(batch) -- (3) BEFORE propagating, all at once
|
|
emitDown(self, batch) -- (4) the detached batch is the payload — the gate itself never is
|
|
return true
|
|
end
|
|
|
|
-- `state:Gate(setup)`; `setup(emit) -> onUpstreamEmit`. Returns a State<T> (same T).
|
|
function Impl.Gate(self: any, setup: any): any
|
|
if type(setup) ~= "function" then
|
|
error("State: Gate setup must be a function (emit) -> onUpstreamEmit", 2)
|
|
end
|
|
local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node
|
|
node._withheld = newWithheld()
|
|
node._onUpstreamEmit = Void -- until setup returns: a throwing setup must not leave a nil callee
|
|
-- `H-200` (b): detach while user code (`setup`) runs — whether it THROWS or
|
|
-- returns garbage, no zombie subscriber is left behind (no pcall needed). The
|
|
-- window is unobservable: the withheld set stays empty while detached.
|
|
self._subs[node] = nil
|
|
local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean
|
|
return node:_flush(commit)
|
|
end)
|
|
if type(onUpstreamEmit) ~= "function" then
|
|
error("State: Gate setup must return the onUpstreamEmit function", 2)
|
|
end
|
|
node._onUpstreamEmit = onUpstreamEmit -- strong: the policy closure (and, through it, the Blocker handle)
|
|
self._subs[node] = true -- re-attach: the gate starts hearing emits only now
|
|
return node
|
|
end
|
|
|
|
-- `state:Observer(fn?)` — leaf subscriber, fires once at registration
|
|
-- (`source-state-plan.md` "`state:Observer(fn)`"). Body lives in `Observer.luau`.
|
|
function Impl.Observer(self: any, fn: any?): any
|
|
if fn ~= nil and type(fn) ~= "function" then
|
|
error("State: Observer fn must be a function (or nil for the always-observe utility)", 2)
|
|
end
|
|
return ObserverImpl.new(self, fn)
|
|
end
|
|
|
|
function Impl.Apply(self: any, factory: any): any
|
|
if type(factory) == "function" then
|
|
return (factory :: any)(self)
|
|
end
|
|
if type(factory) ~= "table" or type(factory.__apply) ~= "function" then
|
|
error("State: Apply factory must be a function or an object with an __apply method", 2)
|
|
end
|
|
return (factory :: any):__apply(self) -- method form: `self` there is the factory object (`H-158`)
|
|
end
|
|
|
|
return Impl
|
|
end
|
|
|
|
local function Init(module: any)
|
|
implsOf(module).State = createImpl(module)
|
|
end
|
|
|
|
local function implFor(module: any)
|
|
local impl = implsOf(module).State
|
|
if impl == nil then
|
|
error("State: State.Init(module) has not run for this quad instance", 1) -- invariant check; every caller pulls it in first (`H-177`)
|
|
end
|
|
return impl
|
|
end
|
|
|
|
return {
|
|
Init = Init,
|
|
implFor = implFor,
|
|
}
|