quad/.claude/archive/dispatch-hintvalue-model-reversed.md
qwreey 69466abc47
docs(dispatch): 0-Z/0-A 확정 — Attribute 전용 키+이름 claim, 하강 diff 전면 반영
`question.md` 최우선 두 건을 한 패스로 닫고, 배너를 달고 있던 7개 문서 +
인덱스 레이어 전체를 갱신했다.

0-Z (Attribute 이름 소유권):
- 그룹은 비공개 `GetKey`로 이름마다 자기 전용 키를 써서 위임 → 교차 오염
  구조적 제거. 이름 소유권은 `AttributeKeyHandler`의 이름 claim이 판정
  (`nameClaims` Relate, 다른 키가 같은 이름을 노리면 즉시 error).
- 권고안 (a)(그룹 안 claimant Relate)는 그룹↔직접 쓰기를 못 잡아 기각 —
  두 경로가 만나는 말단 핸들러에서 공개 키는 같은 객체라 소유자 구분 불가.

0-A (재디스패치 = 하강 diff):
- 래핑 핸들러의 선행 `retractFrom` 폐기, `Dispatch.process`가 슬롯의
  `handler`를 먼저 비교(같으면 클로저에 새 값 전달 후 재process, 다르면
  그 자리부터 전량 철거).
- 귀결: `retractFrom`이 3-인자로 축소(힌트를 외부에서 만들어 넣을 자리
  소멸), `isX(hintValue)` 가드 규칙 폐지, 깊은 체인 힌트 유실 캐비엇 삭제,
  Dispatch의 점유 체크 폐지.
- 9차 세션이 미뤄둔 2단계 분할을 같이 수행 — 디스패치 코어를
  `base/dispatch-core-plan.md`로 분리하며 재작성(bind-system-plan은
  2263→1219줄). 옛 모델은 archive/dispatch-hintvalue-model-reversed.md.

패키지 재배치 (사용자 제기):
- Tag/Attribute의 부기 알고리즘 전체를 quad-base로, 백엔드는
  `addTag`/`removeTag(inst,{string})`/`setAttribute(inst,name,v)` 3개 op만
  주입(웹 className/data-* 대응). 엔진 고유 타입 패밀리만 백엔드.
- `HANDLER_PRIORITY_FALLBACK` 신설 — base 제공 핸들러의 밴드, 백엔드가
  평범한 우선순위로 덮어쓰면 언제나 이김.

부수: 스파이크 04/19가 옛 모델을 검증 중이라 rewrite-required로 이동,
question.md 최우선 칸 비움, 새 소소 항목 2건 등록(Merged 이름 중복,
`hintValue` 이름 재검토). doc-check ERROR 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KckSawrsJSJmDBcSojJPxZ
2026-08-13 23:53:15 +09:00

18 KiB

[역전됨] 재디스패치 = "철거 후 재구축" + hintValue 힌트 모델

상태: 역전됨 — 2026-08-13 여섯 번째 세션에 사용자가 결함을 제기하고 방향을 제시, 같은 날 열네 번째 세션에 마지막 열린 항목(Attribute 이름 소유권)까지 확정되며 base/dispatch-core-plan.md로 전면 반영 완료. 이 문서는 그때까지 research/ 아래 dispatch-redispatch-diff-plan이었던 설계안 원문이고, 지금 유효한 모델은 base/dispatch-core-plan.md의 "Dispatch 체인" 절 — 여기 남기는 이유는 "왜 힌트 모델을 버렸는가"의 재현 사례와 근거가 통째로 보존될 가치가 있어서(같은 함정을 다시 설계하지 않도록).

뒤집힌 옛 모델의 골자 (원문 보존)

-- 옛 계약: 래핑 핸들러가 재-dispatch 전에 자기 아래를 먼저 철거하고,
-- 그때 "곧 디스패치될 raw 값"을 힌트로 실어 보냄.
function StoreBind.process(inst, k, state, index)
    local observer = state:Observer(function()
        local realv = state:Get()
        Dispatch.retractFrom(inst, k, index + 1, realv)  -- 선행 철거 + 힌트
        Dispatch.process(inst, k, realv, index + 1)
    end)
    ...
end

function Dispatch.retractFrom(inst, k, index, v)   -- 4-인자(마지막이 힌트)
    ...
    retractor(if i == index then v else nil)        -- 힌트는 직속 1단계에만
    ...
end

옛 모델이 같이 요구하던 규칙들(전부 지금은 폐기):

  • isX(hintValue) 가드 필수 — 힌트의 타입이 계약으로 보장되지 않아서.
  • hintValue는 직속 위임 1단계에만 보장, 깊은 인덱스는 항상 nil — 그래서 State<State<Tag>>류에서 깜빡임 방지가 조용히 꺼짐.
  • Dispatch.process의 점유 체크(이미 점유된 인덱스면 즉시 error) — Attribute 이름 소유권이 이 부수 효과에 얹혀 있었음.

아래는 역전을 이끈 분석 원문 (당시 research/ 문서 그대로)

1. 현행 hintValue의 실제 결함 (사용자 지적, 확인됨)

현행 계약: Dispatch.retractFrom(inst,k,index,v)index 자리 retractor에 v를 넘김. 그 v는 **"그 자리에 곧 디스패치될 raw 값"**이다.

문제: 그 raw 값이 핸들러가 이해하는 의미 값이라는 보장이 전혀 없음.

1-1. 힌트로 None 센티널이 그대로 넘어감 (사용자 제기, 재현됨)

[AttributeKey "foo"] = state, state가 5None을 오갈 때:

사이클 체인
None StoreBind@1NoneHandler@2AttributeKeyHandler@3
5 StoreBind@1AttributeKeyHandler@2

5None으로 갈 때 StoreBind는 retractFrom(inst,k,2,None)을 부르고, 인덱스 2의 AttributeKeyHandler retractor가 hintValue = None 을 받음. Attribute는 클로저가 no-op이라 무해하지만, TagHandler였다면 isTag(None)이 거짓이라 "이 자리가 Tag이길 그만둔다"로 오판해 이름 전부를 RemoveTag함.

1-2. 래핑이 한 겹만 끼어도 힌트가 무의미해짐

같은 이유로 힌트가 State, Tween, 그 외 미래에 추가될 어떤 래퍼여도 말단 핸들러는 그걸 해석 못 함. 앞서 문서화했던 "깊이 2 이상에선 nil" 문제는 이 결함의 한 특수 케이스였을 뿐 — 진짜 문제는 깊이가 아니라 **"힌트의 타입이 계약으로 정해져 있지 않다"**는 것.

1-3. 그래서 힌트 기반 최적화가 "가끔 조용히 꺼진다"

TagContains skip, Ref/Slot의 identity 비교는 전부 힌트가 자기 타입일 때만 동작 — 위 경로들에선 소리 없이 전량 정리로 퇴화함. 정확성은 유지되지만(그래서 지금까지 안 드러남) 깜빡임/재생성 방지라는 존재 이유가 무너짐. 특히 Slot은 "가드가 없으면 마운트된 서브트리 전체가 [정정, 2026-08-13 4차 감사] 언마운트됐다 재마운트"(작성 당시 아직 destroy 모델 — 파괴가 아니라는 것만 정정, 파급이 크다는 결론은 그대로)라 파급이 큼.

2. 채택 모델 — 재디스패치는 "철거 후 재구축"이 아니라 "하강 diff"

사용자 정리:

"차라리 process 를 쭉 진행해, 이전거랑 자신 것이랑 다르면 nil 또는, 그걸 나타내는 HandlerChanged 등의 무언가로 아래를 전부 죽이고, 같으면 값을 계속 넣어가며 전파해야할듯, 같은것이면 본인 인덱스에 대해서만 후처리를 해."

"retract 계약이 지금 보면, 일단 시도해보는데, 이전이 내 핸들러면 내가 처리, 아니고 다르면 아래쪽을 그냥 retract 처리해서 전부 제거"

래핑 핸들러가 재-dispatch 전에 retractFrom을 먼저 때리는 것을 폐기하고, 그냥 Dispatch.process를 아래로 내려보냄. 비교는 Dispatch.process 안에서 일어남:

-- chains[inst][k][index] = { handler = h, retractor = fn }
function Dispatch.process(inst, k, v, index)
    local list = <확보 + chains에 등록>   -- 기존 순서 규칙 그대로(h.process 前)
    local slot = list[index]
    local h = Dispatch.getHandler(inst, k, v)   -- 매치 실패는 기존대로 즉시 error

    if slot ~= nil and slot.handler == h then
        -- 같은 핸들러: 아래를 안 건드리고, 이 자리 클로저에 새 값을 넘겨
        -- 스스로 전이를 처리하게 함. v는 h.isHandlable(v)가 참임이 보장됨.
        slot.retractor(v)
        slot.retractor = h.process(inst, k, v, index)
    else
        -- 다른 핸들러(또는 빈 자리): 이 자리부터 아래를 전부 철거 후 새로 설치
        Dispatch.retractFrom(inst, k, index, nil)
        list[index] = { handler = h, retractor = h.process(inst, k, v, index) }
    end
end

StoreBind/NoneHandler는 이제 그냥:

Dispatch.process(inst, k, realv, index + 1)   -- retractFrom 선행 호출 없음

2-1. 이걸로 동시에 풀리는 것들

  • 힌트의 타입이 보장됨 — 클로저에 값이 넘어가는 건 오직 핸들러가 같을 때뿐이고, "같다"는 건 getHandler(inst,k,v) == slot.handler 로 판정한 것이므로 v는 정의상 그 핸들러의 isHandlable을 만족함. 1-1/1-2의 None/래퍼 오염이 구조적으로 불가능해짐. 지금의 "isX(hintValue) 가드 필수" 일반 규칙 자체가 힌트의 타입 미보장을 메우던 임시방편이었음이 드러남 — 그 규칙도 같이 없앨 수 있음.
  • 깊은 체인의 힌트 유실도 사라짐 — 힌트를 위에서 아래로 전파하는 게 아니라, 각 레벨이 자기 재프로세스에서 자기 힌트를 받음. State<State<Tag>>에서 바깥이 새 inner State를 내놓아도: index 2는 StoreBind끼리 같으니 자기 클로저가 구독을 갈아타고, 재위임으로 내려간 index 3은 TagHandler끼리 같으니 진짜 Tag 객체를 힌트로 받아 Contains skip이 정상 동작. (앞 라운드에 "구조상 불가피"라고 적었던 건 틀렸음 — 철거 선행 모델에서만 불가피했던 것.)
  • StoreBind 구독 갈아타기 문제 없음(사용자 지적: "다만 뭐가 문제인지 모르겠어요") — "같은 핸들러면 유지"가 아니라 "같은 핸들러면 자기 클로저 → 자기 process"라, 옛 구독 해제와 새 구독이 그 안에서 끝남. 검토 중 제가 문제로 짚었던 것은 "유지"로 잘못 읽은 데서 나온 것.
  • 최상위는 애초에 안 돌아감 — 값이 안 바뀌면 Observer가 안 뛰므로 재프로세스 자체가 없음.

2-2. 두 종류의 retract가 계약상 갈림 (사용자 정리)

"새 프로세싱으로 인한 retract처리와, 단순 retract는 다르다"

  • 단순 retract(언마운트/전체 철거, retractFrom): 뒤따르는 process가 없음. 힌트는 항상 nil. 핸들러는 자기 기여를 무조건 전부 걷어냄.
  • 재프로세싱: 핸들러가 같으면 그 자리 클로저가 새 값을 힌트로 받고, 다르면 그 자리부터 아래가 단순 retract 됨.

2-3. 전제 계약 — inst 부작용은 말단 핸들러만 (사용자 명시)

"inst 에 실질적 처리를 가하는 동작은 항상 말단 핸들러 노드다" "중간 노드는 단순 언워랩만 한다"

기존 핸들러 전부가 이미 만족함(확인함):

핸들러 위치 inst 부작용
StoreBind 중간 없음(구독 + 재위임만)
NoneHandler 중간 없음(재위임만)
PropertyHandler 말단 프로퍼티 세팅
TagHandler 말단 AddTag/RemoveTag
AttributeKeyHandler 말단 SetAttribute
SlotHandler 말단 마운트
RefLeafHandler 말단 Ref:Set
UICornerHandler 말단 자식 Instance 생성/제거
AttributeGroupHandler 자기 체인에선 말단 없음(다른 키로 위임)

새 제약을 거는 게 아니라 이미 성립하는 성질을 계약으로 승격하는 것.

3. 검토 중 정리된 것

3-1. HandlerChanged 마커는 불필요

"핸들러가 바뀜"은 그 자리 retractor가 nil 힌트로 불린다는 사실 자체로 이미 표현됨. 별도 마커 값을 만들면 그것도 결국 "힌트로 넘어오는 정체불명의 값"이 되어 1-1과 같은 문제를 되풀이함.

3-2. oldValue를 넘기자는 보완안 — 철회(사용자 지적)

"이전 값인 oldValue 는 처음부터 클로저라 이미 본인이 알지 않아요?"

맞음. 클로저는 자기 process 호출의 v를 upvalue로 캡처하고 있고, 힌트로 새 값을 받으므로 old/new를 이미 둘 다 갖고 있음. 문제였던 건 오직 힌트의 타입 보장이고, 그건 2-1의 핸들러 선비교로 해결됨 — chains에 값을 따로 저장할 이유가 없음. (chains에 추가로 저장해야 하는 건 비교용 handler 하나뿐.)

3-3. 중간(재위임) 핸들러에 붙는 작은 계약

같은 핸들러로 재프로세스될 때, 재위임하는 핸들러는 반드시 다시 재위임해야 함. 안 그러면 아래 인덱스가 고아로 남음(아무도 안 지움). StoreBind/NoneHandler는 항상 재위임하므로 지금은 위반 사례가 없지만, "조건부로만 재위임하는" 핸들러를 새로 만들면 그 자리에서 Dispatch.retractFrom(inst,k,index+1,nil)을 직접 불러 아래를 정리해야 함.

4. 부수 효과 — Slot의 "해제 짝"은 애초에 없어도 됨 (사용자 지적)

base/slot-plan.md가 "attachSlot이 등록한 Dispatch.setLength/ setOffsetSource에 대응하는 해제 짝이 없다"를 언마운트 전환의 실제 작업량으로 꼽았는데, 새 함수가 필요 없음:

"옛 오너가 setLength/setOffsetSource 를 그냥 실행해도 된다는 생각. retract에서 hint 를 보고 Slot이 아니면 그냥 setLength(...0...) setOffsetSource(...None...) 될 수 있어요."

이건 이미 확정된 관용구 그대로임 — bind-system-plan.md의 "실제 마운트를 하지 않는 위치는 None을 등록, setLength도 짝을 맞춰 0". 즉 해제 = 0/None으로 재등록이고, 별도 unregister API가 아예 필요 없음.

state<state<Frame>>류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된 것"으로 수용(사용자 판단) — state<state<Tag>>와 같은 범주이고, 평탄화 도구(research/operator-sugar-plan.md)로 처리할 요소이며 실사용 케이스가 드묾. Dispatch에 별도 배관을 넣지 않음.

5. 남은 열린 항목 — Attribute 이름 소유권 (하나뿐, 사용자 확인 필요)

사용자 판단:

"이 방식으로 가면, 자연히 Attribute 의 소유권 충돌은 처리되네요. 이미 처리된 인덱스에 대해 다시 프로세스 되는것 자체가 UB가 아니고, 오류 처리가 필요한 곳이면 직접 처리하면 되니까요."

앞부분(재프로세스가 UB 아님)은 동의 — 뒷부분("자연히 처리됨")은 추적해보니 그렇지 않음. 구체적으로:

그룹 A가 Dispatch.process(inst, AttributeKey("foo"), sourceA, 1)로 등록해둔 상태에서 그룹 B가 같은 이름에 sourceB로 들어오면: 인덱스 1의 현재 핸들러는 StoreBind이고 sourceBStoreBind에 매치되므로 "같은 핸들러"로 판정되어 조용히 갈아탐. 그리고 나중에 그룹 A의 클로저가 자기 이름들을 retractFrom할 때 그룹 B의 바인딩을 대신 철거함(교차 오염). 즉 예전 "조용한 last-write-wins"가 그대로 돌아옴 — 이번 감사에서 고쳤던 바로 그 증상.

즉 "직접 처리하면 되니까"가 맞고, 그 '직접 처리'를 실제로 무엇으로 할지가 유일하게 남은 결정. 후보:

  • (a) 이름별 claimant Relate를 Attribute 쪽에 둠 — 2026-08-13 네 번째 세션에 owners라는 이름으로 만들었다가 기각됐던 그것. 당시 기각 사유는 지금은 해당 없음: 그때 버그는 "소유권 반납이 processv==nil 분기에만 있어서 그룹이 이름을 통째로 놓는 경로가 그 분기를 안 타 옛 소유권이 안 지워짐"이었는데, 지금은 클로저가 항상 불리고 거기서 반납하면 되므로 그 구멍이 구조적으로 없음. 실질 6줄 정도.
  • (b) 감지 포기, 문서로만 금지 — 사용자 코드 실수이므로 UB로 두는 것. 다만 증상이 "조용한 오작동 + 교차 오염"이라 다른 UB들(즉시 스택오버플로/즉시 error)보다 훨씬 나쁨.
  • (c) Dispatch에 claimant 개념을 일반화 — 이번에 걷어낸 방향이라 다시 넣는 건 반대.

권고: (a). 기각 사유가 새 모델에서 소멸했고, 소유권 판정을 필요로 하는 유일한 핸들러에만 국소적으로 두는 게 "Dispatch는 diff만 한다"는 이번 방향과도 맞음.

6. 반영 범위 (확정 시)

  • bind-system-plan.mdDispatch.process 의사코드, "Dispatch 체인" 절, "핸들러 계약"(힌트 타입 보장 추가, isX(hintValue) 가드 규칙 삭제), "Handler 작성 체크리스트" 3번 항목, StoreBind 예시(선행 retractFrom 삭제), NoneHandler 예시, "hintValue는 직속 1단계에만" 항목 삭제.

  • tag-plan.md — 힌트가 항상 Tag임이 보장되므로 isTag(hintValue) 가드 삭제, 깊은 중첩 캐비엇 삭제.

  • slot-plan.md — 4절대로 "해제 짝 필요" 서술 정정, 언마운트 경로에 setLength(0)/setOffsetSource(None) 명시.

  • attribute-plan.md — 5절 결정 반영, process/클로저 모양 재확정.

  • [2026-08-13 7차 감사에서 누락 발견 — 추가] architecture.md — 소스 트리의 Dispatch/init.luau 항목(chains/retractFrom 서술)과 Attribute.luau 항목("retractFrom에 재귀 위임")이 현행 모델 전제로 쓰여 있음. 이 파일도 최상단에 0-Z 배너를 달고 있는데 이 목록엔 빠져 있었음 — 모든 세션이 "먼저 읽으라"고 지목하는 진입점이라 누락되면 파급이 큼.

  • [같이 추가] ROADMAP.md — M2/M4/M6/M10에 "0-Z 먼저 해소할 것" ⚠️ 배너가 달려 있고, 그 배너가 가리키는 체크리스트 항목들(특히 M6의 "SlotHandler.process는 claim 실패 시에도 파괴적 클로저를 반환해야 함")이 선행 retractFrom 전제로 쓰여 있음 — base 4개를 옮길 때 같이 갱신하고 배너를 걷을 것.

  • [2026-08-13 감사에서 추가] ref-plan.mdbind-system-plan.md 1단계 분할(2026-08-13 아홉 번째 세션, 순수 이동) 때 "Ref의 retract" 절이 옮겨오면서 선행 retractFrom 호출 서술도 그대로 딸려왔음. 분할 당시 이 문서(section 6, 당시 아직 안 쓰임)가 없어 배너가 안 붙었던 것 — 반영 시 나머지 6개와 같이 재작성하고 배너 걷을 것.

  • [2026-08-13 8차 감사에서 추가] 같은 패스에서 bind-system-plan.md 2단계 분할도 할 것 — 이 문서가 지시하는 재작성 범위(핸들러 계약 / 확정된 디스패치 모델 / Dispatch 체인 / Handler 작성 체크리스트)가 곧 분할 예정인 "디스패치 코어" 블록과 정확히 겹침. 어차피 그 텍스트를 전면 재작성하므로, 재작성하면서 새 파일로 옮기면 인바운드 참조 (~37곳)를 한 번만 고치면 됨. 따로 하면 같은 곳을 두 번 만짐.

요약: 배너를 달고 있는 파일 = 반영 대상. 위 7개(bind-system-plan/ tag-plan/slot-plan/attribute-plan/architecture/ROADMAP/ ref-plan)가 전부이고, 반영이 끝나면 각 파일의 ⚠️ 배너도 같이 제거할 것.


최종 결말 (2026-08-13 열네 번째 세션)

  • 5절의 열린 항목은 (a)가 아니라 그 변형으로 확정됨 — 사용자가 제시한 그룹 전용 키(GetKey, 비공개) + AttributeKeyHandler의 이름 claim. (a)(그룹 안에 이름별 claimant Relate)를 그대로 쓰면 그룹↔직접 쓰기 충돌을 못 잡는다는 게 트레이싱으로 드러났기 때문 — 두 경로가 만나는 유일한 지점인 말단 핸들러에서 k가 같은 객체라 소유자를 구분할 수 없음. 근거와 최종 의사코드는 base/attribute-plan.md "이름 소유권" 절.
  • Dispatch.retractFrom은 4-인자에서 3-인자가 됐음 — 값을 넘기는 경로가 Dispatch.process의 "같은 핸들러" 분기 하나로 통일되면서, 외부가 힌트를 만들어 넣을 자리 자체가 없어짐(옛 결함이 구조적으로 재발 불가).
  • 6절의 반영 목록(7개 문서 + bind-system-plan.md 2단계 분할)은 같은 세션에 전부 수행됨 — 디스패치 코어는 base/dispatch-core-plan.md로 분리되며 재작성됐다.