decide(slot): track slot->inst ownership directly instead of position diffing

Position-keyed comparison only catches "did this exact slot spot change,"
not "is this same Slot object already mounted somewhere else" -- which is
exactly the double-mount invariant Slot already promises for its elements.
A Relate<slot, inst> enforces it directly: same owner -> spurious re-emit,
ignore; different owner -> error; no owner -> bind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
qwreey 2026-08-12 16:20:29 +09:00
parent f20922ce39
commit 2b7e90cd92
Signed by: qwreey
GPG key ID: D28DB79297A214BD
3 changed files with 89 additions and 8 deletions

View file

@ -208,28 +208,46 @@ retract 없이 process가 diff 담당"이라는 전제 자체가 틀렸음** —
단계로 자연히 갈림 — `retract`가 "이전 것 정리", `process`가 "새 것
마운트" 전담:
**[정정, 2026-08-12 열두 번째 세션] "같은 값인가"를 위치별 relate로
간접 비교하는 대신, Slot 자신이 지금 어느 `inst`에 바인딩됐는지를 직접
추적** — 이게 이미 확정된 "한 element가 어디에도 중복 마운트 안 됨"
전역 불변식(위 "요소 타입 제약" 절)을 Slot 컨테이너 자신에도 그대로
적용하는 것이라 더 정확함(위치 비교로는 "이 Slot이 동시에 다른 위치에도
마운트돼 있는가"를 못 잡음):
```lua
local relate = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot 기억
local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot(retract가 뭘 지울지 알아야 함)
local slotOwner = Relate() -- Slot 자신이 weak 키 — {[slot] = 지금 바인딩된 inst}
function SlotHandler.process(inst, k, slotValue)
local old = relate:GetStrong(inst, k)
if old == slotValue then
return -- 이미 같은 바인딩(retract가 방금 손 안 댄 경우) — no-op
local owner = slotOwner:GetStrong(slotValue)
if owner == inst then
return -- 이미 이 inst에 바인딩된 채 — 단순 emit 전파, no-op
end
if owner ~= nil then
error("이 Slot은 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
end
attachSlot(slotValue, inst, inst, k)
relate:SetStrong(inst, k, slotValue)
slotOwner:SetStrong(slotValue, inst)
kSlotMap:SetStrong(inst, k, slotValue)
end
function SlotHandler.retract(inst, k, v)
local old = relate:GetStrong(inst, k)
local old = kSlotMap:GetStrong(inst, k)
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음
destroySlotTree(old) -- 폐기, 옮기지 않음 — 아래 "확정" 절 그대로
relate:SetStrong(inst, k, nil)
slotOwner:SetStrong(old, nil) -- 관계 해제 — old를 나중에 다른 곳에 다시 마운트해도 됨
kSlotMap:SetStrong(inst, k, nil)
end
-- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 no-op으로 스킵
-- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 owner==inst로 no-op
end
```
`attachSlot` 자체가 quad-roblox 소속이라 `inst`를 아는 건 자연스러움 —
`slotOwner`가 굳이 `inst`의 정체를 몰라도(예: 다른 백엔드에서 중간
표현 테이블이어도) 무관하게 동작함, 그냥 "지금 이 자리를 차지한 값이
누구냐"만 구분하면 됨.
- **같은 바인딩이면 완전히 무시하는 게 이 자리에선 효율 문제가 아니라
정합성 문제** — Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal
없음)이 이미 정책으로 확정돼 있어서, 이 no-op 가드가 없으면 **재귀 재

View file

@ -0,0 +1,48 @@
# 2026-08-12 열두 번째 세션 — Slot의 `slot→inst` 소유권 relate, `retractUnder` 4-인자 이유
## 배경
직전 세션(열한 번째, "retract는 항상 불림" 전면 정정)에서 고친 `Slot`
`retract`/`process` 의사코드를 사용자가 검토하다가 두 가지를 짚음.
## 1. Slot의 "같은 값인가" 판정 — 위치 비교 대신 `slot→inst` 소유권 추적
기존 의사코드는 `(inst,k)`별 relate로 "이 위치에 이전에 뭐가 있었나"만
비교했음 — 사용자 지적: Slot은 이미 "한 element가 어디에도 중복 마운트
안 됨"이 전역 불변식인데, 위치 비교로는 "이 Slot이 지금 *다른* 위치에도
동시에 마운트돼 있는가"를 못 잡음(같은 Slot 객체를 실수로 두 군데
`Frame`에 넣는 경우 등). 대신 `Relate<slot → inst>`로 각 Slot 자신이
지금 어느 `inst`에 바인딩됐는지 직접 추적하면: `owner == inst`(같은
자리로의 단순 emit 전파)면 무시, `owner`가 다른 inst면 즉시 error(다중
마운트), `owner`가 없으면 정상 바인딩. `attachSlot`이 이미 quad-roblox
소속이라 `inst`를 아는 것 자체는 자연스럽고, 몰라도 무관하게 동작함.
**반영**: `base/slot-plan.md` "Slot과 Store 바인드의 관계" 절 의사코드를
`kSlotMap`(위치별, retract가 뭘 지울지 알기 위한 용도로 존속)+`slotOwner`
(Slot별, 소유권 판정용 신설) 두 릴레이션으로 재작성.
## 2. `Dispatch.retractUnder(inst, k, keep, v)`가 왜 4-인자인가
사용자 질문: 설계상 `(inst, key, newValue)` 3개면 되고 `oldValue`
각 핸들러가 자기 `Relate`로 직접 저장하기로 한 거 아니었나(Dispatch가
일괄 저장하면 "저장할지 말지 모르는" 부작용이 생겨 처리 함수가 담당하기로
했던 결정) — 그런데 왜 4번째 인자가 있는지.
**답**: `keep``v`는 서로 다른 문제를 풂. `keep`은 "체인의 어느
항목이 호출자 자신이라 retract하면 안 되는지"를 정하는 구조적 파라미터
(StoreBind 같은 래핑 핸들러가 자기 자신은 안 지우고 그 아래로 위임된
것만 지우기 위해 필요, `keep=nil`이면 전체 체인 청소). `v`는 old value
저장이 **아니라** — 이번 대화 전체에서 만든 메커니즘(Tag의 `Contains`
힌트, Ref/Slot의 identity 비교, Attribute의 `v==nil` 게이트) 자체가
`retract`가 "새로 들어올 값"을 미리 아는 것에 의존하고 있어서, 그
힌트를 실어나르는 용도. `cutoff+1`(직접 대체되는 항목)만 진짜 `v`
받고 그보다 깊은 항목은 `nil`을 받음 — "무엇으로 대체됐는지"가 유의미한
건 바로 다음 항목뿐이라서. old value를 각 핸들러가 자기 `Relate`
저장한다는 원래 결정은 전혀 안 바뀜 — `retractUnder`는 old를 옮긴 적이
없고, 지금도 없음.
## 반영
- `base/slot-plan.md` — 위 1번 의사코드 정정.
- 2번은 순수 설명(질문에 답변) — 문서 반영 대상 없음, 기존 `retractUnder`
시그니처/동작이 이미 정확했음을 확인만 함.

View file

@ -579,3 +579,18 @@ Claude가 지적했으나, 사용자가 "덮여 쓰여지는 즉시 retract 실
방지). `Attribute`의 그룹 위임도 "남아있는 이름"에서 `retractUnder`
생략하면 체인이 계속 쌓이는 누수를 추가로 발견·정정. 역전 사례는
`archive/retract-always-fires-reversed.md`에 원문·근거·영향 범위 보존.
**2026-08-12 열두 번째 세션 — `Slot``slot→inst` 소유권 relate,
`retractUnder` 4-인자 이유** (`session/2026-08-12-12-slot-owner-relate-retractunder-args.md`)
직전 세션에서 고친 `Slot`의 의사코드를 사용자가 검토 — 위치별 relate로
"같은 값인가"를 비교하는 대신, Slot이 이미 갖고 있는 "한 element가
어디에도 중복 마운트 안 됨" 전역 불변식을 Slot 컨테이너 자신에도
그대로 적용해 `Relate<slot→inst>`로 소유권을 직접 추적하는 게 더
정확하다고 지적(위치 비교로는 같은 Slot이 동시에 다른 위치에도
마운트된 경우를 못 잡음) — `owner==inst`면 단순 emit 전파로 무시,
다른 inst면 즉시 error, 없으면 정상 바인딩으로 재작성. 별도로
`Dispatch.retractUnder(inst,k,keep,v)`가 왜 4-인자인지 질문받아 답변:
`keep`(체인 어디까지 지울지, 구조적)과 `v`(새로 들어올 값 힌트,
Tag/Ref/Slot/Attribute가 이번 대화 내내 의존해온 그 메커니즘)는 서로
다른 용도라 하나로 안 합쳐짐 — old value를 각 핸들러가 자기 `Relate`
저장한다는 원래 결정과는 무관, `retractUnder`는 old를 옮긴 적이 없음.