quad/.claude/luau-test/rewrite-required/05-store-state-diamond-propagation.luau
qwreey c58c97a877
design: Gate 표면 확정(state:Gate + GateNode) + State 에포크 채택, 두 문서 base/ 승격
사용자 확정 둘로 M2 착수를 막던 설계 항목이 전부 사라졌다.

1) Gate — 탑레벨 프리미티브를 만들지 않고 state:Gate(setup) 메소드로 확정.
   ComputeNode와 같은 층위의 GateNode를 만든다. Blocker는 그 위의 별개
   프리미티브로, 이미 확정돼 있던 state:Block(blocker)가 내부에서
   self:Gate(policy)를 부른다. Debounce/Throttle의 state:Apply(...) 관용구는
   그대로 — 팩토리가 내부에서 :Gate를 부르면 되기 때문. 이름 문제(Gater?)도
   메소드 자리로 가면서 소멸. Get()엔 영향 없음(통지만 막음)까지 확정.
   research/gate-primitive.md -> base/gate-plan.md.

2) State 에포크 — 채택 확정. 구현은 M3.
   research/state-epoch-validation.md -> base/state-epoch-plan.md.

에포크 채택으로 source-state-plan.md의 두 확정 서술("emit은 항상 전파" /
"quad가 접지 않는 것은 중복 통지뿐")이 역전됐다. 원문은
archive/always-propagate-no-dedup-superseded.md. 지금 계약은 "invalid로는
절대 안 접고, 같은 소스의 같은 에포크가 두 번째로 도착했을 때만 접는다" —
2026-08-14의 invalid 기반 dedup 금지를 되돌린 게 아니라는 점을 역전 문서와
source-state-plan.md, README 세 곳에 못박음(흐려지면 "영구 침묵" 버그로
되돌아감).

같이 갱신: architecture.md 전파 모델 요약, blocker-plan.md(:Gate 배선 +
Get 계약이 에포크 안의 전제라는 것), debounce-throttle-plan.md(공용 게이트
권고가 실현됨 / 파동 단위 최적화 서술 정정), reference/comparison-fusion-vide.md,
source-state-plan.md의 Observer 계약 각주(이제 "새 에포크는 항상 통과"에
의존), ROADMAP M0 각주·M2 각주·M3 체크박스, README/question/todos 인덱스.

스파이크 05-store-state-diamond-propagation은 done/ -> rewrite-required/ 로
되돌렸다 — 다이아몬드 Observer가 이제 변경당 1회만 울어야 해서 핵심 assert가
정반대가 됐다(살릴 것/새로 넣을 것은 STATUS.md에 기재).

처리 전량의 소스는 qa-request/pre-implementation-qa-round5-followup.md의 O절.
doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 21:04:27 +09:00

175 lines
6.4 KiB
Text

--[[
검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get()
시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지.
배경: ROADMAP.md M0 1번째 항목. **[재작성, 2026-08-19]** 옛 버전은
"이미 dirty면 더 아래로 전파하지 않는다"를 검증했는데, 그 규칙은
확정된 Observer 계약(fn이 :Get()을 안 불러도 됨)과 모순돼 역전됨
(`archive/invalidate-dedup-propagation-reversed.md`). 이 버전은
현행 모델을 검증:
1. emit(invalidate 신호)은 자기 invalid 상태와 무관하게 *항상*
전파된다 — 이미 dirty인 노드도 다시 전파를 계속함(early-stop 없음)
2. 중복 **재계산**은 오직 :Get() 시점 캐시로만 막힌다(dirty 플래그가
"내 캐시가 낡았다" 표시일 뿐, 전파 게이트가 아님)
3. :Get()을 안 부르는 Observer는 다이아몬드에서 한 번의 source 변경마다
경로 수만큼(여기선 2번) 계속 운다 — 옛 모델처럼 두 번째부터
침묵하면 안 됨(핵심 음성 대조군)
다이아몬드 구조:
source
/ \
stateA stateB
\ /
stateC (:With(stateA, stateB):Compute(...))
실행: `luau 05-store-state-diamond-propagation.luau`
]]
local function makeSource(initial)
local self = { value = initial, listeners = {} }
function self:Get()
return self.value
end
function self:Set(v)
self.value = v
self:Invalidate()
end
function self:Invalidate()
-- source 자신은 dirty 개념이 없음(항상 최신) — 리스너에게 신호만 쏨
for _, fn in self.listeners do
fn()
end
end
function self:OnInvalidate(fn)
table.insert(self.listeners, fn)
end
return self
end
local invalidateCallCount = { stateA = 0, stateB = 0, stateC = 0 }
local computeCallCount = { stateA = 0, stateB = 0, stateC = 0 }
local function makeState(name, deps, computeFn)
local self = {
name = name,
dirty = true, -- 처음엔 아직 계산 안 됐으니 dirty
cached = nil,
listeners = {},
}
function self:Invalidate()
invalidateCallCount[name] += 1
-- 핵심: invalid는 "내 캐시가 낡았다"는 표시일 뿐 전파 게이트가 아님
-- — 이미 dirty였어도 무조건 다시 세팅하고 무조건 아래로 전파한다.
self.dirty = true
print(string.format(" [%s] invalidate #%d — 항상 전파", name, invalidateCallCount[name]))
for _, fn in self.listeners do
fn()
end
end
function self:OnInvalidate(fn)
table.insert(self.listeners, fn)
end
function self:Get()
if self.dirty then
computeCallCount[name] += 1
print(string.format(" [%s] pull-recompute 실행 (총 %d번째)", name, computeCallCount[name]))
local args = {}
for i, d in deps do
args[i] = d:Get()
end
self.cached = computeFn(table.unpack(args))
self.dirty = false
else
print(string.format(" [%s] 캐시된 값 그대로 반환(재계산 없음)", name))
end
return self.cached
end
for _, d in deps do
d:OnInvalidate(function()
self:Invalidate()
end)
end
return self
end
local source = makeSource(1)
local stateA = makeState("stateA", { source }, function(v)
return v + 10
end)
local stateB = makeState("stateB", { source }, function(v)
return v + 100
end)
local stateC = makeState("stateC", { stateA, stateB }, function(a, b)
return a + b
end)
print("=== 1. 최초 Get() — 전부 계산돼야 함 ===")
print("stateC:Get() =", stateC:Get())
print("compute 호출 횟수:", computeCallCount.stateA, computeCallCount.stateB, computeCallCount.stateC)
assert(
computeCallCount.stateA == 1 and computeCallCount.stateB == 1 and computeCallCount.stateC == 1,
"최초 계산 횟수가 예상과 다름"
)
print()
print("=== 2. 재차 Get() — 캐시만 반환, 재계산 없어야 함 ===")
print("stateC:Get() =", stateC:Get())
assert(computeCallCount.stateC == 1, "invalidate 안 했는데 재계산이 일어남 (버그)")
print()
print("=== 3. source:Set() -> 다이아몬드 invalidate 전파(항상 전파, early-stop 없음) ===")
source:Set(2)
print(
"invalidate 호출 횟수(stateC):",
invalidateCallCount.stateC,
"(stateA 경로 1번 + stateB 경로 1번 = 2번이어야 함 — 두 번 다 끝까지 실행돼야 함, 조기 종료 없음)"
)
assert(invalidateCallCount.stateC == 2, "다이아몬드 두 경로 모두 stateC까지 끝까지 전파돼야 함(early-stop 없음)")
print()
print("=== 4. invalidate 이후 Get() — 재계산은 딱 1번(중복 재계산 방지는 캐시 몫) ===")
print("stateC:Get() =", stateC:Get())
print(
"compute 호출 횟수(stateC):",
computeCallCount.stateC,
"(2여야 함 — 1차 계산 + 이번 재계산 1번. invalidate가 2번 왔다고 재계산도 2번이면 버그)"
)
assert(computeCallCount.stateC == 2, "invalidate가 2번 와도 재계산은 캐시로 1번만 막혀야 함")
print()
print("=== 5. :Get()을 안 부르는 Observer — 매 source 변경마다 계속 울어야 함(옛 모델 음성 대조군) ===")
do
local observerFireCount = 0
-- state:Observer(fn)을 흉내: :Get()을 절대 호출하지 않는 순수 리스너
stateC:OnInvalidate(function()
observerFireCount += 1
end)
for i = 1, 3 do
source:Set(2 + i)
end
print("3번의 source:Set() 이후 Observer 발화 횟수:", observerFireCount, "(다이아몬드라 회당 2번씩, 6이어야 함)")
assert(
observerFireCount == 6,
"Get()을 안 부르는 Observer가 계속 안 울면(예: 2 근처에서 멈추면) 옛 '이미 dirty면 전파 중단' 모델로 회귀한 것"
)
end
print()
print("모든 assert 통과 — '항상 전파 + Get() 시점 캐시 dedup' 모델이 예상대로 동작함")
--[[
확인 포인트:
1. 위 assert들이 전부 통과하는가.
2. 3번 절에서 stateC의 invalidate 로그가 "이미 dirty" 같은 조기 종료
문구 없이 매번 "항상 전파"로 끝까지 도는지 눈으로 확인.
3. 5번 절이 이 파일에서 가장 중요한 회귀 방지 장치 — 옛(역전된) 모델을
그대로 되돌려 넣으면(Invalidate 안에 `if self.dirty then return end`를
부활시키면) observerFireCount가 6이 아니라 2에서 멈추고 이 assert가
실패해야 정상.
4. 이 스파이크는 실제 :With/:Compute API 모양이 아니라 최소 골격만
흉내낸 것 — 실제 구현 시 self/deps를 State 핸들로 lazy하게 넘기는
부분은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
정확성"만 검증 대상.
]]