diff --git a/.claude/README.md b/.claude/README.md index aa9cf90..40cdd68 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -22,7 +22,7 @@ | 폴더 | 기준 | |---|---| | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | -| `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | +| `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음. **[2026-08-21 확장] 확정된 결정의 "왜 그렇게 정했나" 근거 기록도 여기 둔다** — `research/`(아직 상의 필요)도 `archive/`(뒤집혔거나 기각됨)도 아니고, `base/`가 근거로 인용하는 온디맨드 자료라는 이 폴더의 기준에 정확히 맞기 때문(`slot-attach-decomposition.md`/`epoch-brand-composition.md`가 그렇게 들어옴) | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | | `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). `pre-implementation-qa-round4.md`(4라운드: 사용자 요청으로 **`base/` 확정 전체를 "예가 나와야 정상인 문항"으로 다시 뽑은 전수 문항지** — **[2026-08-21] 완료·종결**. 1~3라운드와 달리 이 파일은 **문항지 원본 그대로 남긴다** — 처리 결과 전량이 `-followup.md`에 쌓였으므로 "아니오만 남기는 재편"은 안 하기로 함(같은 정보가 두 곳에 갈라지는 걸 피함). 문항 수/문서별 분포는 그 파일 자신이 소스). `pre-implementation-qa-round4-response.md`(사용자 회신 원문 — 이 라운드는 회신을 **별도 파일**로 받았다, 1~3라운드와 다른 점), `pre-implementation-qa-round4-followup.md`(**[2026-08-20 시작, 2026-08-21 종결]** 그 회신 처리 결과 — A~H 8개 절이 4차에 걸쳐 시간순으로 쌓였고 **마지막 H절이 최신이자 소스**. B·C절의 재질문/판단 대기 항목은 F·G절을 거쳐 **H절에서 전량 닫혔다**(`Detach` 보존 주체, `KeyGone`, `Owned`, `attachSlot` 분해). **열린 질문 없음**. **[2026-08-21 정정]** 여기 적혀 있던 "5라운드 문항지는 만들지 않는다"는 뒤집혔다 — 같은 날 사용자 요청으로 5라운드를 만들었다), `pre-implementation-qa-round5.md`(**[2026-08-21 신설·처리 완료]** 5라운드: 4라운드에서 "예"로 넘어간 자리는 건너뛰고 **(1) 4라운드에 문항이 아예 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가 아니라 실제 커밋된 M1 코드**), **(2) 4라운드 회신 이후 새로 확정된 것**(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해 등), **(3) 큰 문서의 심화**(예: `debounce-throttle-plan.md`)만 묻는다. 문항 수는 그 문서 자신이 소스), `pre-implementation-qa-round5-response.md`(사용자 회신 원문 — 4라운드와 같이 별도 파일), `pre-implementation-qa-round5-followup.md`(**[2026-08-21]** 그 회신 처리 결과 — 즉시 반영분 / 재질문 / 사용자 판단 필요 / 새로 만든 문서 둘(`gate-plan.md`·`state-epoch-plan.md` — 같은 날 확정되며 `base/`로 승격)까지. **처리 결과의 소스는 이 파일**). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | @@ -59,7 +59,7 @@ | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | | `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) | | `debounce-throttle-plan.md` | **[2026-08-14 신설, 2026-08-19 전부 해소돼 `research/`에서 승격]** 시간 기반 전파 게이트 `Debounce`/`Throttle` — 사용자 요청("`Blocker`와 유사하게")으로 신설. 요지: (1) `Blocker`가 이미 쓰는 게이트 노드의 **릴리스 트리거만 타이머로 바꾼 것**이라 새 전파 메커니즘이 아님, (2) 무효화 채널만 만지므로 laziness 안 깨짐, (3) **Debounce/Throttle의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐** — 공개 생성자는 둘, 구현은 하나, (4) 알고리즘은 quad-base + 주입 op 2개 `setTimeout(func, delay) -> Timeout`/`clearTimeout`(Roblox `task.delay`/`task.cancel`로 배선 — **인자 순서 반대라 주의**), `Timeout`은 `{ __type_timeout: true, _native: any }`. **[2026-08-19 마지막 라운드]** 의미론은 **(A) emit-gate**(`Blocker`와 동일, `:Get()`은 항상 최신값)로 확정 — 검토했던 값-지연 안은 laziness와 상충해 철회. 제어 핸들은 개별은 `Ref` 아웃파라미터·전체는 팩토리 자체의 `:Flush()`/`:Cancel()`(weak 레지스트리)로 확정, `Time`/`MaxTime`은 `number \| State`(스케줄 시점에만 폴링) 허용. 이름은 `Debounce`/`Throttle` 유지 + Roblox 관용 "debounce"와 다르다는 문서 경고. **결과적으로 quad-base에 새 코어 메커니즘을 안 더하는 순수 슈가로 귀결**(`Blocker`의 gated state + `Ref` + 주입 op 2개 위에 전부 얹힘) — 우선순위는 `Operator.*`와 같은 급으로 재평가됨. **부수 성과**: 이 설계 중 `source-state-plan.md`의 무효화 dedup 서술이 `Observer` 계약과 모순되는 게 발견돼 base 전면 정정(`archive/invalidate-dedup-propagation-reversed.md`) | -| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음(그 dedup 경로의 process/retract 대칭은 **미확인**, M3 착수 전 확인) | +| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, ...deps)` — deps 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음. **[2026-08-20 `E-10` → 2026-08-21 `EF-3`에서 반영]** 그 dedup 경로의 process/retract 대칭은 **성립함이 확인됨**(핸들러가 `old`를 `Relate`로 직접 들고 양쪽이 같은 비교식을 씀 — 남은 건 구현 시 회귀 확인뿐, 설계상 열린 항목 아님). **[2026-08-21 5라운드 `C-6`]** 시그니처가 `Effect(fn, ...deps)`로 확장돼 의존성을 여러 개 직접 받고(`Ref`도 가능) 각각에 구독을 건다. **[2026-08-21]** 다중 의존성이 공통 상류를 공유할 때 한 파동에 `fn`이 여러 번 돌던 미해결 갭은 **`EffectHandle`이 자기 `EpochMap`을 들어** 닫힘(`base/state-epoch-plan.md`) | | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드). **[2026-08-18 구현 전 QA 반영]** **`Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가 이김)을 둘 다 제공**으로 열린 항목 해소, 그리고 **⚠️ 같은 그룹 객체를 두 위치에 놓는 경우를 잡을 위치별 claim이 필요**하다는 미해결 항목 신설(`Ref`처럼 `bindLifetime` 재사용은 불가) | @@ -67,12 +67,12 @@ | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 | | `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`). **[2026-08-18 구현 전 QA 반영]** 내부 구조가 **별도 `.Callbacks` 테이블 + 평범한 `.Value` 필드**로 단순화(`__index` 우회 폐기), `RefLeafHandler.isHandlable`에 빠져 있던 `type(k)=="number"` 추가(leaf 바인딩은 **배열 전용**), "배열 파트의 `None`은 process를 안 탄다"는 옛 명확화 전면 정정 | | `event-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 **[2026-08-18 정정] `None`/`nil`을 넣으면 disconnect**(옛 `false` 센티널은 `None` 도입 전의 선택이라 폐기 — `EventHandler.isHandlable`이 `v == nil`에도 매치돼야 함). 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | -| `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`), `isState`를 branded 타입 전부로 일반화(`isPostRef` 포함, 2026-08-14 아홉 번째 세션). 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **[2026-08-18 구현 전 QA 반영]** **`Brand`는 아무 의존성도 갖지 않는다** — `Brand.get`이 `x == None`을 먼저 보는 특수 분기 안은 기각(`isNone`은 그냥 `v == None`, `None`을 평범하게 태깅하는 건 무방). **분리는 순수 이동** | +| `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 branded 타입 전부로 일반화(`isPostRef` 포함, 2026-08-14 아홉 번째 세션). **[2026-08-21 전면 재작성]** 공유 레지스트리 + `Brand.get(x) -> tag`(객체당 태그 하나)에서 **인스턴스 브랜드**(`Brand()` + `:register`/`:is`, **다중 태깅 허용**)로 바뀜 — 발단은 `Source`가 `SourceBrand`이면서 동시에 `EpochBrand`여야 하는데 옛 모양으로는 표현이 안 되던 것. 역조회는 없어졌고(멤버십 질문만 씀), weak-key·테이블 아이덴티티·duck-typing 기각 근거·포함 관계 predicate 합성은 전부 유지. 역전 원문은 `archive/brand-shared-registry-reversed.md`, 근거 기록은 `reference/epoch-brand-composition.md`. 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **[2026-08-18 구현 전 QA 반영]** **`Brand`는 아무 의존성도 갖지 않는다** — `None` 특수 분기 안은 기각(`isNone`은 그냥 `v == None`) | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | | `fallback-plan.md` | **[2026-08-14 세션, `research/`에서 승격]** `Fallback`/`Traceback` — 컴포넌트 함수를 감싸 에러 시 플레이스홀더를 그려주는 순수 슈가(`additional-primitives-plan.md`의 "Error Boundary" 절이 내린 "빈 자리 아님" 결론 위에 얹힘). `Fallback`은 `pcall` 기반(trace 없음), `Traceback`은 `xpcall`+`debug.traceback` 기반(trace 항상 있음) — 플래그 대신 별도 함수로 분리(`Ref`/`PreRef`와 같은 패턴). `err: any`(Lua `error()`가 임의 값을 던질 수 있음, `error(msg)` 기본 호출의 위치 접두 캐비엇 포함) 확정. 패키지는 `quad-base`, 이름 확정. 메커니즘 실측은 `audit/fallback-xpcall-verification.md`. 구현 우선순위는 형제 백로그(`quad-mock`/`quad-debug`/`Operator`)와 동급, 맨 뒤 | | `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 | | `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Block(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **남은 것은 생명주기·재진입 계약과 M2 범위**(`Gate`만 vs `Blocker`까지). 구현은 M2 | -| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **루트 Source 에포크 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. 노드가 `sourceCountMap`(값 유효성)/`sourceEmitMap`(전파 dedup) 두 테이블을 들고, emit은 발행 source만 싣고, 순회는 `rawInvalid == false`일 때만 돌며 값만 앞당기고 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 구현은 M3 | +| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만 돌며 값만 앞당기고 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** 구현은 M3 | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) @@ -81,19 +81,19 @@ | `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선. **[2026-08-07 `base/`→`reference/` 이동]** v2의 결정 자체가 아니라 다른 문서가 인용하는 온디맨드 자료라 항상 읽을 필요는 없음 | | `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료(전파 모델 등 일부 서술은 이후 라운드에서 뒤집혔으니 `bind-system-plan.md` 쪽을 최신으로 볼 것). **[2026-08-07 `base/`→`reference/` 이동]**, `quadnomicon` 소재 후보 | | `comparison-charm.md` | **[2026-08-09 신설]** littensy/charm(Roblox Zustand류) 비교 — `batch()`/`atom()`/수동 dispose Effect 3가지는 quad가 이미 기각한 패턴이라 반면교사, `None` 센티널은 독립 재확인, charm-sync의 diff/patch는 quad 미착수 네트워크 복제 영역의 첫 참고자료, 이미 확정된 `:Compute`의 `previous` 인자(`base/source-state-plan.md`)엔 정황 증거(생성 시 필수 `equals`, computed의 previous-in-getter) 제공 | +| `epoch-brand-composition.md` | **[2026-08-21 신설, 같은 날 확정·승격 완료 → `research/`에서 이동]** `Epoch` 인터페이스 + `EpochMap` 컴포지션 + `Brand` 인스턴스화의 **결정 근거 기록**. 발단은 다중 의존성 `Effect`에서 한 파동에 `fn`이 두 번 도는 갭 — `Effect`가 자기 `EpochMap`을 들면 닫힌다. 에이전트가 짚었던 대가 둘(`Brand.get` 역조회 상실 / 포함관계가 흩어짐)은 **둘 다 해소**(전자는 필요 없고, 후자는 착오 — predicate 합성으로 그대로 쓰면 됨). 에이전트가 한때 "`:Sync`가 필수"라 적은 것도 `Update`를 좁게 본 착오로 철회. 정본은 `base/state-epoch-plan.md`/`base/brand-plan.md`/`base/source-state-plan.md`/`base/effect-plan.md` | +| `slot-attach-decomposition.md` | **[2026-08-21 신설, 같은 날 확정 → `research/`에서 이동]** `attachSlot`의 책임 분해 **결정 근거 기록** — 결론은 후보 (B) 분해 채택이고 `base/slot-plan.md`에 반영 완료(`materializeSlotTree`+`mountSlotTree`+두 줄짜리 `attachSlot` 래퍼). QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 flush 앞/뒤 어디에 둘지가 갈렸는데, 사용자가 그건 자리 선택 문제가 아니라 *"attachSlot 의 기능이 너무 다양해진게 문제"* 라고 짚어 확장 논의로 넘어간 것. `attachSlot`이 지던 책임 일곱과 순서 제약 일곱(각각 `RC-1`/`RC-3`/`RC-4` 등 실제로 밟은 버그 출처까지)을 모아두고, **C6(길이 최종값은 flush 뒤에야 정해짐)와 C7(부기가 물리보다 먼저)이 단일 함수로는 동시 만족 불가능**함을 보인 뒤 분해 후보 넷을 대조. **[2026-08-21 후속 캐비엇]** 그 `C7`은 같은 날 `native*` 계층 확정으로 **일반 계약으로서는 폐기**됐다(`archive/bookkeeping-before-physical-reversed.md`) — 결론은 그대로 유효하지만(배치 경로가 부기를 먼저 끝내는 건 `C6` 요구사항이라 별개) 그 표만 떼어 읽지 말 것, 문서 상단에 배너를 달아뒀다. 사용자 확정 논거는 *"지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 생각함"*. 정본은 `base/slot-plan.md` | ## `research/` — 아직 착수 전, 상의 필요 | 문서 | 내용 | 우선순위 | |---|---|---| -| `epoch-brand-composition.md` | **[2026-08-21 신설]** 에포크 부기를 State에서 떼어내 `EpochMap`으로 컴포지션하고, emit 페이로드를 `Source` 대신 **`Epoch` 인터페이스**(`{Count: number}`, 그 자체로 키)로 일반화하며, 그걸 위해 `Brand`를 **인스턴스화 가능**하게(다중 태깅) 바꾸자는 사용자 제안. 발단은 다중 의존성 `Effect`에서 한 파동에 `fn`이 두 번 도는 갭 — `Effect`가 자기 `EpochMap`을 들면 닫힌다. 에이전트가 짚었던 대가 둘(`Brand.get` 역조회 상실 / 포함관계가 흩어짐)은 **같은 날 둘 다 해소**(전자는 필요 없고, 후자는 착오 — predicate 합성으로 그대로 쓰면 됨). `Epoch`는 `Source`가 구조적으로 만족하고 리비전 필드는 공개까지 확정. **[같은 날 2차 회신으로 사실상 전량 확정]** 리비전은 숫자, `Update(전체 deps)`가 곧 sync라 `:Sync`는 순수 최적화, `Observer` 클로저는 `:Compute`와 같은 `fn(self, from)`. 남은 미정은 리비전 증가를 `bit32` 랩으로 할지 평이한 `+1`로 할지 하나뿐(둘 다 실질 위험 없음). **`base/` 승격은 사용자 확인 대기** — 승격하면 `state-epoch-plan`/`source-state-plan`/`effect-plan`/`brand-plan` 넷을 같이 고쳐야 함 | 중 — M3(State) 전. `Brand` 전환은 M1 코드가 아직 `Brand`를 안 써서 문서 비용뿐 | | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | | `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 | | `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 | | `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 못 고치는 트레이드오프(의도된 설계) 정리. **[2026-08-12 열여덟 번째 세션]** "고칠 만한 것" 절에 남아있던 마지막 두 항목(use-after-destroy 검증 안전망 부재, `:With` 동적 의존성 미지원)도 사용자가 "고칠 필요 없음"으로 최종 판단해 3번 절(못 고치는 트레이드오프)로 이전 — 2번 절은 이제 해소된 항목만 남음 | 하 — 배경 자료, 더 이상 사용자 판단 대기 상태 아님 | | `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 | -| `slot-attach-decomposition.md` | **[2026-08-21 신설, 같은 날 확정]** `attachSlot`의 책임 분해 근거 기록 — **결론은 후보 (B) 분해 채택**이고 `base/slot-plan.md`에 반영 완료(`materializeSlotTree`+`mountSlotTree`+두 줄짜리 `attachSlot` 래퍼). 아래는 그 논의의 출발점. QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 flush 앞/뒤 어디에 둘지가 갈렸는데, 사용자가 그건 자리 선택 문제가 아니라 *"attachSlot 의 기능이 너무 다양해진게 문제"* 라고 짚어 확장 논의로 넘어간 것. 지금 `attachSlot`이 지고 있는 책임 일곱(부모 등록 offset/length, `:List` 실체화, 마운트 상태 전이, 배치 게이팅, 자식 배치, 재귀)과 순서 제약 일곱(각각 `RC-1`/`RC-3`/`RC-4` 등 실제로 밟은 버그 출처까지)을 모아두고, **C6(길이 최종값은 flush 뒤에야 정해짐)와 C7(부기가 물리보다 먼저)이 단일 함수로는 동시 만족 불가능**함을 보인 뒤 분해 후보 넷(현행 유지 / `prepare`+`mount` 2단 / 3단 / 문서만)을 대조. 사용자 확정 논거는 *"지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고 생각함"*. 정본은 여전히 `base/slot-plan.md` | 하 — **[2026-08-21] 결론 확정·반영 완료**, 이제 근거 기록용 | | `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리(**[2026-08-19]** 그 질문은 `base/debounce-throttle-plan.md`로 전부 해소·승격 완료), `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State|T>` → `State` 평탄화 항목 신설(백로그) — `State>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | | `quad-recursive-acronym.md` | **[2026-08-14 신설]** GNU/WINE류로 `Quad`를 재귀 약어화하는 카피 브레인스토밍 — 설계 결정도 착수 게이팅도 아니고 나중에 README.md 헤딩 등에 쓸 캐치프레이즈 후보 모음. 자학 개그 방향(기각)과 지연평가/재귀·커링/펑터/클로저를 자랑하는 방향(채택 후보, 미확정) 정리 | 하 — 카피 소재, 설계 상의 필요 없음. 사용자가 최종 문구 고르면 반영 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | @@ -105,7 +105,8 @@ | 문서 | 내용 | |---|---| -| `always-propagate-no-dedup-superseded.md` | **[역전됨, 2026-08-21 신설]** `source-state-plan.md`가 확정해뒀던 "emit은 자기 `invalid`와 무관하게 **항상** 전파된다 / quad가 접지 않는 것은 중복 *통지*뿐이다" — `base/state-epoch-plan.md` 채택으로 "같은 소스의 같은 에포크가 두 번째로 도착하면 접는다"로 바뀜. ⚠️ 2026-08-14의 `invalid` 기반 dedup 역전을 되돌린 게 아님(그 금지는 유효) | +| `always-propagate-no-dedup-superseded.md` | **[역전됨, 2026-08-21 신설]** `source-state-plan.md`가 확정해뒀던 "emit은 자기 `invalid`와 무관하게 **항상** 전파된다 / quad가 접지 않는 것은 중복 *통지*뿐이다" — `base/state-epoch-plan.md` 채택으로 "같은 `Epoch`의 같은 리비전이 두 번째로 도착하면 접는다"로 바뀜. ⚠️ 2026-08-14의 `invalid` 기반 dedup 역전을 되돌린 게 아님(그 금지는 유효) | +| `brand-shared-registry-reversed.md` | **[역전됨, 2026-08-21 신설]** `Brand`의 옛 표면 — 공유 weak 레지스트리 하나 + `Brand.set`/`Brand.get(x) -> tag`(**객체당 태그 하나**). `Source`가 `SourceBrand`이면서 동시에 `EpochBrand`여야 하는 요구(`base/state-epoch-plan.md`의 `Epoch` 인터페이스)를 표현할 수 없어 **인스턴스 브랜드**로 재작성됨(`base/brand-plan.md`). 같이 버려진 역조회 `Brand.get`은 코퍼스 전수 조사에서 쓰는 자리가 하나도 없어 대가가 아니었고, "포함 관계가 코드에 드러난다"는 우려는 에이전트 착오로 철회됨 | | `store-source-proxy-reversed.md` | [역전됨] 2026-08-04에 확정했던 `StoreSource` 프록시 설계(Store가 Source를 감춘 별도 프록시로 감쌈) — 2026-08-06 세 번째 세션에서 "Source가 State를 구조적으로 만족" 재구성으로 완전히 대체됨. 원문·역전 이유·신구 비교표 보존, `quadnomicon` 소재 후보 | | `ref-phase-option-reversed.md` | [역전됨] `CreatedRef`의 `phase` 옵션 — 위치 기반 순서 + `PreRef` 신설로 대체됨 | | `preref-order-unguaranteed-withdrawn.md` | **[철회됨, 2026-08-14 아홉 번째 세션 신설]** 복수 `PreRef`/`PostRef` 간 fire 순서를 "배열 index 순서 보장"에서 **미보장으로 바꾸려던 안** — 같은 세션에 제안·철회, 현재 계약은 **보장**(2026-08-07 결정 그대로). 반례는 `FastQuery(...) -> PreRef`류 조합(앞자리 항목이 뒤 항목의 전제를 만들어주는 정당한 합성), 보장 비용 0 + 배열 파트 index 순서 계약의 자동 귀결이라 새로 내주는 자유도 없음. 양쪽 논거 보존 | diff --git a/.claude/archive/always-propagate-no-dedup-superseded.md b/.claude/archive/always-propagate-no-dedup-superseded.md index 0fbe913..3bc5c50 100644 --- a/.claude/archive/always-propagate-no-dedup-superseded.md +++ b/.claude/archive/always-propagate-no-dedup-superseded.md @@ -18,7 +18,10 @@ **역전 이유**: 플래그가 아니라 에포크로 판정하면 (a) DFS 전파 도중 `Get()`이 섞인 값을 캐시하는 glitch가 없어지고, (b) 그 판정이 중복 통지까지 O(1)로 접는다. -성능이 아니라 **정확성** 동기다 — 경위는 `base/state-epoch-plan.md` §1·§3. +성능이 아니라 **정확성** 동기다 — 경위는 `base/state-epoch-plan.md` §1(실재하는 +glitch)과 §4(수신 규칙). **[2026-08-22 정정]** 여기 `§1·§3`으로 적혀 있었으나 +그 문서가 `Epoch`/`EpochMap` 승격 때 §1~§8로 재편되며 옛 §3("이게 실제로 +무엇을 고치는가")은 규칙 본문으로 흡수됐다. --- diff --git a/.claude/archive/brand-shared-registry-reversed.md b/.claude/archive/brand-shared-registry-reversed.md new file mode 100644 index 0000000..1b3efe5 --- /dev/null +++ b/.claude/archive/brand-shared-registry-reversed.md @@ -0,0 +1,89 @@ +# [역전됨] `Brand` — 공유 레지스트리 + `Brand.get(x) -> tag` (객체당 태그 하나) + +> **역전일**: 2026-08-21. **대체된 곳**: `base/brand-plan.md`(인스턴스 브랜드로 +> 전면 재작성). **역전 근거 원문**: `reference/epoch-brand-composition.md` §1의 +> 6번 / §3. +> +> **뒤집힌 것은 API 표면 하나뿐이다** — weak-key 레지스트리를 쓴다는 것, +> 태그가 문자열이 아니라 테이블 아이덴티티라는 것, duck-typing을 안 쓰는 +> 두 근거, 포함 관계를 predicate 합성으로 표현한다는 것은 **전부 그대로 +> 살아 있다**(`base/brand-plan.md`가 소스). 아래는 그 표면의 옛 모양만 +> 보존한 것. + +## 무엇이 뒤집혔나 + +**옛 모양** — 모듈 하나가 공유 레지스트리 하나를 들고, 객체마다 태그를 +**정확히 하나** 붙인다. 판별은 그 태그를 되읽어 비교한다. + +```lua +local Brand = {} +local registry = setmetatable({}, {__mode = "k"}) + +function Brand.set(x, tag) registry[x] = tag end +function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값 + +-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님 +local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag, + StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, PostRefTag, + ModifierTag = + {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} + +-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: +Brand.set(newHandle, ObserverTag) +``` + +`isX`는 그 위에 얇게 얹혔다: + +```lua +local function isSource(x) + return Brand.get(x) == SourceTag +end +local function isState(x) + return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족 +end + +local function isPreRef(x) + return Brand.get(x) == PreRefTag +end +local function isPostRef(x) + return Brand.get(x) == PostRefTag +end +local function isRef(x) + -- PreRef/PostRef가 Ref 런타임을 재사용 = 둘 다 Ref의 한 종류 + return isPreRef(x) or isPostRef(x) or Brand.get(x) == RefTag +end +``` + +`None` 처리도 이 모양에 묶여 있었다 — *"`None` 자체를 레지스트리에 +평범하게 태깅하는 건 무방(사용자가 허용) — 그러면 특수 분기 없이도 +`Brand.get(None)`이 답을 준다. 즉 '범용 introspection 창구'를 지키고 +싶으면 특수 분기가 아니라 평범한 등록으로 지킨다."* + +## 왜 뒤집혔나 + +**표현할 수 없는 관계가 실재했다.** `Epoch` 인터페이스가 도입되면서 +`Source`는 `SourceBrand`이면서 **동시에** `EpochBrand`여야 하는데, +"객체당 태그 하나" 모델로는 이걸 못 적는다. `Source`가 아닌 원천(외부 +시계 등)이 `Epoch`로 참여하려면 다중 태깅이 필수다. + +사용자 제안(2026-08-21): *"본인이 거기 속하면, 본인이 직접 해당 브랜드를 +가져와 등록하면 … `isXXXX`에서 각각의 구현을 넣을 필요가 없어짐. 따라서 +외부 확장도 쉬워진다."* + +## 같이 버려진 것 — `Brand.get`의 역조회 + +옛 모양은 "이 값이 대체 무엇인가"를 되묻는 **범용 introspection 창구**를 +겸했다. 새 모양엔 그게 없다(브랜드마다 자기 집합만 안다). + +**대가가 아니었다** — 사용자 확인: *"확실히 의미가 없어진것 같습니다 +필요하진 않아요."* 코퍼스가 실제로 쓰는 건 전부 `isX` 형태의 **멤버십 +질문**이고, 역조회를 하는 자리는 전수 조사에서 하나도 없었다. + +## 같이 검토됐다 철회된 우려 — "포함 관계가 코드에 드러난다"는 성질 + +에이전트가 "자기 등록 방식이면 `isRef`/`isState` 같은 포함 관계가 흩어진다"고 +대가로 짚었으나 **착오였고 철회됐다.** 사용자 지적: *"여전합니다. +`PreRefBrand` 가 존재할테니. 거기에 `is()` 를 해서, 코드에 전부 드러나는거 +똑같습니다."* — 자기 등록이 **여러 브랜드에 등록하기를 강제하는 게 아니므로**, +각 타입은 자기 브랜드에만 등록하고 포함 관계는 지금처럼 predicate 합성으로 +한 곳에 쓰면 된다. 2026-08-09에 세운 성질이 그대로 유지된다. diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 72c1616..e24146f 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -921,3 +921,299 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 툴링 체인(pesde types emit 등)의 지원 시점은 외부 사정이고 관측 수단이 에이전트에 없다. 그래서 질문을 닫고 `HUMAN_TODO.md` 8번(사용자가 시점을 파악하거나, 가능해질 때 에이전트에 알림)으로 옮겼다. + + +--- + +## [해소 배치 이관, 2026-08-21] `question.md`가 들고 있던 해소 항목 16건 + +**왜 옮겼나**: `question.md`는 스스로 *"항목을 해소하면 여기서 지우고 +`archive/question-resolved.md`에 근거와 함께 옮길 것 — 다시 쌓이면 같은 +문제가 반복됨"*이라고 규정해뒀는데, 2026-08-21 기준 파일의 절반이 다시 +`[해소]` 마커로 차 있었다(2026-08-21 하루에만 여러 건이 닫힌 결과). 예고된 +그대로 "필터해서 읽기 힘듦"이 재발한 것이라 이 규칙대로 일괄 이관한다. +**결정 내용은 하나도 안 바뀜 — 읽는 자리만 옮긴 것**이고, 지금 유효한 +설계는 언제나 `base/`가 소스다. + +**⚠️ 아래 항목 중 `Epoch`/`EpochMap` 관련 서술은 이관 시점 표현 그대로다** — +같은 날 `Epoch`/`EpochMap`/`Brand` 승격으로 **필드 이름이 바뀌었다** +(`sourceCountMap` → `valueEpochMap`, `sourceEmitMap` → `emitEpochMap`, +"소스"/"count" → "`Epoch`"/"리비전"). 결정 자체는 그대로이고 표현만 +바뀌었으니, 현재형 규칙은 `base/state-epoch-plan.md`를 볼 것. + +- **[해소됨, 2026-08-18] `DI` → `D`(Declarative) 확정** — 원문과 근거는 + `archive/question-resolved.md`. 요지: `DI`가 "Dependency Injection"과 + 완전히 겹쳐 실제 오해 전례가 있었고, `D`는 Instance 전용이 아닌 declare + 요소 전반으로 확장 가능하며 `D.FrameModifier`류 타입 프리픽스도 짧게 + 유지된다. 미뤄뒀던 유일한 사유(한 글자 식별자의 검색성/자기설명력)는 + "문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로 + 보완하기로 같이 확정. 코퍼스 반영 완료 — + `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절. + +- **[해소됨, 2026-08-19] `PopOnly` → `Detach` 확정** — 원문과 근거는 + `archive/question-resolved.md`. 요지: 이미 있는 + `Extract`(호출자가 직접 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데, + `Detach`는 "화면(부모 계층)에서만 떼어낼 뿐 관리 주체는 여전히 + reconcile"이라는 뜻이라 `Extract`의 "소유권을 통째로 넘긴다"와 자연스럽게 + 구분되고, `nil`(파괴)과의 대비도 더 직접적으로 드러남. 공개 표면 위치도 + 같이 확정 — `Slot`이 함수(팩토리)라 `Slot.Detach`처럼 붙이려면 + callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라 + **패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`의 + "`nil` 리턴은 파괴가 기본" 절. + +- **[해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] M2가 M3의 `Blocker.luau`에 + 구조적으로 의존하던 순서 문제 — "게이팅 먼저"로 결정.** 사용자 판정: + *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가 + 필요함."* 즉 로드맵 순서를 유지하지 않고 게이팅을 M2로 앞당긴다. **다만 + 앞당기는 대상이 `Blocker` 그 자체가 아니라 그 아래의 공용 `Gate` 노드로 + 바뀌었고**(같은 라운드 `DT-4`), 그 표면/이름이 미정이라 **새 열린 항목으로 + 이어진다 — 바로 아래 `Gate` 항목.** 아래는 해소 전 서술: 이대로 각주만 두고 + 로드맵 순서를 유지할지, + `Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로 + 앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법 + 때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가 + `getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작 + `Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직 + 없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔 + 임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전 + 필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`의 + "ROADMAP.md 마일스톤 정합성" 절. + +- **[해소, 2026-08-21 구현 전 QA 5라운드 H절] `mountInst`의 삽입 위치 + 중첩 + offset 결함 — `Dispatch.getOffsetAt(ownerKey, i)` 신설로 확정.** + `setOffsetSource(None)`은 얼리 리턴하고, 숫자가 필요한 쪽이 `getOffsetAt`을 + 직접 부른다(사용자안 — 병렬 배열을 안 늘리는 pull 방식). 베이스는 `isSlot` + 분기의 정체가 "베이스가 있나"임을 명확히 했고(베이스는 따로 저장하지 않고 + Slot의 `.Offset`을 그대로 읽는다 — 최상위 물리 inst는 항상 0), 중첩 Slot은 + 자기 `Offset`을 관측해 깊은 전파를 한다. 반영하다 **재마운트가 `Offset` Source를 새로 만들던 결함**도 + 같이 잡았다(identity 재사용). 상세는 + `qa-request/pre-implementation-qa-round5-followup.md`의 H절. + 아래는 열려 있던 시점의 서술: 둘이 한 덩어리다: + (a) `setOffsetSource`의 `None`이 "아무것도 안 차지함"과 "발행 채널 없음" + 두 뜻을 겸하고 있어 **plain 요소의 offset 숫자가 계산조차 안 된다**(그래서 + DOM류 백엔드가 삽입 위치를 알 방법이 없다 — 사용자 지적), (b) `recompute`가 + `sum = 0`에서 시작해 `ownerKey.Offset`을 안 읽으므로 **depth ≥ 2에서 중첩 + Slot의 자식 offset이 부모 베이스만큼 어긋난다**(이번에 발견, 지금까지 depth 1만 + 써서 안 드러났음). 제안은 `bk.offsetList` 신설(항상 숫자 계산) + + `mountInst(target, element, index)` + `recompute`의 `base` 시드 + 중첩 Slot이 + 자기 `Offset`을 관측해 재계산하는 구독 하나 — + `qa-request/pre-implementation-qa-round5-followup.md`의 G절이 소스. + +- **[해소, 2026-08-21] offset이 바뀌면 이미 배치된 물리 노드를 옮겨야 하는가 — + 아니오.** **사용자 확정**: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고 + 빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"* — DOM류는 삽입/삭제 + 자체가 뒤 형제를 물리적으로 밀고 당기므로, **이미 놓인 노드를 다시 옮길 일이 + 없다.** offset 숫자는 그 자리가 **다음에** insert/remove할 때 쓰는 것뿐이라는 + 기존 서술이 그대로 맞다. 이게 성립하려면 백엔드 op이 **아토믹한 최소 단위** + (`mountInst`/`unmountInst`/reposition)여야 한다는 게 같이 확인된 요구사항. + 아래는 열려 있던 시점의 서술: `mountInst(target, element, + index)`가 **삽입 시점의 위치만** 받는 일회성 호출이라, 이미 마운트된 요소의 + 물리 위치는 offset이 바뀌어도 갱신되지 않는다. Roblox는 `LayoutOrder`가 + 프로퍼티라 `updateFn`이 반응형으로 처리하면 되지만 **DOM은 물리 순서 자체가 + 배치**다. `dispatch-core-plan.md`는 "quad-web의 offset 핸들러는 no-op이고 숫자는 + *다음에* 스스로 insert/remove할 때만 쓴다"고 적어뒀는데, 앞 형제의 길이가 변해 + 뒤 형제들의 offset이 밀리는 흔한 경우에 **이미 놓인 노드를 옮길지**가 그 서술만 + 으론 안 갈린다. 선택지는 (a) 옮긴다(백엔드가 offset 변경을 관측해 재배치), + (b) 안 옮긴다(그러면 DOM에선 순서가 실제로 어긋남), (c) `:List`가 재조정 때 + 필요한 것만 명시적으로 다시 `mountInst`. quad-web이 실제로 생길 때까지 미룰 수 + 있으나, **계약을 지금 정해두지 않으면 M6 구현이 (b)를 전제로 굳는다.** + +- **[해소, 2026-08-21] `:List` 재조정의 `getOffsetAt` 비용 — 접두합 캐시로.** + **사용자 제안 채택**: *"getOffsetAt 은 compute 된걸 캐시해도 될듯. length + 변경되는 뒤는 캐시가 무효화되도록 shouldRecomputeAfter 등을 둬서 특정 인덱스 + 초과부는 offset 다시 계산하고, 해당 값 위치 자체는 유효하므로 그것을 통해 더 + length 를 이어붙이면 될듯."* → `bk.offsetCache` + **`bk.invalidAfter`**("여기까지는 유효")로 반영 + (`base/dispatch-core-plan.md`). **무효화 규칙은 하나** — + `invalidAfter = min(invalidAfter, i)`(`setLength`도 splice도 자기 인덱스까지, + 베이스 변경만 `0`). `recompute`도 이 캐시 위에 얹혀 O(N)이라 + **"캐시를 누가 채우나"라는 갈래가 없어졌다**(사용자 정정: 함수를 나눌 이유가 + 없다). 아래는 열려 있던 시점의 서술: + `settle`이 키마다 `rawAdd`/`rawReplace`를 부르고 그 각각이 `getOffsetAt`(O(i))을 + 부르므로, 이미 마운트된 리스트의 데이터가 통째로 바뀌면 **O(N²)**다(최초 마운트는 + `_mounted == false`라 얼리 리턴이 막아준다). `DC-9`에서 `setOffsetSource`의 즉시 + 계산이 O(N²)인 걸 "배치 등록 1회니 감수"로 판단했지만 **이건 매 reconcile이라 + 빈도가 다르다.** 해법은 있다 — reconcile이 `pos`처럼 **절대 offset도 러닝 + 누적**으로 들고 다니면 O(n)(그게 `mountSlotTree`가 이미 하는 방식). 그렇게 + 할지, 아니면 실측 전엔 그냥 둘지 판단 필요. + +- **[해소, 2026-08-21 같은 날] 게이트가 유보했다 내보내는 emit이 싣는 출처 — + `emit(self)` + 흡수 집합.** `GateNode`가 흡수한 소스를 `withheld` 집합에 + 들고 있다가, 풀 때(=flush) **집합을 그 자리에서 떼어내 새 테이블로 스왑하고** + 자기를 출처로 그 배치를 하류에 넘긴다. 하류는 출처가 게이트면 그 집합의 소스들에 평소 규칙을 + 적용한다. **`setup` 시그니처는 안 바뀐다** — 집합을 채우는 건 정책이 아니라 + 노드이기 때문. `base/gate-plan.md`의 4번, `base/state-epoch-plan.md` §4 + (이관 당시엔 §2였으나 그 문서가 §1~§8로 재편됨). + +- **[해소, 2026-08-21 같은 날] State 에포크 — 새 노드의 두 맵 초기값과 + `:With` 병합.** `sourceEmitMap`은 **비우고**(어떤 emit이 와도 "처음 보는 + 것"으로 걸리는 게 맞다 — 새 노드는 개념적으로 emit을 받아본 적이 없다), + `sourceCountMap`은 **전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`** + (비워두면 순회가 훑을 목록 자체가 없어 "유효하다"로 오판한다 — *"'내가 뭘 + 추적하고 있나' 가 필요하죠"*). 그래서 `:With` 병합 규칙은 **필요 없어졌다.** + 재계산 시 갱신 범위(전부 갱신)도 확정. `base/state-epoch-plan.md`의 §2·§5 7번. + +- **[해소, 2026-08-21 같은 날] `Gate` — 소스 없는 emit(빈 배치)은 **아무것도 + 안 한다**.** 에이전트 권고("빈 배치 = 무조건 통지")는 **사용자 기각** — + *"쌓아둔것 자체가 없는데 뒤로 넘긴다는건 이상합니다 … 표면적으로 보면 State + 중간에 Emit 을 추가하는 격"*. 새 규칙도 아니다: `blocker-plan.md`가 이미 + "`HasBlockedEmit`이 false면 아무 것도 안 함(idempotent)"으로 확정해뒀고, + `withheld`는 그 플래그의 일반화다. **따름정리로 `Effect(fn, ...deps)`의 설치 + 구간 억제는 `Gate` 소비자가 아니게 됐다** — `Effect` 내부 플래그로 처리하고, + `effect-plan.md`에 있던 "`Gate`보다 뒤" 순서 제약도 사라졌다. + `base/gate-plan.md`의 7·8번. + - **같이 제기됐던 "재진입 계약"은 열린 항목이 아니었다**(사용자 지적으로 + 2026-08-21 정리) — `blocker-plan.md`의 재진입은 **같은 인스턴스 중첩**을 + 말하는 것이지 정책의 `emit()` 호출과 무관하고, 끝나지 않는 되먹임은 + 2026-08-04 확정 원칙대로 **UB**이며, 유한한 재진입은 flush 진입 시 스왑으로 + 이미 안전하다. `gate-plan.md`의 6번. + +- **[해소, 2026-08-21] 공용 게이트 노드의 이름과 표면 — `state:Gate(setup)` + 메소드 + `GateNode`로 확정.** 탑레벨 프리미티브는 안 만들고, `Blocker`는 + `state:Block(blocker)` 안에서 그 배선을 쓴다(사용자: *"Gate 는 따로 + 프리미티브 없이 state:Gate( (emit) -> ()->() ) 처럼 선언되고 마치 Compute + 처럼 GateNode(ComputeNode 처럼) 생성된다"*). `Get()`엔 영향 없음(통지만 + 막음)까지 확정. **[2026-08-21 정정]** 여기 "남은 것은 사용자 판단이 아니라 + 구현 시 정할 것들"이라 적었으나, 두 번째 `/code-review high`가 빈 배치 + emit을 잠시 사용자 판단 항목으로 되돌렸다 — **같은 날 해소돼(바로 위 항목) + 원래 서술로 돌아왔다.** 구현 시 + 정하면 되는 건 생명주기와 M2 범위뿐 — `base/gate-plan.md`가 + 소스. 아래는 열려 있던 시점의 서술: 위 항목의 결정("게이팅 먼저")에 따라 base에 만들 것이 + `Blocker`가 아니라 **상류 emit을 가로채 정책이 통과 여부를 정하는 공용 게이트 + 노드**로 확정됐다(`Blocker`/`Debounce`/`Throttle`이 그 위의 정책). 사용자 + 스케치는 `Gate(function(emit) return function() ... end end)` 2단 구조이고, + **공개 API로 낸다**(사용자: *"이 API가 비공개일 이유는 없어보인다"*). 남은 것 — + (a) **이름**(사용자: *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의 + 문제"*, 에이전트 권고는 `Gate` 그대로 — `gate`는 이미 장치를 가리키는 명사), + (b) M2에 `Gate`만 넣을지 `Blocker`까지 넣을지, 그리고 생명주기 계약. + **[2026-08-21 해소]** 여기 있던 "`:Apply` 팩토리인가"와 "`Blocker`가 그 위에 + 어떻게 얹히는가"는 닫혔다 — **사용자 확정으로 `Gate`는 `:Apply`가 아니라 + `:With`류 State 메소드**(*"state 의 전파를 손대는 작업이라 with 처럼 다른 + 노드가 나는게 맞음"*)이고, 그러면 `Blocker`는 이미 확정된 + `state:Block(blocker)` 메소드가 내부에서 그걸 부르면 되므로 배선 문제 자체가 + 없어진다. 상세는 `base/gate-plan.md`. + +- **[해소, 2026-08-21] State 재계산/전파 판정을 "소스 에포크 비교"로 바꿀지 — + 채택 확정**(사용자: *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다. + 채택하면 될것 같아요."*). 규칙 전량은 `base/state-epoch-plan.md`, 구현은 M3. + 아래는 열려 있던 시점의 서술: 사용자 제안: 각 State가 자기 상류 루트 + `Source`들의 카운트를 들고 있다가 `Get()` 때 비교해 재계산 여부를 정한다. + 동기는 성능이 아니라 **정확성** — DFS 전파 도중 Observer가 `Get()`을 부르면 + 아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어가는 glitch가 지금 모델에 + 실재한다. 에이전트 분석 결과 진단·방향 모두 타당하고 선례도 있다 + (MobX/Adapton류 버전 검증). **[2026-08-21 갱신 — 여기 있던 "중복 통지는 안 + 고쳐지고 선언 안 된 의존성을 UB로 명문화해야 한다"는 서술은 같은 날 둘 다 + 뒤집혔다]**: 중복 통지도 **같은 장치로 접고**, UB 조항은 사용자 기각으로 + 빠졌다. 이어진 3·4차 정정으로 **기제는 사실상 다 정해졌다** — 순회는 + `rawInvalid`가 **false**일 때만 돌고(목적은 "못 받은 emit 받기"), emit은 + count 없이 **발행 source만** 싣고, 순회가 발견한 변경은 **테이블 둘**로 + 처분한다(`sourceCountMap`은 순회가 앞당겨 올리고, `sourceEmitMap`은 상류의 + 진짜 emit을 기다림 — *"emit 바로 안하고 상류가 emit 해줄 때 까지 + 기다립니다"*). 그래서 통지가 죽지도 게이트를 새지도 않아 `source = nil` + 규약도 필요 없어졌다. **남은 판단은 채택 여부 자체 하나**이고, State 내부 표현을 + 바꾸는 결정이라 M3 뒤로 미루면 되돌리는 비용이 크다. 상세는 + `base/state-epoch-plan.md`. + +- **[해소, 2026-08-21 구현 전 QA 5라운드 `C-4`] `Dispatch.setLength`의 Observer + 앵커 — 물리 target으로 확정(4라운드 `D-56` 역전).** `setLength`가 + `(ownerKey, i, len, anchor)`로 4번째 인자를 받아 **부기 키와 생명주기 앵커를 + 분리**한다. 그래서 `bindLifetime`은 항상 물리 Instance만 상대하고, + `isBoundAlive`의 세 번째 분기(형태 미정으로 열려 있던 것)도 **필요 자체가 + 없어졌다.** 역전 원문은 `archive/bindlifetime-slot-owner-reversed.md`, 지금 + 결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단. 아래는 + 열려 있던 시점의 서술: 그때 확정(4라운드 `D-56`)은 + "`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하라"인데, + 사용자가 그 전제 자체에 의문을 제기했다: *"애초에 Slot 이 effect 나 다른 + 요소들을 소유할 수가 없다 … 실제 observer/effect 는 실제 inst 에 불림 … + 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할 + 부분."* 대안은 **부기 키(`ownerKey`)와 생명주기 앵커(물리 `physicalTarget`)를 + 분리**하는 것 — 그러면 `bindLifetime`은 항상 Instance만 받고, + `isBoundAlive`의 세 번째 분기(지금 ⚠️ 미정)도 통째로 불필요해진다. 상세와 + 트레이싱은 `qa-request/pre-implementation-qa-round5-followup.md`. + +- **[해소, 2026-08-21] `Detach` 홀드 중 키가 사라졌을 때의 처분** — + 선택지 (c)로 확정: `updateFn`을 **`KeyGone`으로 한 번 더 불러 처분을 + 묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라 + **`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가 + 닿고 소유권도 유지됨), owner가 죽으면 `activateList`가 설치한 `Effect`가 + 정리한다. 원래 갭이 + 치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가 + **GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`의 + "Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절. + +- **[해소, 2026-08-21] `attachSlot` 책임 분해** — **(B) 분해 채택으로 확정**, + `base/slot-plan.md`에 반영 완료(`materializeSlotTree`/`mountSlotTree`/얇은 + `attachSlot`). 근거 기록은 `reference/slot-attach-decomposition.md`. + 아래는 그 열려 있던 시점의 서술: 한 + 함수가 부모 등록(offset/length) / `:List` 실체화 / 마운트 상태 전이 / 배치 + 게이팅 / 자식 배치 / 재귀를 다 지고 있어서, **"부모에게 알리는 길이의 + 최종값은 flush가 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 동시에 + 만족되지 않는다**(지금은 후자를 지키고 전자를 포기 — 부모 `recompute`가 + 1회 헛돎). 사용자 판단으로 확장 논의 대기 — 책임 목록/순서 제약 출처/분해 + 후보 넷은 `reference/slot-attach-decomposition.md`. **M6(`:List`) 착수 전 + 필요**, 선행으로 아래 `Detach` 보관 위치가 먼저 닫히는 게 나음. + +- **[해소됨, 2026-08-18] `Attribute.Merged`의 이름 중복** — `Merged`(겹치면 + error)와 `Overridden`(겹치면 뒤가 이김)을 **둘 다 제공**하는 것으로 확정 + (제3안). 근거·파급은 `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인 + array-part 값 객체" 절. + +- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** — + 같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별 + claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼 + `bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야 + 하므로), **[2026-08-20 QA 4라운드] 이름도 `groupClaimKeys`로 확정**. + **[해소, 2026-08-21 QA 5라운드 `AT-1`] 키도 `(inst, groupValue) → k`로 확정** + (사용자: *"group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 + 필요가 없고, 그건 key->name 이 유일성을 검증해준다"*), `nameClaims`보다 + **위치 claim을 먼저** 본다 — `base/attribute-plan.md`의 "이름 소유권" 절. + **이 항목은 닫혔다.** + +- **`Gate`(2순위, 2026-08-21 신설)**: `Blocker`/`Debounce`/`Throttle` 아래의 + 공용 게이트 노드 이름. 사용자 지적 — *"프리미티브 명을 Gater? 뭔가 이상하게 + 들어간다는게 약간의 문제"* — 코퍼스가 `Blocker`/`Modifier`/`Observer`처럼 + `-er`를 많이 쓰는데 `Gater`는 영어로 어색하다. 에이전트 권고는 **`Gate` + 그대로**(`gate`는 이미 행위자가 아니라 **장치**를 가리키는 명사라 `-er`가 + 불필요 — `Source`/`Ref`/`Slot`/`Tween`도 같은 계열), 대안 후보는 + `Valve`/`Relay`. **[2026-08-21 해소] `Gate`로 확정**(탑레벨 생성자를 안 + 만들고 `state:Gate(setup)` 메소드로 가면서 `Gater` 문제 자체가 사라짐 — + 메소드 자리에서는 `:With`/`:Compute`와 나란히 자연스럽다). 노드 타입 이름은 + `GateNode`. `base/gate-plan.md`의 1번이 소스. + +## [해소됨, 2026-08-21] `Epoch`의 리비전 증가 방식 — `bit32` 랩으로 확정 + +**결론**: `self.Revision = bit32.bnot(-self.Revision)`. +리비전은 uint32 안에 머문다. 정본은 `base/state-epoch-plan.md` §2. +**[2026-08-22 정정]** 이 항목을 처음 적을 때 에이전트가 형태를 +`bit32.band(rev + 1, 0xFFFFFFFF)`로 잘못 옮겼다 — 사용자가 말한 건 처음부터 +`bit32.bnot(-a)`였고(*"제가 말한건, bit32.bnot(-a) 입니다"*), 그건 랩어라운드 +**감소**를 **연산 하나로** 한다(`a > 0`이면 `a - 1`, `0`이면 `4294967295`). + +**사용자 논거**: *"그건 luau 에서 native call 이라 아주 빨라요. 반면 double 의 +연산이 느린편인데, 희소 수준이 아니라, 사실상 만나는걸 수년간 보기 어려운 +라운드되어 동일해 무시되는 경우를 막기 위해 double 까지 올려야할 이유를 +모르겠어요. 매번 도는 코드인지라, 값 싸게 native call + num 연산으로 가볍게 +가고 싶어요."* — `2^53` 포화는 어차피 도달 불가능한 시나리오인데 그걸 +피하겠다고 값을 double 영역까지 키울 이유가 없다는 것. 매 `Set`마다 도는 +hot path다. + +**에이전트가 달았다가 철회한 단서**: "`bit32`라서 더 싸다는 건 아니다 — +`n + 1`은 어느 쪽이든 double 덧셈이고 `bit32`는 fastcall을 하나 더 얹는다"고 +적었으나, **이건 `band(rev + 1, mask)`라는 다른 형태를 놓고 한 비교라 +틀렸다**(2026-08-22 정정). `bit32.bnot(-a)`는 덧셈 위에 얹히는 게 아니라 +**갱신 자체를 대체**하는 단일 FASTCALL이므로, "native call이라 아주 빠르다"는 +사용자 서술이 맞다. + +**따름정리**: 이 방식은 **단조 증가가 아니라 랩어라운드 감소**다. 지금 규칙이 +`==`/`~=`만 쓰기 때문에 무해하지만, 순서 비교를 넣고 싶어지면 이 결정부터 +되짚어야 한다. + +아래는 열려 있던 시점의 서술: + +- **`Epoch`의 리비전 필드 증가 방식(3순위, 2026-08-21 신설)**: `bit32` 랩 + (uint32 랩어라운드라 double 포화가 없음, 사용자가 염두에 둔 쪽) vs 평이한 + `+1`(할당·연산 더 쌈, 포화가 `2^53`이라 `bit32`의 `2^32` 랩보다 충돌 거리가 + 오히려 넓음). **둘 다 도달 불가능이라 실질 위험은 없고 취향 문제다.** + **이게 `Epoch`/`EpochMap` 승격 후 남은 유일한 미정 항목이다** — + `base/state-epoch-plan.md` §2, 근거 기록은 + `reference/epoch-brand-composition.md` §4의 4번. + (필드 이름 `Revision`과 타입 `Epoch`는 확정, 승격 시 `base/`에 반영됨.) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 91d5df0..6ea59d8 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -268,8 +268,8 @@ quad/ │ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`) │ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형) — Handler는 Dispatch/Attribute.luau(아래) (`base/attribute-plan.md`) │ ├── AttributeKey.luau # 단일 키 `AttributeKey<>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치) -│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계) -│ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) +│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계) +│ ├── Effect.luau # `Effect(fn, ...deps)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) │ ├── Dispatch/ │ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환) @@ -322,7 +322,7 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로 프리미티브 타입 자신의 공개 어휘**라는 것: 1. 프리미티브 타입 생성자, `Type(args)` 스타일: `Source(default)`/ `Ref(default)`/`Store({defaults})`/`Modifier()`/`Relate()`/ - `Effect(fn, state?)`/`PreRef(default)`/`PostRef(default)`. + `Effect(fn, ...deps)`/`PreRef(default)`/`PostRef(default)`. 2. 그 인스턴스의 콜론 메서드: `state:Get()`/`:With(...)`/`:Compute(fn)`/ `:Observer(fn)`/`:Apply(factory)`/`:Peek(key)`, `source:Set(v)`/`:Emit()`, `ref:Set(v)`/`:Callback(fn)`/`:Wait(thread?)`, `observer:Subscribe()`/ @@ -354,9 +354,11 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로 `isPostRef`/`isModifier`/`isObserver`/... `Brand` 절), 생명주기 게이트(`canExecute`/ `bindLifetime`, `base/lifecycle-pattern.md`), 그리고 **프리미티브가 아닌** 내부 엔진/레지스트리의 네임스페이스 멤버(`Dispatch.process`/ - `getHandler`/`addHandler`/`drive`, `Brand.set`/`get`) — 이 셋은 "타입 + `getHandler`/`addHandler`/`drive`, `Brand()`의 `:register`/`:is`) — 이 셋은 "타입 고유의 어휘"가 아니라 여러 타입에 걸쳐 쓰이거나(`isX`류) 프리미티브 - 자체가 아닌 것(Dispatch/Brand는 `Type(args)` 생성자가 없는 내부 엔진)의 + 자체가 아닌 것(Dispatch는 `Type(args)` 생성자가 없는 내부 엔진이고, + `Brand`는 생성자가 있지만 사용자 표면이 아닌 base 내부 유틸 — 사용자에게 + 노출되는 건 `isX` wrapper들이다)의 구성원이라 PascalCase 대상이 아님. Handler 계약 필드(`isHandlable`/ `priority`/`process` — 2026-08-13 다섯 번째 세션에 `retract`가 `process`의 반환값으로 합쳐지기 전엔 4종이었음)도 여기 속함 — 이건 애초에 "함수"라기보다 @@ -452,11 +454,12 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬 **emit 전파는 자기 `invalid` 상태와 무관함**. 한때 `source-state-plan.md`가 "이미 `invalid`면 전파 중단"으로 서술했으나 `Observer` 계약과 모순돼 역전됨 — `archive/invalidate-dedup-propagation-reversed.md`. **[2026-08-21 갱신]** -전파를 접는 판정은 이제 `invalid`가 아니라 **소스 에포크 비교**가 한다 — -같은 소스의 같은 에포크가 두 경로로 도착하면 두 번째는 접히고(다이아몬드에서 +전파를 접는 판정은 이제 `invalid`가 아니라 **`Epoch` 리비전 비교**가 한다 — +같은 `Epoch`의 같은 리비전이 두 경로로 도착하면 두 번째는 접히고(다이아몬드에서 값도 통지도 한 번), DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch도 같이 -사라진다. 규칙 전량은 `base/state-epoch-plan.md`, 명시적 게이트는 -`base/gate-plan.md`(`state:Gate`)와 그 위의 `Blocker`). State는 쓰기 대상이 아니고, 값을 쓰는 +사라진다. 그 부기는 재사용 가능한 **`EpochMap`**으로 떼어져 있어 State가 아닌 +소비자(`Effect`)도 같은 판정을 쓴다. 규칙 전량은 `base/state-epoch-plan.md`, +명시적 게이트는 `base/gate-plan.md`(`state:Gate`)와 그 위의 `Blocker`). State는 쓰기 대상이 아니고, 값을 쓰는 경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 — `:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07] 읽기는 `:Get()` 하나로 통일 — 프로퍼티 읽기 표기는 Ref의 `.Value` 전용으로 좁혀짐. **[표기 정정, 2026-08-18]** 여기 소문자 `.value`로 적혀 있었음). 값 하나만 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 51d3b2c..d646904 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -56,7 +56,8 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 store-bind 가능(`None`/`nil`로 disconnect) → **`base/event-plan.md`**. 단 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 아래 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절에 그대로 있음. -- **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, +- **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(**[2026-08-21 재작성]** + 인스턴스 브랜드 `Brand()` + `:register`/`:is`, 다중 태깅 허용, `isState`를 branded 타입 전부로 일반화) → **`base/brand-plan.md`**. - **`Tag` / `Attribute` 특수 키** → **`base/tag-plan.md`** / **`base/attribute-plan.md`**. 이 문서가 예전에 다루던 타입 파라미터화 문제 diff --git a/.claude/base/brand-plan.md b/.claude/base/brand-plan.md index 39da189..91f12d0 100644 --- a/.claude/base/brand-plan.md +++ b/.claude/base/brand-plan.md @@ -2,20 +2,17 @@ > **[2026-08-13 아홉 번째 세션] `bind-system-plan.md`에서 분리됨.** > 자기 완결적인 유틸이라 디스패치 코어와 같은 파일에 있을 이유가 없었음. -> **내용은 옮기기만 했고 결정은 하나도 안 바뀜.** +> +> **[2026-08-21 전면 재작성] 공유 레지스트리 + `Brand.get(x) -> tag`(객체당 +> 태그 하나)에서 **인스턴스 브랜드**(`Brand()` + `:register`/`:is`, 다중 태깅 +> 허용)로 바뀜.** 역전 원문은 `archive/brand-shared-registry-reversed.md`, +> 근거 기록은 `reference/epoch-brand-composition.md`. **뒤집힌 건 API 표면 +> 하나뿐**이고 weak-key 레지스트리·테이블 아이덴티티·duck-typing 기각 근거· +> predicate 합성은 전부 그대로다. **상태**: base — 동작/구현 방식은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). -**⚠️ [2026-08-21] 이 문서를 전면 재작성하는 제안이 `research/`에 대기 중이다** — -단일 공유 레지스트리 + `Brand.get(x) -> tag`(객체당 태그 하나)를 **인스턴스 -브랜드**(`Brand()` + `:register`/`:is`, **다중 태깅 허용**)로 바꾸는 안 -(`research/epoch-brand-composition.md`). 발단은 `Source`가 `SourceBrand`이면서 -동시에 `EpochBrand`여야 하는데 지금 구조로는 표현이 안 되는 것. **역조회 -`Brand.get`은 불필요로 확정**됐고, 아래 "포함 관계가 코드 모양에 드러난다"는 -성질은 **그대로 유지된다**(predicate 합성을 계속 쓰면 됨). **커밋된 M1 코드는 -아직 `Brand`를 안 쓰므로 전환 비용은 문서뿐.** 승격 전엔 이 문서가 정본. - ## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션) **배경**: `isState`(2026-08-07 다섯 번째 세션 확정, `:Peek<>(key): @@ -37,62 +34,111 @@ Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서 읽기 부작용도 안 만든다 — 아래 duck-typing 기각 근거 두 개가 정확히 이 한 줄에서 나온다. -**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 -사용(문자열 아님).** +## ⭐ 구현 — 인스턴스 브랜드, 브랜드마다 자기 weak 집합 하나 (2026-08-21 확정) -``` -local Brand = {} -local registry = setmetatable({}, {__mode = "k"}) +**`Brand()`가 브랜드 객체 하나를 만든다.** 그 객체가 weak-key 집합 하나를 +들고, 값은 **자기가 속한 브랜드에 스스로 등록**한다. -function Brand.set(x, tag) registry[x] = tag end -function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값 +```lua +local function Brand() + local members = setmetatable({}, {__mode = "k"}) + return { + register = function(self, x) members[x] = true end, + is = function(self, x) return members[x] == true end, + } +end --- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님 -local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag, - StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, PostRefTag, - ModifierTag = - {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} +-- 각 타입이 자기 브랜드를 하나씩 소유 +local ObserverBrand, EffectBrand, TagBrand, AttributeBrand, TweenBrand, + BlockerBrand, StateBrand, SourceBrand, StoreBrand, SlotBrand, + RefBrand, PreRefBrand, PostRefBrand, ModifierBrand, EpochBrand = + Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), + Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand() -- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: -Brand.set(newHandle, ObserverTag) +ObserverBrand:register(newHandle) ``` -**문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안)** — -Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는 -무시할 만하지만, **오타 안전성**이 실질적 이득: 태그가 오타난 문자열 -리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블 -레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이 -되어 헷갈릴 여지가 없음. - -**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — -단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위 -관계(subtype)가 있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우 -(`isState`/`isRef`)로 갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를 -"집합 멤버십"(`t == A or t == B`, 플랫한 셋 체크)으로 구현하던 방식을 -"더 구체적인 predicate를 먼저 정의하고 그 위에 얹는" 합성 방식으로 -재정리 — 동작은 동일하지만, 어느 predicate가 다른 predicate를 내포하는지 -(포함 관계의 방향)가 코드 모양 자체에 드러나게 함: +**⭐ 다중 태깅이 이 설계의 존재 이유다.** 한 값이 **여러 브랜드에 동시에** +속할 수 있다 — 실제로 그게 필요한 자리가 있다: +```lua +-- Source는 Source이면서 동시에 Epoch다 (base/state-epoch-plan.md) +SourceBrand:register(source) +EpochBrand:register(source) ``` + +옛 모양(`Brand.get(x) -> tag`, 객체당 태그 하나)으로는 이걸 표현할 수 없었고, +그게 재작성의 직접 발단이다 — `archive/brand-shared-registry-reversed.md`. + +**부수 이득 — 외부 확장이 열린다.** `Source`가 아닌 원천(외부 시계 등)이 +`Epoch`로 참여하고 싶으면 `EpochBrand:register(self)` 한 줄이면 되고, +`isEpoch` 구현을 고칠 필요가 없다. 사용자 논거: *"본인이 거기 속하면, 본인이 +직접 해당 브랜드를 가져와 등록하면 … `isXXXX`에서 각각의 구현을 넣을 필요가 +없어짐. 따라서 외부 확장도 쉬워진다."* + +**역조회는 없다** — "이 값이 대체 무엇인가"를 되묻는 창구는 제공하지 않는다. +코퍼스가 실제로 쓰는 건 전부 `isX` 형태의 **멤버십 질문**뿐이고, 역조회를 하는 +자리는 전수 조사에서 하나도 없었다(사용자 확인: *"확실히 의미가 없어진것 같습니다 +필요하진 않아요."*). + +**브랜드 아이덴티티가 곧 테이블 레퍼런스다 — 문자열 태그를 안 쓰는 이유는 +그대로 유효(사용자 제안)**: Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) +포인터 비교라 성능 차는 무시할 만하지만, **오타 안전성**이 실질적 이득 — +태그가 오타난 문자열 리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, +잘못된 브랜드 **변수**를 참조하면 즉시 드러나거나 최소한 진짜 다른 집합이 +되어 헷갈릴 여지가 없음. 새 모양에선 이게 더 강해진다 — 브랜드가 값이라 +`nil`을 인덱싱하면 그 자리에서 에러가 난다. + +**메소드 이름이 소문자인 건 이 유틸의 기존 관례를 잇는 것**(`Brand.set`/ +`Brand.get`이 그랬다). quad 공개 표면의 PascalCase 메소드 관례(`:Get`/`:Set`/ +`:With`)와 다른데, `Brand`는 사용자가 직접 부르는 프리미티브가 아니라 base +내부 유틸이고 사용자에게 노출되는 건 `isX` wrapper들이다 — 이름 자체가 +용어 정리 대기 항목이므로 케이싱도 그때 같이 본다(`question.md` 1번). + +## `isX` wrapper — 포함 관계는 predicate 합성으로 (2026-08-09 열한 번째 세션) + +**`isX`는 브랜드를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — 단순 +항등인 경우(`isObserver(x) = ObserverBrand:is(x)`)와, 상위 관계(subtype)가 +있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우(`isState`/`isRef`)로 +갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를 "집합 멤버십"(플랫한 셋 +체크)으로 구현하던 방식을 "더 구체적인 predicate를 먼저 정의하고 그 위에 +얹는" 합성 방식으로 재정리 — 동작은 동일하지만, 어느 predicate가 다른 +predicate를 내포하는지(포함 관계의 방향)가 코드 모양 자체에 드러나게 함: + +```lua local function isSource(x) - return Brand.get(x) == SourceTag + return SourceBrand:is(x) end local function isState(x) - return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족 + return isSource(x) or StateBrand:is(x) -- Source가 State를 구조적으로 만족 end local function isPreRef(x) - return Brand.get(x) == PreRefTag + return PreRefBrand:is(x) end local function isPostRef(x) -- [2026-08-14 아홉 번째 세션] PostRef 확정 - return Brand.get(x) == PostRefTag + return PostRefBrand:is(x) end local function isRef(x) -- PreRef/PostRef가 Ref 런타임을 재사용 = 둘 다 Ref의 한 종류 - return isPreRef(x) or isPostRef(x) or Brand.get(x) == RefTag + return isPreRef(x) or isPostRef(x) or RefBrand:is(x) end ``` +**⭐ 다중 태깅이 가능해져도 포함 관계는 계속 이 합성으로 쓴다.** *"`Source`를 +`StateBrand`에도 같이 등록하면 `isState`가 한 줄이 되지 않나"*는 하지 않는다 — +등록 지점이 여러 곳에 흩어지면 "어느 브랜드에 등록하는 걸 빠뜨렸나"가 조용한 +버그가 되고, 포함 관계가 코드 모양에서 사라진다. **각 타입은 자기 브랜드에만 +등록하고, 포함 관계는 predicate 한 곳에 쓴다.** 사용자 확인: *"여전합니다. +`PreRefBrand` 가 존재할테니. 거기에 `is()` 를 해서, 코드에 전부 드러나는거 +똑같습니다."* + +- **예외는 "구조적 인터페이스"뿐** — `Source`를 `EpochBrand`에 같이 등록하는 + 건 포함 관계가 아니라 **서로 다른 축의 계약을 동시에 만족**하는 것이라 + 합성으로 표현할 수가 없다(`isEpoch`가 `isSource`를 알아야 할 이유가 없고, + `Source`가 아닌 원천도 참여해야 한다). 이게 다중 등록의 정당한 용례다. + **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만 염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을 @@ -104,7 +150,7 @@ end 문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "`isState(x): boolean` 필요" 절에 있던 "별도 `isSource` 불필요" 서술은 `session/2026-08-07-08-none-sentinel-dispatch-brand.md`에서 이미 정정됨. -**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 +**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 목록에서 빠져있던 것 추가(2026-08-07 열 번째 세션), 이후 `isRef`/`isPreRef` 관계 자체가 재정정됨(2026-08-09 열한 번째 세션).** 처음엔 `isRef`/`isPreRef`를 `isObserver`와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로 @@ -113,9 +159,9 @@ end **`PreRef`도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함 방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적**이라는 지적으로 뒤집힘. -- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`Brand.get(x) == - PreRefTag`), **`isRef(x)`는 그 위에 `Brand.get(x)==RefTag`를 OR로 - 얹은 상위 개념** — 즉 이제 **`isRef(preRefInstance)`는 `true`.** +- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`PreRefBrand:is(x)`), + **`isRef(x)`는 그 위에 `RefBrand:is(x)`를 OR로 얹은 상위 개념** — 즉 + **`isRef(preRefInstance)`는 `true`.** - **`(v=Ref)` children 배열 leaf 매치 핸들러(`Dispatch/Leaf.luau`)는 이제 `isHandlable`을 `isRef(v) and not isPreRef(v) and not isPostRef(v)`로 명시적으로 좁혀야 함**(**[2026-08-14 아홉 번째 세션]** `PostRef` 확정으로 @@ -124,41 +170,39 @@ end 명시적으로 말해야 하는 모양으로 바뀜(두 pre-pass 소진이 이미 걸러줘 정상 경로에선 거의 안 걸리지만, `base/ref-plan.md`의 두 동적 경로 가드 Handler와 이 조합이 같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장). - `isModifier`도 같은 단순 항등(`Brand.get(x) == ModifierTag`, 상위 개념 - 없음). + `isModifier`도 같은 단순 항등(`ModifierBrand:is(x)`, 상위 개념 없음). - **`PostRef`도 `PreRef`와 완전히 같은 포함 방향** — `Ref` 런타임을 그대로 - 재사용하고 브랜드 태그만 다르므로 `isRef(postRefInstance)`도 `true`. + 재사용하고 브랜드만 다르므로 `isRef(postRefInstance)`도 `true`. 즉 `isRef`는 이제 `{Ref, PreRef, PostRef}` 셋을 통과시키는 상위 개념이고, `isPreRef`/`isPostRef`가 각각 가장 구체적인 항등 — `PreRef`/`PostRef` 사이엔 포함 관계가 없음(서로 배타적인 형제). **같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** — -`Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등 -predicate, 태그 자체는 원래부터 목록에 있었지만(`SlotTag`) `isX` -wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의 +`SlotBrand:is(x)`/`EffectBrand:is(x)`인 단순 항등 predicate, 브랜드 +자체는 원래부터 목록에 있었지만 `isX` wrapper로 명시적으로 안 적혀 있던 +것을 `base/modifier-plan.md`의 "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 들어오면 즉시 error" 절이 필요로 해서 이번에 같이 적음. **[정정, 2026-08-18 구현 전 QA] `Brand`는 아무 의존성도 갖지 않는다 — -`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 `Brand.get(x)`가 범용 -introspection 창구 역할까지 겸하려면 `None`도 빠지면 안 되므로 *"`Brand.get`이 -내부적으로 `x == None`을 먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에 -레지스트리 조회로 폴백하며, `isNone`이 그 특수 분기의 구현체가 된다고 했다. -사용자 판정: *"Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에 -의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v == -None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일."* +`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 판별 창구가 범용 +introspection을 겸하려면 `None`도 빠지면 안 되므로 *"내부적으로 `x == None`을 +먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에 레지스트리 조회로 폴백하며, +`isNone`이 그 특수 분기의 구현체가 된다고 했다. 사용자 판정: *"Brand 는 None 을 +참조할 필요는 없음. Brand 자체는 아에 의존성 없고, None 도 테깅되는건 맞으나, +isNone 대신 필요한 곳에서 v == None 하면 되는 일, 혹은 isNone 구현 자체를 +그렇게 해주면 되는 일."* - **`Brand → None` 의존을 만들지 않는다** — 특수 분기를 넣는 순간 가장 밑바닥 유틸이어야 할 `Brand`가 다른 프리미티브를 참조하게 된다. - **`isNone`은 그냥 `v == None`** — 그런 이름의 함수를 두더라도 구현이 레퍼런스 비교 한 줄이면 된다. 싱글턴이라 그게 제일 싸고 정확하다는 판단 자체는 그대로 유효. -- **`None` 자체를 레지스트리에 평범하게 태깅하는 건 무방**(사용자가 - 허용) — 그러면 특수 분기 없이도 `Brand.get(None)`이 답을 준다. 즉 - "범용 introspection 창구"를 지키고 싶으면 **특수 분기가 아니라 평범한 - 등록**으로 지킨다. 등록을 안 하기로 하면 `None`은 그 창구에서 빠지는 - 것을 받아들인다 — 어느 쪽이든 `Brand` 쪽 코드는 그대로다. +- **`None`을 `NoneBrand`에 평범하게 등록하는 것 자체는 무방**(사용자가 + 허용) — 다만 **[2026-08-21]** 역조회 창구가 없어졌으므로 등록의 유일한 + 효용은 `NoneBrand:is(x)`뿐이고, 그건 `v == None`이 더 싸다. 어느 쪽이든 + `Brand` 쪽 코드는 그대로다. **duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는 이유 — 서로 독립된 두 가지(2026-08-20 `B-4`에서 분리 명시)**: @@ -173,11 +217,13 @@ None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되 `isHandlable` 계약(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)과 정면으로 부딪힌다. 최악의 경우 엔진이 죽는 상황까지 있다. -weak-key 레지스트리 조회는 포인터 해싱 한 번이라 `pcall`도, 오인도 없다. +weak-key 조회는 포인터 해싱 한 번이라 `pcall`도, 오인도 없다. 브랜드마다 +집합이 갈려도 **조회 비용은 그대로 한 번**이고, `isState`처럼 OR로 합성된 +predicate만 최대 브랜드 수만큼 조회한다(전부 2~3개). weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과 같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이 -GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 -레지스트리인 Observer의 `:Subscribe` 레지스트리와는 반대 성격). +GC되면 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 레지스트리인 +Observer의 `:Subscribe` 레지스트리와는 반대 성격). **Luau 타입 narrowing은 자동으로 안 됨 — 명시적 `::` 캐스팅 필요(사용자 확인, Luau가 원래 그렇게 동작함).** `isX(v)`가 참이어도 Luau 컴파일러가 @@ -187,6 +233,10 @@ State ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만 "자동 narrowing"을 기대하면 안 됨. -**이름은 전부 가칭 — `Brand`/`ObserverTag`류 포함 용어 정리 대상, -`.claude/question.md`에 반영.** +**마일스톤**: **커밋된 M1 코드는 아직 `Brand`를 안 쓴다**(`quad-base/src`는 +`init.luau`/`Relate.luau`/`Debug`뿐, 2026-08-21 확인) — 이 재작성의 전환 +비용은 문서뿐이었다. 실제 구현은 각 프리미티브가 만들어지는 마일스톤에서 +같이 간다. +**이름은 전부 가칭 — `Brand`/`ObserverBrand`류 포함 용어 정리 대상, +`.claude/question.md`에 반영.** diff --git a/.claude/base/debounce-throttle-plan.md b/.claude/base/debounce-throttle-plan.md index 953a397..c3b6fc6 100644 --- a/.claude/base/debounce-throttle-plan.md +++ b/.claude/base/debounce-throttle-plan.md @@ -294,8 +294,8 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정" ### 그래서 정정 후 그림 (권고) - **emit(무효화 신호)은 구독자에게 전파된다.** State가 이미 `invalid`인지는 - 전파 여부와 무관. **[2026-08-21 갱신]** "항상"은 빠졌다 — 소스 에포크 - 비교를 채택하면서 **같은 에포크가 두 번째로 도착하면 접힌다** + 전파 여부와 무관. **[2026-08-21 갱신]** "항상"은 빠졌다 — `Epoch` 리비전 + 비교를 채택하면서 **같은 `Epoch`의 같은 리비전이 두 번째로 도착하면 접힌다** (`base/state-epoch-plan.md`). `invalid`로 접는 것이 금지인 건 그대로. - 다이아몬드 중복 *재계산*은 **pull-recompute가 이미 구조적으로** 막음(`architecture.md`의 서술이 맞음) — 별도 장치 불필요. @@ -330,8 +330,8 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정" > 위로 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음 > `invalid`가 꺼짐. -**[2026-08-21 후속]** 위는 2026-08-14 시점 확정 원문이다. 그 뒤 소스 에포크 -비교를 채택하면서 "항상"에 예외가 하나 생겼다 — **같은 소스의 같은 에포크가 +**[2026-08-21 후속]** 위는 2026-08-14 시점 확정 원문이다. 그 뒤 `Epoch` 리비전 +비교를 채택하면서 "항상"에 예외가 하나 생겼다 — **같은 `Epoch`의 같은 리비전이 두 번째로 도착하면 접힌다**(`base/state-epoch-plan.md`). `invalid`로 접는 것이 금지라는 이 문단의 요지는 그대로다. diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 6668a05..f2b9c2e 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -266,21 +266,41 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect 성립하지 않는 게 확인됐다(`base/gate-plan.md`의 8번) — 설치 구간엔 어떤 `Set`도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가 없다. 설치 중 발화를 누르는 플래그 하나면 되고 새 메커니즘이 필요 없다. -- **⚠️ [2026-08-21 신설 — 미해결] 의존성들이 공통 상류를 공유하면 한 파동에 - `fn`이 여러 번 돈다.** `A → b`, `A → c`, `Effect(fn, b, c)`에서 `A:Set()` - 한 번에 `b`가 자기 observer를, `c`가 자기 observer를 **각각 정당하게** 깨워 - `fn`이 두 번 돈다. **소스 에포크 dedup으로는 안 접힌다** — `b`와 `c`는 서로 - 다른 노드라 접어줄 **공통 하류가 없고**, 둘 다 규칙 1에 정당하게 걸린다 - (`base/state-epoch-plan.md`). 위의 "설치 구간 억제"도 이건 안 덮는다(그건 - 등록 시점만). - - **해법 후보**: `Effect`가 **자기 `EpochMap`을 하나 들고** 각 내부 observer가 - 그걸 `Update`해서 첫 번째만 통과시키는 것 — - `research/epoch-brand-composition.md`가 정확히 이걸 노린 제안이고 - 승격 대기 중이다. (검토했다 접은 대안: deps를 하나의 파생 노드로 수렴시켜 - 다이아몬드 dedup에 태우기 — 노드가 늘고 "N deps → N observers" 구조를 - 바꿔야 해서 위 안보다 못하다.) - - **그 제안을 안 쓴다면** `useEffect`처럼 "N번 돌아도 무방"으로 계약을 - 명시하는 선택지도 있다. **어느 쪽이든 M3 구현 전에 정할 것.** +- **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만 + 돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`, + `A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를, + `c`가 자기 observer를 **각각 정당하게** 깨워 `fn`이 두 번 돌았다. State 층 + dedup으론 안 접힌다 — `b`와 `c`는 서로 다른 노드라 접어줄 **공통 하류가 + 없고**, 둘 다 §4의 1번 규칙에 정당하게 걸린다(`base/state-epoch-plan.md`). + 위의 "설치 구간 억제"도 이건 안 덮는다(그건 등록 시점만). + - **확정된 해법**: `EffectHandle`이 `EpochMap`을 **하나** 들고, 각 내부 + Observer의 클로저가 받은 `from`으로 그걸 `Update`한다. **`true`일 때만 + `fn`을 부른다.** `Effect`가 곧 그 dep들의 **공통 하류**가 되므로, 한 + 파동에 몇 개가 깨우든 첫 번째만 통과한다. + ```lua + -- 각 dep의 내부 Observer가 공통으로 거는 클로저 + function(self, from) + if handle._installing then return end -- 설치 구간 억제 (아래) + if handle._epochs:Update(from) then + handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행 + end + end + ``` + **⚠️ 억제 플래그가 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회 + 실행에는 `from`이 없어서(`nil`, `base/source-state-plan.md`의 + "`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면 + 설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다 + (2026-08-21 커밋 전 `/code-review high` 발견). + - **`Ref` 의존성은 이 맵에 안 낀다** — `Ref`는 `Epoch`가 아니고 + `:Callback`으로 발화하므로 `from`이 없다. `Ref` 쪽 발화는 그대로 매번 + `fn`을 돌린다(`Ref`는 반복 재설정마다 도는 게 계약이고, 공통 상류 문제 + 자체가 없다). + - **검토했다 접은 대안**: deps를 하나의 파생 노드로 수렴시켜 다이아몬드 + dedup에 태우기 — 노드가 늘고 "N deps → N observers" 구조를 바꿔야 해서 + 위 안보다 못하다. `useEffect`처럼 "N번 돌아도 무방"으로 계약을 느슨하게 + 두는 선택지도 있었으나, 접는 비용이 맵 하나뿐이라 채택 안 함. + - 근거 기록은 `reference/epoch-brand-composition.md`(이 갭이 `EpochMap` + 분리의 직접 발단이었다). - **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도 N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를 같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피 diff --git a/.claude/base/gate-plan.md b/.claude/base/gate-plan.md index 50ab254..4dbca3a 100644 --- a/.claude/base/gate-plan.md +++ b/.claude/base/gate-plan.md @@ -16,11 +16,13 @@ GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부 표면을 주기도 하구요."* 아래 본문 중 "공개 프리미티브로 꺼낸다"류 서술은 그 이전 시점 표현이니 이 배너 기준으로 읽을 것. -**⚠️ [2026-08-21] emit 페이로드 표현을 바꾸는 제안이 `research/`에 대기 중** -(`research/epoch-brand-composition.md`) — 하류가 게이트 identity를 한 번도 안 -쓰므로 페이로드를 `Epoch|{Epoch}`로 일반화하는 안. 아래 4번의 **기제(흡수 집합, -flush 시 스왑, 게이트-게이트 unfold, 빈 배치 무통지)는 그대로 유효**하고 넘기는 -값의 타입만 바뀐다. 승격 전엔 이 문서가 정본. +**⚠️ [2026-08-21 반영 완료] emit 페이로드는 `Epoch | EpochSet`이다** +(`EpochSet = { [Epoch]: true }` — **배열이 아니라 집합**, +`base/state-epoch-plan.md` §3) — 하류가 +게이트 identity를 한 번도 안 쓰므로 게이트 노드 자체는 안 싣고 **떼어낸 +`Epoch` 집합 스냅샷**만 넘긴다(근거 기록은 +`reference/epoch-brand-composition.md`). 아래 4번의 기제(흡수 집합, flush 시 +스왑, 게이트-게이트 unfold, 빈 배치 무통지)는 그대로다. **한 줄**: `Blocker`가 쓰던 "게이티드 State 노드"를 한 겹 일반화해서, **상류 emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 **`state:Gate(setup)` @@ -119,29 +121,29 @@ end) `Debounce`/`Throttle`도 emit-gate로 확정돼 있어(`base/debounce-throttle-plan.md` §4) `Gate`도 같은 계약으로 통일한다. 공개 계약 문구: **"게이트를 통과하지 않은 값도 `:Get()`으로는 보인다."** - `base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서 §5의 3번이 - "게이트를 에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 - 뒤집지 않기 위해서다. + `base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서가 "게이트를 + 에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 뒤집지 않기 + 위해서다 — 그 문서 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목. 4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — `emit(self)` + 흡수 집합.** 문제는 실재했다: 확정 `setup`은 `(emit: () -> ()) -> (() -> ())`라 - 양쪽 다 source를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부 - `[source]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무 + 양쪽 다 출처를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부 + `[epoch]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무 원천도 못 지목한 채 도착해 **하류에서 삼켜진다**(2026-08-21 `/code-review high` 발견). **확정 기제**(사용자 안, 에이전트가 냈던 (a) `nil` 전체 확인 / (b) 소스마다 개별 emit은 기각 — 각각 O(1) 판정을 깨거나 `Blocker`의 "정확히 1회"를 깬다): - - `GateNode`가 **자기를 거쳐간 소스 집합**을 들고 있는다 — - `withheld : { [source] : true }`, **weak key**(소스 맵과 같은 이유, - `state-epoch-plan.md` §5의 5번). + - `GateNode`가 **자기를 거쳐간 `Epoch` 집합**을 들고 있는다 — + `withheld : { [epoch] : true }`, **weak key**(`EpochMap`과 같은 이유, + `state-epoch-plan.md` §3). - **⭐ [2026-08-21 단순화] 통과와 유보를 구분하지 않는다.** 상류 emit이 오면 **정책을 실행하기 전에 무조건** 그 출처를 `withheld`에 넣고, 그 다음 정책을 실행한다. 정책이 `emit()`을 부르면 게이트가 하류에 전파하고, 안 부르면 집합에 그대로 쌓인다. - **⚠️ [2026-08-21 `/code-review high`] "무조건"은 *정책의 통과/유보와 무관하게*라는 뜻이지 *수신 규칙을 건너뛴다*는 뜻이 아니다.** 게이트도 - 평범한 노드처럼 `state-epoch-plan.md` §2의 규칙 1~3을 **먼저** 적용하고, + 평범한 노드처럼 `state-epoch-plan.md` §4의 규칙 1~3을 **먼저** 적용하고, 3번(둘 다 같음)으로 삼켜진 emit은 **정책도 안 돌고 집합에도 안 들어간다.** 안 그러면 다이아몬드(`A→B→G`, `A→C→G`)에서 `A:Set()` 한 번에 정책이 두 번 돌아, `Throttle`의 leading 통과 직후 두 번째 emit이 @@ -166,17 +168,17 @@ end) emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다 ``` 하류가 순회하는 것은 `gate._withheld`가 아니라 **받은 `batch`** 다. - 중첩 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다. 같은 에포크가 - 중첩으로 두 번 도달하는 경우는 애초에 문제가 아니다 — 하류 count가 이미 + 중첩 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다. 같은 리비전이 + 중첩으로 두 번 도달하는 경우는 애초에 문제가 아니다 — 하류 맵이 이미 최신이라 규칙 3으로 삼켜진다. - **그래서 게이트는 언제나 자기(와 그 배치)를 출처로 낸다** — 그냥 통과시킬 때도 상류 출처를 그대로 넘기지 않는다. 사용자: *"후행 노드들은 한개가 지연된거로 생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가 없습니다."* 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정 규칙은 완전히 같다. - - 하류가 **평범한 노드**면, 출처가 `GateNode`일 때 그 집합을 순회하며 각 - 소스에 평소 규칙(1~3)을 적용하고, 하나라도 걸리면 **받은 출처를 그대로** - 더 아래로 넘긴다(`state-epoch-plan.md` §2). + - 하류가 **평범한 노드**면 그냥 `EpochMap:Update(batch)`가 집합을 순회하고, + 하나라도 걸리면 **받은 배치를 그대로** 더 아래로 넘긴다 + (`state-epoch-plan.md` §4 — 단일이든 집합이든 같은 규칙이다). - **⭐ [2026-08-21 신설] 하류가 또 다른 게이트면 — 받은 집합을 풀어 자기 `withheld`에 합친다.** 게이트가 게이트 emit을 받는 경우가 정의돼 있지 않던 구멍이었다(사용자 발견). **출처를 그대로 넘기면 안 된다** — @@ -184,27 +186,29 @@ end) 하류 게이트가 그 참조만 들고 유보했다가 나중에 풀면 이미 지나간 배치를 내보내게 된다. 그래서 수신 시점에 **풀어서 옮겨 담아야** 한다: ``` - -- 출처가 Source면 그 하나를, 게이트 배치면 그 배치 전부를 편다 - for source in unfold(origin) do - self._withheld[source] = true + -- 출처가 Epoch 하나면 그 하나를, 배치면 그 배치 전부를 편다 + for epoch in unfold(from) do + self._withheld[epoch] = true end -- 그 다음 평소대로 정책 실행 ``` 게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이 먼저 풀리든 정보가 안 샌다. - - **게이트의 `sourceEmitMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다 - (집합 전체에 대해 한꺼번에). 그래야 "내가 하류로 던진 에포크"라는 맵의 - 뜻이 게이트에서도 참이 된다 — 유보 중 같은 에포크가 다른 경로로 또 오면 - 규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에 있으므로 무해하다. + - **게이트의 `emitEpochMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다 + (집합 전체에 대해 한꺼번에, `:Sync(batch)`). **이건 `state-epoch-plan.md` + §4 의사코드의 유일한 예외이고, 그 문서에 예외로 기록돼 있다.** 그래야 "내가 하류로 던진 + 리비전"이라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중 같은 리비전이 + 다른 경로로 또 오면 규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에 + 있으므로 무해하다. - **⭐ [2026-08-21 `/code-review high`] emit 없이 푸는 경로는 집합을 *버려야* 한다.** `blocker:OffWithoutEmit()`은 정의상 "밀린 전파를 버리며 끈다"(`base/blocker-plan.md`)이므로, 그 경로도 **`withheld`를 비운다** (전파는 안 하고 새 테이블로 스왑). 안 그러면 `Dispatch.drive`의 배치 게이팅이 매 프레임 `On()` → … → `OffWithoutEmit()`을 도는 동안 집합이 - **단조 증가**하고, 나중에 아무 소스나 한 번 통과하는 순간 **버리기로 했던 - 옛 소스들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다. - - 그렇게 비우고 나면 하류의 `sourceEmitMap`은 뒤에 남지만, 그 소스의 다음 - 진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요. + **단조 증가**하고, 나중에 아무 `Epoch`나 한 번 통과하는 순간 **버리기로 + 했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다. + - 그렇게 비우고 나면 하류의 `emitEpochMap`은 뒤에 남지만, 그 `Epoch`의 + 다음 진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요. **⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라 **노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화). diff --git a/.claude/base/lifecycle-hooks-plan.md b/.claude/base/lifecycle-hooks-plan.md index 898c79f..a2871d0 100644 --- a/.claude/base/lifecycle-hooks-plan.md +++ b/.claude/base/lifecycle-hooks-plan.md @@ -134,8 +134,8 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관 ### `OnDestroyed(fn)` -`Effect(function() return fn end)`를 반환하는 팩토리. `Effect(fn, state?)`가 -`state` 생략 시 "설치 시 즉시 1회 실행 + 반환값이 leaf 사망 시 정확히 +`Effect(function() return fn end)`를 반환하는 팩토리. `Effect(fn, ...deps)`가 +**deps 생략 시** "설치 시 즉시 1회 실행 + 반환값이 leaf 사망 시 정확히 1회 호출되는 cleanup"이라는 기존 계약(`base/effect-plan.md` 28행)을 그대로 재사용 — 다만 여기서는 **설치 단계에서 실행되는 함수가 `fn` 자신이 아니라 `function() return fn end`라는 래퍼**라는 점에 주의. diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 5d6930c..7979d06 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -1955,7 +1955,7 @@ nil/None 금지)는 그대로. > 않는다**는 것이었다(`setLength` 슬롯이 하나뿐이라 원리적으로 불가능). > **결론: `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해하고 > `attachSlot`은 그 둘을 부르는 두 줄짜리 래퍼로 남긴다** — 이름/시그니처/ -> 호출부 전부 불변. 근거 기록은 `research/slot-attach-decomposition.md`. +> 호출부 전부 불변. 근거 기록은 `reference/slot-attach-decomposition.md`. **✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는 `Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할 @@ -2006,7 +2006,7 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 -- [전면 재작성, 2026-08-21 구현 전 QA 4라운드 확정] 옛 단일 `attachSlot`을 -- **비공개 재귀 둘 + 얇은 공개 진입점**으로 분해. 공개 표면(이름/시그니처/ -- 호출부 셋)은 하나도 안 바뀐다 — 쪼갠 건 함수가 아니라 **재귀**다. --- 근거와 대안 비교는 `research/slot-attach-decomposition.md`. +-- 근거와 대안 비교는 `reference/slot-attach-decomposition.md`. -- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다. local function materializeSlotTree(slot, physicalTarget, ownerKey, position) @@ -2086,7 +2086,7 @@ end -- 아래 `acc`가 `slot.Offset:Get()`에서 시작하는데, 그 값이 최종값이 되는 건 -- materialize의 마지막 `recompute`가 끝난 뒤다. 순서를 뒤집거나 materialize를 -- 건너뛰고 부르면 물리 삽입 위치가 조용히 어긋난다(공개 `attachSlot`이 둘을 --- 붙여 부르는 것이 이 계약의 전부 — `research/slot-attach-decomposition.md`의 +-- 붙여 부르는 것이 이 계약의 전부 — `reference/slot-attach-decomposition.md`의 -- "prepare만 하고 mount 안 한 중간 상태" 항목이 아직 열려 있는 이유이기도 하다). local function mountSlotTree(slot, physicalTarget) slot._mounted = true @@ -2117,7 +2117,7 @@ end ``` **왜 쪼갰나 — 이득 넷**(상세와 기각된 대안은 -`research/slot-attach-decomposition.md`): +`reference/slot-attach-decomposition.md`): 1. **C6와 C7이 처음으로 동시에 만족된다.** 옛 코드는 `setLength`의 자리가 하나뿐이라 "최종값으로 등록"(C6)과 "부기가 물리보다 먼저"(C7) 중 하나를 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index beb696c..256e64a 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -94,6 +94,18 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴. 불필요 — Source 객체 자체가 저장소 역할을 함. 이 모델은 이전에 검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/ 캐싱 단계 자체가 사라짐). +- **⭐ [2026-08-21 확정] `Source`는 `Epoch` 인터페이스도 같은 방식으로 + 구조적으로 만족한다** — `type Epoch = { Revision: number }`이고, `:Set()`/ + `:Emit()`이 그 `Revision`을 **갱신한다**(직전과 다른 값으로 — `Epoch` + 계약이 요구하는 건 "다르다"뿐이라 방향은 계약이 아니고, 확정된 연산 + `bit32.bnot(-rev)`는 실제로 **감소**한다). **`Revision`은 공개 필드여야 + 한다** — 비공개면 구조적 만족이 타입 레벨에서 성립하지 않는다(사용자: + *"그래야 타입 상 `Source` 가 `Epoch`를 만족해요."*). `Store`와 달리 + `Source`는 키가 사용자 것이 아니라 예약 이름이 늘어도 충돌하지 않는다. + 런타임 판별은 `SourceBrand`와 **동시에** `EpochBrand`에 등록하는 것으로 + 하고(다중 태깅, `base/brand-plan.md`), `State`를 만족하는 관계와 달리 이건 + 포함 관계가 아니라 **다른 축의 계약**이라 predicate 합성으로는 표현되지 + 않는다. 계약 전량은 `base/state-epoch-plan.md` §2가 소스. - **이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 OOP 상속과는 다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 @@ -180,14 +192,6 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa 왜 프리미티브가 아니라 탑레벨 싱글톤인지는 `base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절 참고. -> **⚠️ [2026-08-21] 이 문서의 두 자리를 바꾸는 제안이 `research/`에 대기 중** -> (`research/epoch-brand-composition.md`, 승격만 남음) — (1) `Source`가 -> **`Epoch` 인터페이스**(`{Revision: number}`)를 구조적으로 만족한다는 서술 -> 추가(`State`를 만족하는 기존 패턴과 같은 모양, 리비전 필드는 **공개**여야 -> 타입 레벨에서 성립), (2) 아래 "`state:Observer(fn)`" 절의 클로저가 -> `:Compute`와 같은 모양인 **`fn(self, from: Epoch|{Epoch})`**로 인자를 받음. -> 값은 여전히 안 실어주므로 그 계약은 안 깨진다. - ## 전파 모델 확정: push-invalidate(신호만) / pull-recompute(`Get()` 시점에만) **Fusion식 eager 노드·생성순 정렬은 안 만듦.** @@ -195,11 +199,12 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa - `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만 쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구). -- **⭐ [2026-08-21 재확정 — 판정 주체가 `invalid`에서 소스 에포크로 바뀜] +- **⭐ [2026-08-21 재확정 — 판정 주체가 `invalid`에서 `Epoch` 리비전으로 바뀜] emit은 자기 `invalid` 상태와 **무관하게** 전파되고, 접히는 것은 오직 - **같은 소스의 같은 에포크가 두 번째로 도착했을 때**뿐이다.** 판정 규칙 + **같은 `Epoch`의 같은 리비전이 두 번째로 도착했을 때**뿐이다.** 판정 규칙 전량은 **`base/state-epoch-plan.md`가 소스** — 여기선 이 절의 다른 서술과 - 어긋나지 않게 요지만 적는다. + 어긋나지 않게 요지만 적는다. 여기 있던 "emit은 *항상* 전파된다"는 무조건 + 서술의 역전 원문은 `archive/always-propagate-no-dedup-superseded.md`. - **`invalid`(구현 이름 `rawInvalid`) 플래그의 역할은 "내 캐시가 낡았다"는 표시 하나뿐** — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 상류로 올라가 재계산하고, 그 결과를 캐시에 넣고, 플래그를 끈다. @@ -207,15 +212,13 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa 방식이 폐기된 이유는 `:Get()`을 호출하지 않는 `Observer`(아래 "`state:Observer(fn)`" 절이 명시적으로 허용하는 사용법)가 **한 번 울고 영구히 침묵**하기 때문이고, 그 실패 모드는 지금도 유효하다(원문은 - `archive/invalidate-dedup-propagation-reversed.md`). 에포크 비교엔 그 모드가 - 없다 — 매 `Set`이 **새 에포크**라 항상 통과하고, 접히는 건 **다이아몬드에서 - 같은 에포크가 두 경로로 도착한 두 번째**뿐이다. + `archive/invalidate-dedup-propagation-reversed.md`). 리비전 비교엔 그 + 모드가 없다 — 매 `Set`이 **새 리비전**이라 항상 통과하고, 접히는 건 + **다이아몬드에서 같은 리비전이 두 경로로 도착한 두 번째**뿐이다. - **emit 전파를 늦추거나 흡수할 수 있는 건 위 dedup 외엔 명시적인 게이트 요소뿐** — `state:Gate(setup)`(`base/gate-plan.md`)와 그 위에 얹히는 `Blocker`(`base/blocker-plan.md`), `base/debounce-throttle-plan.md`의 시간 기반 정책. **평범한 State는 그 외의 이유로 신호를 삼키지 않는다.** - - **[2026-08-21] 여기 있던 "emit은 *항상* 전파된다"는 무조건 서술은 - 역전됐다** — 원문은 `archive/always-propagate-no-dedup-superseded.md`. - 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 — "필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/ 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 @@ -250,7 +253,7 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa **⭐ [2026-08-21 역전] 중복 *통지*도 이제 접힌다.** 여기엔 원래 "quad가 추가로 접지 않는 것은 중복 통지뿐이고, `d` 아래 `Observer`가 한 사이클에 두 번 우는 것은 의도된 동작"이라고 적혀 있었으나, `base/state-epoch-plan.md` 채택으로 -**두 번째 신호는 삼켜진다** — 그 신호가 나르는 에포크를 `d`가 이미 봤기 +**두 번째 신호는 삼켜진다** — 그 신호가 나르는 리비전을 `d`가 이미 봤기 때문이다. 그래서 위 1단계는 "`d`가 두 번 받는다"가 아니라 **"두 번째는 `d`에서 멈춘다"**가 된다. 역전 원문과 이게 2026-08-14 역전을 되돌린 게 아닌 이유는 `archive/always-propagate-no-dedup-superseded.md`. @@ -258,7 +261,7 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa **부수로 같이 고쳐진 것 — 섞인 값(glitch).** 옛 모델에선 전파가 DFS라 `b` 가지가 먼저 끝까지 내려가고, 그 아래 Observer가 `d:Get()`을 부르면 `c`는 아직 신호를 못 받아 **옛 캐시를 반환**해 `d`가 `(b_new, c_old)`를 캐시했다. -에포크 비교는 `c`가 신호 없이도 스스로 낡음을 알아채므로 이 창이 없다 — +리비전 비교는 `c`가 신호 없이도 스스로 낡음을 알아채므로 이 창이 없다 — 상세와 재현 시나리오는 `base/state-epoch-plan.md` §1. **전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)** @@ -929,6 +932,26 @@ retract/Destroy되면 자동으로 정리됨. 두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를 따로 안 짜도 됨). `state:Observer()`(인자 없는 "항상 관측" 유틸)도 이 규칙을 그대로 따름 — 호출 즉시 한 번 관측이 트리거됨. +- **⭐ [2026-08-21 확정] `fn`의 시그니처는 + `fn(self, from: (Epoch | EpochSet)?)` — `:Compute`와 같은 모양이다.** 사용자: *"Compute 와 유사하게 나올 수 + 있다 봐요. self 를 넘겨주고, 그 뒤에 epoch|{epoch} 를 주는게 맞아보입니다."* + 위 "`:With`/`:Compute` — self 인자도 lazy 핸들로 통일" 절과 같은 결이다. + - `self`는 이 Observer가 붙은 State의 **lazy 핸들**(값이 아님). + - `from`은 **이 통지의 출처**다 — `Epoch` 하나이거나 `Epoch`들의 **집합** + (`{[Epoch]: true}`, 게이트가 유보를 풀며 떼어낸 스냅샷). 값도 리비전도 + 안 실린다. 분기는 `isEpoch`로. 계약은 `base/state-epoch-plan.md` §5가 소스. + - **⚠️ [2026-08-22 신설] 등록 시점의 즉시 1회 실행에는 `from`이 없다** — + 그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `from`은 + **옵셔널**이고, 이때만 `nil`이다(2026-08-21 커밋 전 `/code-review high` + 발견 — 한때 non-optional로 적혀 있었다). `fn`이 `from`을 실제로 쓰는 + 소비자라면 `nil`을 "설치 발화"로 분기해야 한다. + - **이건 "값을 안 실어주는 구독" 계약을 안 깬다** — 넘기는 건 값이 아니라 + **핸들과 메타데이터**뿐이다. + - 인자 없는 `state:Observer()`(항상 관측 유틸)도 그대로 성립한다 — 넘겨줄 + `fn`이 없으니 인자 얘기가 아예 안 나온다. + - **`from`을 실제로 쓰는 첫 소비자는 `Effect`다** — 자기 `EpochMap`을 + 들고 각 내부 Observer가 그걸 `Update`해서 다중 의존성 중복 발화를 + 접는다(`base/effect-plan.md`의 "`Effect(fn, ...deps)`" 절). - **값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함.** 기존 "emit은 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(위 "전파 모델 확정" 절)이 그대로 적용됨: `fn`은 "뭔가 @@ -938,18 +961,18 @@ retract/Destroy되면 자동으로 정리됨. `:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 `noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()` 호출 여부를 작성자가 직접 결정하게 열어둔 것. - - **⚠️ 이 허용이 전파 규칙에 의존한다(2026-08-14 명시, - [2026-08-21 갱신]).** 지금 형태로 말하면 **"`Set` 한 번은 새 에포크라 - 항상 통과한다"**에 의존한다 — 에포크 dedup이 접는 건 *같은* 에포크의 - 두 번째 도착뿐이라 이 Observer는 매 변경마다 정확히 한 번 운다 - (`base/state-epoch-plan.md`). 아래 서술의 "항상 전파"는 그 뜻으로 읽을 것. `fn`이 `:Get()`을 안 하면 상류 State는 계속 - `invalid`로 남는데, 만약 "이미 `invalid`면 전파를 멈춘다"는 규칙이 - 있으면 **이 Observer는 두 번째 변경부터 영원히 안 울림**. 실제로 - 2026-08-14 이전까지 위 "전파 모델 확정" 절에 그런 문장이 있었고, - 이 계약과 정면 충돌하는 상태로 방치돼 있었음 - (`archive/invalidate-dedup-propagation-reversed.md`). **두 서술은 - 같이 움직여야 함** — 전파를 접는 최적화를 다시 넣고 싶어지면 - 반드시 이 항목부터 확인할 것. + - **⚠️ 이 허용이 전파 규칙에 의존한다(2026-08-14 명시, 2026-08-21 갱신).** + 의존하는 명제는 **"`Set` 한 번은 새 리비전이라 항상 통과한다"** 하나다 — + dedup이 접는 건 *같은* `Epoch`의 *같은* 리비전이 두 번째로 도착한 + 것뿐이라, 이 Observer는 매 변경마다 정확히 한 번 운다 + (`base/state-epoch-plan.md` §4). **`invalid`로 전파를 접는 최적화는 + 지금도 금지**다 — `fn`이 `:Get()`을 안 하면 상류 State는 계속 + `invalid`로 남으므로, 그런 규칙이 있으면 **이 Observer는 두 번째 + 변경부터 영원히 안 울린다**(2026-08-14 이전에 실제로 그 문장이 위 + "전파 모델 확정" 절에 있었고 이 계약과 충돌한 채 방치됐었다 — + `archive/invalidate-dedup-propagation-reversed.md`). **두 서술은 같이 + 움직여야 함** — 전파를 접는 최적화를 다시 넣고 싶어지면 반드시 이 + 항목부터 확인할 것. - **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯 번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제 `fn`을 만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링(`modifier-plan.md` diff --git a/.claude/base/state-epoch-plan.md b/.claude/base/state-epoch-plan.md index 4647a5c..b4e968e 100644 --- a/.claude/base/state-epoch-plan.md +++ b/.claude/base/state-epoch-plan.md @@ -1,26 +1,23 @@ -# State 재계산/전파 판정 — 소스 에포크 비교 (2026-08-21 확정) +# State 재계산/전파 판정 — `Epoch` 비교와 `EpochMap` 컴포지션 (2026-08-21 확정) -**상태**: **확정.** 사용자 제안으로 시작해 같은 날 네 라운드에 걸쳐 다듬은 뒤 +**상태**: **확정.** 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤 **채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다. 채택하면 될것 같아요."* 구현은 **M3(Source/State)**. **⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는 게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을 -얹는다.** 그 절이 원래 갖고 있던 두 서술은 이 채택으로 뒤집혔고(아래 §3), -역전 원문은 `archive/always-propagate-no-dedup-superseded.md`에 있다. +얹는다.** 그 절이 원래 갖고 있던 두 서술은 이 채택으로 뒤집혔고, 역전 원문은 +`archive/always-propagate-no-dedup-superseded.md`에 있다. -**⚠️ [2026-08-21] 이 문서의 구조를 바꾸는 제안이 `research/`에 대기 중이다** — -두 맵을 `EpochMap`으로 컴포지션하고 `Source` 대신 **`Epoch` 인터페이스**로 -일반화하는 안(`research/epoch-brand-composition.md`). **사실상 전량 확정됐고 -승격만 남았다**(같은 날 세션이 길어져 다음 세션으로 미룸). 여기 적힌 규칙 자체는 -그대로 성립하고 표현만 바뀐다 — 승격 전엔 이 문서가 정본. - -**읽는 순서**: 규칙만 필요하면 **§2**만 보면 된다. §1은 왜 이걸 하는가(실재하는 -glitch), §3은 무엇이 고쳐지는가, §5는 세부 계약, §7은 구현 시 확인할 것. +**읽는 순서**: 규칙만 필요하면 **§2~§4**만 보면 된다. §1은 왜 이걸 하는가 +(실재하는 glitch), §5는 emit 페이로드, §6은 State 밖의 소비자, §7은 비용, +§8은 구현 시 확인할 것. **히스토리**: 구현 전 QA 5라운드(`SS-2`/`SS-3`)에서 나왔고, 회신 원문은 -`qa-request/pre-implementation-qa-round5-response.md`, 네 라운드의 정정 경위는 -`qa-request/pre-implementation-qa-round5-followup.md`의 M·N절이 소스. +`qa-request/pre-implementation-qa-round5-response.md`, 여러 라운드의 정정 +경위는 `qa-request/pre-implementation-qa-round5-followup.md`의 M·N절이 소스. +`Epoch`/`EpochMap`으로 일반화한 마지막 라운드의 근거 기록은 +`reference/epoch-brand-composition.md`. ## 1. 사용자가 지목한 문제 (실재함) @@ -29,7 +26,8 @@ A ──> B ──┐ └──> C ──┴──> D ``` -`A:Set()` 한 번에 대해 지금 모델(push-invalidate + pull-recompute)에서: +`A:Set()` 한 번에 대해 옛 모델(push-invalidate + pull-recompute, 판정 주체가 +`invalid` 플래그)에서: 1. `A`가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라 **`B` 쪽 가지가 먼저 끝까지 내려간다** — `D`가 `B`를 통해 신호를 받고, 그 아래 Observer가 발화한다. @@ -41,280 +39,400 @@ A ──> B ──┐ 즉 **(a) 한 사이클 안에서 잘못된 값이 한 번 관측되고**(그 값으로 이미 프로퍼티가 써지는 등 부작용이 나간다), **(b) 같은 계산이 두 번 돈다.** 리액티브 문헌에서 -말하는 전형적인 **glitch**이고, 지금 quad 문서 어디에도 이 현상이 서술돼 있지 -않다. `base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절은 +말하는 전형적인 **glitch**다. + +`base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절은 **중복 재계산이 없다**고만 말하는데, 그 논증은 *"누군가 `d:Get()`을 부르는 시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에 -발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다. +발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다. 아래 규칙이 이걸 고친다. -## 2. 사용자 제안 (최종 정리형) — **[2026-08-21 3차 정정본]** +## 2. `Epoch` — 판정의 최소 인터페이스 -각 State가 **자기에게 영향을 주는 루트 `Source`들의 에포크(count)** 를 들고 -있다가, 그것과 실제 Source의 현재 count를 비교해 재계산/전파 여부를 정한다. -**[2026-08-21] 마지막 정정으로 테이블이 둘로 갈렸다** — 아래 "왜 둘인가" 참고. +**판정에 필요한 건 둘뿐이다: identity와 "직전과 달라지는 리비전".** 그걸 +이름 붙인 것이 `Epoch`다. + +```lua +type Epoch = { Revision: number } +``` + +- **그 자체로 키가 되는 unique 테이블**이다 — `EpochMap`이 `[epoch] = revision` + 으로 들고 있는다. +- **`Source`가 이 인터페이스를 구조적으로 만족한다** — `Source`가 `State`를 + 구조적으로 만족하는 기존 패턴(`base/source-state-plan.md`의 "Source가 State를 + 만족함" 절)과 정확히 같은 모양. `Source:Set()`/`:Emit()`이 자기 `Revision`을 + **갱신한다**(직전과 다른 값으로 — 방향은 계약이 아니고, 아래 확정된 연산은 + 실제로 증가가 아니라 **감소**한다). +- **`Revision`은 공개 필드다.** 비공개면 구조적 만족이 **타입 레벨에서 성립하지 + 않는다**(사용자: *"그래야 타입 상 `Source` 가 `Epoch`를 만족해요."*). + `Store`와 달리 `Source`는 키가 사용자 것이 아니므로 예약 이름이 늘어도 + 충돌하지 않는다 — *"`Source` 는 예약 이름이 늘어나도 됩니다(store 아님)."* +- **런타임 판별은 `EpochBrand:is(x)`**(`base/brand-plan.md`). `Source`는 + `SourceBrand`이면서 동시에 `EpochBrand`에 등록된다 — 이 다중 태깅 요구가 + `Brand`를 인스턴스 브랜드로 재작성한 직접 발단이다. +- **왜 `Source`가 아니라 `Epoch`인가**: 맵이 요구하는 건 identity + 리비전 + 둘뿐인데 그걸 `Source`로 못 박으면 계약이 실제보다 좁아진다. 사용자: + *"'소스를 전해주는것' 이라고 보기엔 너무 협소하고, 일반화된 형태가 아님."* + 일반화의 실익은 **`Source`가 아닌 원천(외부 시계 등)이 특수 분기 없이 + 낀다**는 것이고, 그건 `EpochBrand:register(self)` 한 줄로 끝난다. + +**계약은 "직전 값과 다르다"만 요구한다.** 순서 비교(`<`)는 아래 규칙 어디에도 +안 쓰이고 전부 `==`/`~=`뿐이라, 단조 증가조차 계약으로는 과하다 — 실제로 +**아래 확정된 방식은 증가가 아니라 감소한다**(그리고 0에서 한 바퀴 돈다). +규칙이 `==`/`~=`만 쓰기 때문에 그게 문제가 안 되는 것이므로, **나중에 순서 +비교를 넣고 싶어지면 이 결정부터 되짚을 것.** 이름이 `Revision`인 것도 +순서를 뜻하지 않는다 — "직전과 구별되는 표식"이라는 뜻이다. + +- **배경 — 평이한 `+1`이었다면 `2^53`에서 계약이 깨졌다.** Luau 숫자는 + double이라 랩어라운드가 아니라 **포화**한다 — `2^53`을 넘으면 `n + 1 == n`이 + 되어 "다르다"는 보장이 정확히 그 지점에서 깨진다(**초당 100만 `Set`으로 + 285년**이라 도달 불가능하긴 하다). 아래 확정된 `bit32` 랩은 **이 지점 자체를 + 없앤다.** 이걸 근거로 적을 땐 "오버플로해도 다르다"가 아니라 **"도달 + 불가능하다"**로 적을 것 — 전자는 틀린 서술이다. +- **⭐ [2026-08-21 확정] 리비전 갱신은 `bit32.bnot(-rev)` 한 번이다 — 리비전은 + uint32 안에 머문다.** + + ```lua + self.Revision = bit32.bnot(-self.Revision) + ``` + + **[2026-08-22 실측] 이건 랩어라운드 감소다** — `a > 0`이면 `a - 1`, + `a == 0`이면 `4294967295`로 한 바퀴 돈다. `luau`로 확인한 값: + + | `rev` | `bit32.bnot(-rev)` | + |---|---| + | 0 | 4294967295 | + | 1 | 0 | + | 2 | 1 | + | 4294967295 | 4294967294 | + + (원리: `bit32.bnot(x) == 4294967295 - (x mod 2^32)`이고 `a > 0`에서 + `(-a) mod 2^32 == 2^32 - a`라 `a - 1`이 된다. 시작값이 무엇이든 + 상관없다 — 매 호출이 직전과 다른 값을 준다는 것만 계약이다.) + + **사용자 논거**(2026-08-21): *"그건 luau 에서 native call 이라 아주 + 빨라요. 반면 double 의 연산이 느린편인데, 희소 수준이 아니라, 사실상 + 만나는걸 수년간 보기 어려운 라운드되어 동일해 무시되는 경우를 막기 위해 + double 까지 올려야할 이유를 모르겠어요. 매번 도는 코드인지라, 값 싸게 + native call + num 연산으로 가볍게 가고 싶어요."* — 즉 **`2^53` 포화는 + 어차피 도달 불가능한 시나리오인데, 그걸 피하겠다고 값을 double 영역까지 + 키울 이유가 없다**는 것. 이건 매 `Set`마다 도는 hot path다. + - **갱신과 랩이 같은 연산 하나다.** `bit32.bnot`은 Luau가 FASTCALL로 + 거는 빌트인이라, 별도의 덧셈도 마스킹도 없다 — 단항 부호 반전 하나가 + 붙을 뿐이다. + - 랩이라 **`2^53` 포화(`n + 1 == n`)가 아예 안 생긴다** — 위 배경 항목이 + 말하는 계약 파손 지점 자체가 사라진다. + - **⚠️ [2026-08-22 정정] 대신 생기는 `2^32` 랩은 "똑같이 도달 불가능"이 + 아니다.** 여기 그렇게 적어뒀는데 **수치가 틀렸다** — 위 배경 항목과 같은 + 척도(초당 100만 `Set`)로 `2^53`은 285년이지만 **`2^32`는 약 72분**이다 + (20만 배 차이). 현실적인 부하(초당 1만 `Set`)로도 5일 남짓이다. + **그래도 위험하지 않은 이유는 도달 시간이 아니라 충돌 조건이 한 점이기 + 때문이다**: 오판정이 나려면 **어떤 맵 항목이 그 `Epoch`에 대해 정확히 + `2^32`만큼 뒤처져** 있어야 한다. 한 바퀴에서 하나라도 어긋나면 값이 달라 + 정상 판정된다. 그 항목은 emit을 받거나 `:Refresh`를 도는 순간 갱신되므로, + "정확히 한 바퀴 동안 한 번도 안 건드려진 항목"이라야 한다. 확률적으로 + 무시 가능하다는 뜻이지 **산술적으로 불가능하다는 뜻이 아니다** — 이 문서가 + 바로 위에서 "근거를 정확히 적을 것"이라 규정했으므로 같은 기준을 적용한다. + (2026-08-21 커밋 전 `/code-review high` 발견.) + - **⚠️ [2026-08-22 정정] 여기 한때 `bit32.band(rev + 1, 0xFFFFFFFF)`라 + 적고 "`bit32`가 double 덧셈 위에 fastcall을 하나 더 얹는다"는 단서를 + 달아뒀는데, 둘 다 틀렸다.** 사용자가 말한 형태는 처음부터 + `bit32.bnot(-a)`였고(*"제가 말한건, bit32.bnot(-a) 입니다"*), 그건 + **덧셈을 얹는 게 아니라 갱신 자체를 대체한다.** 그래서 "`bit32`라서 + 더 싸다"는 사용자 서술이 맞고, 그걸 반박한 에이전트 단서가 틀렸었다 — + `band(n + 1, mask)`라는 **다른 형태**를 놓고 한 비교였기 때문. +- **테이블 identity를 리비전으로 쓰는 대안은 채택 안 함**(사용자 선택: + *"Revision 숫자로 가는걸 저는 선택하고 싶어요"*). 기능적으로는 둘 다 + 성립하지만 테이블안은 **`Set` 한 번마다 테이블 하나를 할당**해서, 트윈처럼 + 매 프레임 `Set`하는 소스가 여럿이면 GC 압력을 만든다(quad는 GC-native + 아키텍처라 이 축을 신경 써왔다). + +## 3. `EpochMap` — 컴포지션 가능한 부기 객체 + +**에포크 부기를 State에서 떼어내 재사용 가능한 객체로 만든다**(사용자 제안). +그래야 **노드가 아닌 소비자(leaf)도 같은 판정을 쓸 수 있다** — `Effect`가 +그 첫 수요자다(§6). + +```lua +type EpochSet = { [Epoch]: true } -- 배열이 아니라 집합이다 (아래 ⚠️ 참고) + +EpochMap() -> EpochMap + +EpochMap:Update(Epoch | EpochSet) -> boolean -- "뒤로 전파가 필요한가" +EpochMap:Refresh() -> boolean -- 자기 키 전부를 라이브로 다시 읽음 +EpochMap:Sync(Epoch | EpochSet) -- 읽지 않고 쓰기만 (반환값 없음) +EpochMap:TrackFrom(other: EpochMap) -- other가 추적 중인 키를 넘겨받아 라이브 리비전으로 채움 +``` + +**⚠️ [2026-08-22 정정] 여러 개를 넘길 때는 `{Epoch}`(배열)가 아니라 +`{[Epoch]: true}`(집합)다.** 여기 한때 `{Epoch}`로 적혀 있었는데, Luau에서 +그건 `{[number]: Epoch}` 배열이라 **실제로 넘어오는 게이트 배치와 타입이 +다르다** — 배치는 `base/gate-plan.md` 4번이 확정한 `withheld : { [epoch] : true }`를 +그대로 스왑해 넘긴 것이다. 이 표기를 믿고 `ipairs`로 구현하면 배치를 순회할 때 +**원소가 0개**가 되어, 유보됐다 풀린 emit이 하류에서 전부 조용히 삼켜진다 — +`gate-plan.md` 4번이 애초에 고치려던 바로 그 버그다. **집합이어야 하는 이유는 +게이트 쪽 요구**다: 유보 중 같은 `Epoch`가 여러 경로로 도착해도 +`withheld[epoch] = true`가 저절로 접어주고, 게이트-게이트 unfold(같은 절)도 +집합이라야 중복 없이 합쳐진다. (2026-08-21 커밋 전 `/code-review high` 발견.) + +- **`:Update`가 이 객체의 전부다.** 넘어온 각 `Epoch`에 대해 저장된 리비전과 + `epoch.Revision`을 비교하고, 다르면 새 값으로 덮는다. **하나라도 달랐으면 + `true`.** 사용자: *"애초에 Update 자체가 전부 최신 상태로 만들고, 업데이트 + 된게 있으면 true 를 던지는거라."* + - **`EpochSet`을 받으므로 sync 연산이 따로 필요 없다** — 전체를 넘기면 + 그게 곧 sync다. (한때 에이전트가 "`:Sync`가 필수"라고 적었으나 `Update`를 + "하나만 받는 것"으로 좁게 본 착오였고 철회됐다.) + - **⭐ 이건 `invalid`와 다른 물건이다**(사용자: *"이건 invalid 랑은 다른 + 구현이야."*). 반환값의 뜻은 "내 캐시가 낡았다"가 아니라 **"뒤로 전파가 + 필요한가"** 하나다. + - **내부 최적화**: 목록을 돌 때 diff 때문에 읽기가 들어가는데, **한 번 + 다름을 찾으면 반환값이 이미 `true`로 확정**되므로 나머지는 읽지 않고 + 쓰기만 하면 된다(사용자 제안). +- **`:Refresh`는 인자 없는 `:Update`다** — 자기가 이미 들고 있는 키 전부를 + 라이브로 다시 읽어 갱신하고, 하나라도 달랐으면 `true`. 아래 §4의 **순회**가 + 이걸 쓴다(순회가 훑을 대상 목록이 곧 이 맵 자신이라 인자로 받을 게 없다). +- **`:Sync`는 읽기를 건너뛰고 쓰기만 하는 변형**이다(반환값 없음). *"없다고 + 안되는건 아닌데, 그냥 다 안 읽고 set 만 해버리는것은 처음 셋팅에 도움은 + 됩니다."* **[2026-08-22 정정]** 여기 "초기화에만 쓴다"고 적혀 있었으나 + `base/gate-plan.md` 4번이 게이트의 flush 경로에서도 `:Sync(batch)`를 쓰는 + 것으로 확정돼 있다 — 쓰는 자리는 **"반환값이 필요 없다고 이미 아는 곳"** + 둘이다: 노드 생성 시딩, 그리고 게이트가 실제로 전파할 때. +- **⭐ [2026-08-22 신설] `:TrackFrom(other)` — 새 노드 시딩이 이걸 쓴다.** + `other`가 추적 중인 키를 전부 넘겨받아 **라이브 리비전으로** 채운다 + (`other`의 저장값을 복사하는 게 아니다). + - **왜 필요한가**: §4의 시딩 규칙은 "상류의 `Epoch`를 전부 끌어와 채운다"인데, + `:With(a, b)`의 상류 `a`/`b`는 **State이지 `Epoch`가 아니다.** 그 State가 + 추적 중인 루트 `Epoch` 집합은 그 State의 `valueEpochMap` 안에만 있으므로, + 키를 넘겨받는 연산이 없으면 시딩을 **표면으로 표현할 수가 없다** + (2026-08-21 커밋 전 `/code-review high` 발견). + - **새 설계가 아니라 이미 확정된 동작에 이름을 붙인 것**이다 — 사용자 + 확정 문구가 이미 *"전부 가져와서, 실제 count 로 둡니다"*였다. `:Refresh`도 + 같은 성격의 명명이다(§4의 "순회"). + - `Source`처럼 **자기가 곧 `Epoch`인 상류**는 `:TrackFrom`이 아니라 + `:Sync(dep)`로 직접 넣는다 — 아래 시딩 규칙 참고. + - **이름 근거(사용자 확정, 2026-08-22)**: 가칭은 `Absorb`였는데 *"조금 상위 + 요소꺼를 흡수해서 상위 요소에서 제거할것만 같은 이름"*이라 바꿨다 — 이 + 연산은 `other`를 **전혀 안 건드린다**. 게다가 `base/gate-plan.md`가 이미 + "흡수 집합"을 **다른 뜻**(emit을 붙들고 있음)으로 쓰고 있어 한 코퍼스 안에 + 같은 단어가 두 의미로 놓이는 문제도 있었다. `TrackFrom`은 이 맵의 존재 + 이유를 사용자가 표현한 말(*"'내가 뭘 추적하고 있나' 가 필요하죠"*)을 그대로 + 쓰고, `From`이 방향을 못박아 비파괴가 드러나며, 나중에 동적 의존성으로 + 생성 이후에 키를 더하는 자리가 생겨도 이름이 그대로 맞는다(그래서 + `SeedFrom`보다 낫다). 후보 비교는 `question.md`가 아니라 여기서 끝났다 — + 같은 자리에서 확정됐으므로 열린 항목이 아니다. +- **키는 weak다.** `epoch`가 죽으면 항목이 사라진다. `base/relate-plan.md`가 + 경고하는 "값이 키를 되참조하면 안 된다"는 제약은 값이 숫자라 문제없다. + +## 4. State는 `EpochMap`을 둘 컴포지션한다 ``` State - sourceCountMap : { [source (weak key)] : count } - -- "내 값이 이 소스에 대해 최신인가" (값 유효성) - sourceEmitMap : { [source (weak key)] : count } - -- "이 소스의 이 에포크를 내가 하류로 이미 던졌는가" (전파 dedup) - rawInvalid : boolean - -- "재계산이 필요하다"는 확정 플래그 + valueEpochMap : EpochMap -- "내 값이 이 Epoch에 대해 최신인가" (값 유효성) + emitEpochMap : EpochMap -- "이 Epoch의 이 리비전을 하류로 이미 던졌는가" (전파 dedup) + rawInvalid : boolean -- "재계산이 필요하다"는 확정 플래그 ``` -**⭐ [2026-08-21 확정] 노드가 생길 때의 초기값은 두 맵이 서로 다르다.** +**⭐ 왜 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의 상태로 +하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시 +던져야하는지 봐야하나' 를 보는걸 나누는거죠."* 순회가 없다면 **"값을 +최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서** 맵 +하나로 충분하다. 순회를 넣는 순간 그 둘이 갈라진다 — **순회는 값만 앞당기고 +통지는 안 한다.** -- **`sourceEmitMap`은 비운 채로 시작한다.** `nil ~= source.count`라 어떤 emit이 - 와도 "처음 보는 것"으로 걸린다 — 그리고 그게 맞다. 사용자: *"새로 생성된 - 노드에서 들어온 emit 은, 개념적으로 해당 노드가 한번도 받아본적 없는 - emit 입니다."* -- **`sourceCountMap`은 반대로 상류에서 전부 끌어와 실제 count로 채우고, - `rawInvalid = true`로 시작한다.** 이쪽은 비워두면 안 된다 — 순회가 훑을 +**⭐ 대원칙 — 무효화를 결정하는 건 언제나 리비전 비교지 emit의 도착이 아니다.** +emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도 +리비전이 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2번 규칙). 사용자 +정리: *"emit 자체가 invalid 하게 만드는 직접 트리거는 아니라서, (이미 count 가 +최신이면) 캐시가 유효하다."* 아래 나머지 규칙은 전부 이 원칙의 따름정리다. + +### 노드가 생길 때의 초기값 — 두 맵이 서로 다르다 + +- **`emitEpochMap`은 비운 채로 시작한다.** 어떤 emit이 와도 "처음 보는 것"으로 + 걸린다 — 그리고 그게 맞다. 사용자: *"새로 생성된 노드에서 들어온 emit 은, + 개념적으로 해당 노드가 한번도 받아본적 없는 emit 입니다."* +- **`valueEpochMap`은 반대로 상류가 추적 중인 `Epoch`를 전부 끌어와 채우고, + `rawInvalid = true`로 시작한다.** dep마다 갈린다 — **dep이 `Epoch`면** + (`Source`) `:Sync(dep)`, **dep이 State면** `:TrackFrom(dep.valueEpochMap)`. + 분기는 `isEpoch`로 한다. + ```lua + for _, dep in deps do + if isEpoch(dep) then self.valueEpochMap:Sync(dep) + else self.valueEpochMap:TrackFrom(dep.valueEpochMap) end + end + self.rawInvalid = true + ``` 이쪽은 비워두면 안 된다 — 순회가 훑을 대상 목록이 곧 이 맵이라, 비어 있으면 **"훑을 게 없으니 유효하다"**로 오판한다. 사용자: *"이건 상류가 주는대로 lazy 할 순 없는게, '내가 뭘 추적하고 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제 count 로 둡니다. 그러고 `rawInvalid` 를 true 로 두세요."* -- 그래서 **`:With`에서 두 상류가 같은 소스에 다른 count를 들고 있을 때의 병합 - 규칙도 필요 없다** — 어차피 생성 시점의 라이브 count로 통일된다. +- 그래서 **`:With`에서 두 상류가 같은 `Epoch`에 다른 리비전을 들고 있을 때의 + 병합 규칙도 필요 없다** — 어차피 생성 시점의 라이브 리비전으로 통일된다. -**⭐ 대원칙 — 무효화를 결정하는 건 언제나 count 비교지 emit의 도착이 아니다.** -emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도 -count가 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2번 규칙). 사용자 -정리(2026-08-21): *"emit 자체가 invalid 하게 만드는 직접 트리거는 아니라서, -(이미 count 가 최신이면) 캐시가 유효하다."* 이 문서의 나머지 규칙은 전부 이 -원칙의 따름정리다. +### emit을 받았을 때 — 두 맵을 각각 `Update` 하고 그 boolean으로 결정한다 -- `Source:Set()`/`:Emit()`은 자기 count를 증가시킨다. -- **`emit`은 값을 안 싣고 count도 안 싣는다 — 싣는 건 "이 통지의 출처" 하나**다. - 받는 쪽이 거기서 count를 **그때그때 라이브로** 읽는다. 그래서 "에포크 5의 - emit" 같은 건 없다. 출처로 올 수 있는 것은 **둘**이다 - (**[2026-08-21 확장]** — 원래는 `Source`뿐이었다): - - **`Source`** — 그 소스 하나를 확인하라. - - **게이트 배치** — 게이트가 유보를 풀며 **떼어낸 소스 집합**을 확인하라 - (`base/gate-plan.md`의 4번). 게이트가 `blocker:Off()` 등으로 풀 때 쓴다. - **게이트의 살아있는 `withheld` 테이블이 아니라 그 자리에서 스왑해 떼어낸 - 스냅샷**이다 — 재진입이 나도 바깥 전파가 빈 집합을 순회하지 않게 하기 - 위함(같은 절). - **평범한 노드는 출처를 안 바꾼다** — 자기를 끼워넣지 않고 받은 출처를 그대로 - 아래로 넘긴다. **`GateNode`만 예외로 언제나 자기 자신을 출처로 새로 낸다** +```lua +local valueChanged = self.valueEpochMap:Update(from) +local emitChanged = self.emitEpochMap:Update(from) +if valueChanged then self.rawInvalid = true end +if valueChanged or emitChanged then + -- 뒤로 emit (받은 from을 그대로 넘긴다) +end +``` + +세 갈래로 읽으면 이렇다: + +1. **`valueEpochMap`이 달랐다** → 값이 낡았다. `rawInvalid = true`를 세우고 + **뒤로 emit** 한다(두 맵 모두 갱신됨). +2. **`valueEpochMap`은 같은데 `emitEpochMap`이 달랐다** → **값은 이미 최신이지만 + 통지는 아직 안 나갔다**(순회가 앞질러 흡수했거나, 게이트가 붙들고 있는 동안 + 하류가 `Get()`으로 앞당겨 읽은 경우). `emitEpochMap`만 갱신되고 **뒤로 + emit** 한다 — `rawInvalid`는 안 건드린다. +3. **둘 다 같다** → **삼킨다.** 다이아몬드에서 같은 리비전이 두 경로로 도착한 + 두 번째가 여기서 접힌다. + +- **⚠️ [2026-08-22 신설] `GateNode`는 이 의사코드를 그대로 쓰지 않는다.** + 위 코드는 `emitEpochMap`을 **수신 시점에** 갱신하는데, 게이트는 + `base/gate-plan.md` 4번이 **전파할 때 `:Sync(batch)`로** 갱신하는 것으로 + 확정돼 있다(그래야 "내가 하류로 던진 리비전"이라는 맵의 뜻이 게이트에서도 + 참이 된다 — 유보 중엔 아직 안 던졌으니까). 그래서 게이트에서는: + - 판정(규칙 1~3)은 **똑같이** 먼저 돈다. 규칙 3으로 삼켜지면 정책도 안 돌고 + 흡수 집합에도 안 들어간다(`gate-plan.md` 4번). + - 다만 `emitEpochMap:Update`를 수신 시점에 부르지 않으므로, **유보 중에 + 같은 리비전이 다른 경로로 또 오면 규칙 2로 걸려 정책이 한 번 더 돈다.** + 이미 흡수 집합에 있어 무해하다(같은 절). 정책이 실제로 emit한 뒤에는 + `:Sync(batch)`가 돌아 있으므로 그 다음 도착은 정상적으로 규칙 3에 걸린다. + - **이 예외가 여기 기록돼 있지 않았다**(2026-08-21 커밋 전 `/code-review high` + 발견) — §4대로 구현하면 `gate-plan.md` 4번의 계약이 조용히 깨진다. +- **`from`이 하나면 판정은 O(1)이다** — 그 항목 하나만 본다. **다른 항목은 + 건드리지 않는다**(그 `Epoch`들은 자기가 직접 emit 하므로). +- **`from`이 집합이면**(게이트 배치, §5) `Update`가 알아서 순회하고, 하나라도 + 달랐으면 `true`를 준다 — 규칙이 그대로 성립한다. + +### 재계산 판정 + +- **`rawInvalid == true`** → 그냥 재계산한다. 순회할 이유가 없다(이미 확정). +- **`rawInvalid == false`** → **그때만 `valueEpochMap:Refresh()`를 부른다.** + 목적은 하나뿐 — **못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에 + 막혀 있었다거나 전파 파동이 아직 이 가지에 안 닿았다거나). `true`가 나오면 + `rawInvalid = true`로 만들고 재계산한다. +- **⭐ 순회는 `emitEpochMap`을 건드리지 않고, 뒤로 emit 하지도 않는다.** + 그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 **하류까지 정상적으로 + 전파된다**(사용자: *"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*). + 이게 없으면 **값은 맞는데 통지가 죽는** 실패 모드가 생긴다 — 2026-08-14에 + 폐기된 옛 dedup의 "영구 침묵"과 같은 계열이다 + (`archive/invalidate-dedup-propagation-reversed.md`). + +### 재계산이 끝나면 + +- **`rawInvalid = false`**, 그리고 **`valueEpochMap`은 자기가 읽은 상류 + 전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이 + `Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해 + 최신이다. 사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 전부 + 업데이트 하는건 맞아보입니다."* +- **`emitEpochMap`은 안 건드린다 — 계산은 통지가 아니다.** +- 이 "전부 갱신"이 실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가 + `Get()`으로 앞당겨 읽는 경우**다. 그때 `valueEpochMap`이 앞서 있으므로, + 나중에 게이트가 풀며 보내는 통지는 위 2번(통지만)으로 떨어져 **같은 값을 + 다시 계산하지 않는다.** + +## 5. emit 페이로드 — `Epoch | EpochSet`, 게이트는 안 싣는다 + +- **emit은 값도 리비전도 안 싣는다 — 싣는 건 "이 통지의 출처"뿐**이고, 그 + 출처는 `Epoch` 하나이거나 `EpochSet`(`{[Epoch]: true}`)이다. 받는 쪽이 거기서 리비전을 + **그때그때 라이브로** 읽는다. 그래서 "리비전 5의 emit" 같은 건 없다. +- **게이트 노드 자체는 페이로드에 안 싣는다.** 게이트가 유보를 풀 때 + (`blocker:Off()` 등) 넘기는 건 **그 자리에서 스왑해 떼어낸 `Epoch` 집합 + 스냅샷**이다(`base/gate-plan.md`의 4번) — 게이트의 살아있는 `withheld` + 테이블이 아니다(재진입이 나도 바깥 전파가 빈 집합을 순회하지 않게 하기 + 위함). 하류는 게이트 identity를 **한 번도 안 쓴다** — 게이트를 에포크 + 경계로 만드는 안은 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목에서 + 기각됐다. 사용자: *"하류가 Gate 노드를 받을 이유도 없거든."* +- **런타임 분기는 `isEpoch`로 한다** — 단일이냐 집합이냐. +- **평범한 노드는 출처를 안 바꾼다** — 자기를 끼워넣지 않고 받은 출처를 그대로 + 아래로 넘긴다. **`GateNode`만 예외로 언제나 자기 배치를 새로 낸다** (`base/gate-plan.md`의 4번). -- **emit을 받았을 때** — 출처가 `Source`면 판정은 O(1)이다, 그 항목 하나만 본다: - 1. `sourceCountMap[source] ~= source.count` → 둘 다 `source.count`로 갱신하고 - `rawInvalid = true`를 세운 다음 **뒤로 emit** 한다. - 2. 같은데 `sourceEmitMap[source] ~= source.count` → **값은 이미 최신이지만 - 통지는 아직 안 나갔다**(아래 순회가 앞질러 흡수했거나, 게이트가 붙들고 - 있는 동안 하류가 `Get()`으로 앞당겨 읽은 경우). `sourceEmitMap`만 - 갱신하고 **뒤로 emit** 한다 — `rawInvalid`는 안 건드린다. - 3. 둘 다 같으면 → **삼킨다.** - - **다른 소스 항목은 건드리지 않는다** — 그 소스들은 자기가 직접 emit 하므로. -- **게이트는 배치가 비어 있으면 애초에 통지하지 않는다**(`base/gate-plan.md`의 - 8번) — 빈 배치를 흘리는 건 "쌓인 게 없는데 뒤로 넘기는" 꼴이라 State 층에 - `Source:Emit`을 추가하는 것과 같아진다. 그래서 아래 규칙이 빈 집합을 받는 - 경우는 없다. -- **출처가 게이트 배치면** 그 집합을 순회하며 **각 소스에 위 1~3을 - 그대로 적용**하고, 하나라도 1번이나 2번에 걸렸으면 **받은 출처(그 게이트)를 - 그대로** 뒤로 넘긴다. 전부 3번이면 삼킨다 — 다이아몬드에서 같은 해제 통지가 - 두 번 도착해도 두 번째가 접히는 건 소스 emit과 똑같다. - - **⚠️ 단, 받는 쪽이 `GateNode`면 배치를 그대로 넘기지 않고 풀어서 자기 + - **⚠️ 받는 쪽이 `GateNode`면 배치를 그대로 넘기지 않고 풀어서 자기 `withheld`에 합친다** — 배치는 상류 게이트가 이번 전파에만 쓰는 일회성 스냅샷이라, 참조만 들고 있다가 나중에 풀면 그 배치가 이미 지나간 것이 - 된다(`base/gate-plan.md`의 4번). -- **재계산 판정**: - - `rawInvalid == true` → 그냥 재계산한다. 순회할 이유가 없다(이미 확정). - - `rawInvalid == false` → **그때만 `sourceCountMap`을 훑는다.** 목적은 하나뿐 — - **못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에 막혀 있었다거나 - 전파 파동이 아직 이 가지에 안 닿았다거나). 다른 항목이 발견되면 - **`sourceCountMap`만** 갱신하고 `rawInvalid = true`로 만든다. - - **⭐ 순회는 `sourceEmitMap`을 건드리지 않고, 뒤로 emit 하지도 않는다.** - 그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 **하류까지 정상적으로 - 전파된다**(사용자: *"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*). -- **재계산이 끝나면 `rawInvalid = false`, 그리고 `sourceCountMap`은 자기가 읽은 - 상류 전부에 대해 갱신한다**(**[2026-08-21 확정]** — 발행 소스 항목만 갱신하는 - 게 아니다). 맵의 뜻이 "내 값이 이 소스에 대해 최신인가"이므로, 방금 계산한 - 값은 정의상 **모든** 상류에 대해 최신이다. `sourceEmitMap`은 **안 건드린다** — - 계산은 통지가 아니다. - - 이게 실제로 갈리는 자리는 **게이트가 붙들고 있는 동안 하류가 `Get()`으로 - 앞당겨 읽는 경우**다. 그때 `sourceCountMap`이 앞서 있으므로, 나중에 게이트가 - 풀며 보내는 통지는 위 2번(통지만)으로 떨어져 **같은 값을 다시 계산하지 - 않는다.** -- count가 싫다면 `[source] -> {}` 처럼 **유니크 테이블 identity**로 같은 판정이 - 가능하다(사용자 대안). + 된다(같은 절). +- **게이트는 배치가 비어 있으면 애초에 통지하지 않는다**(`base/gate-plan.md`의 + 8번) — 그래서 위 규칙이 빈 집합을 받는 경우는 없다. -**⭐ 왜 테이블이 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의 -상태로 하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시 -던져야하는지 봐야하나' 를 보는걸 나누는거죠."* 순회가 없다면 **"값을 -최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서** 카운트 -하나로 충분하다 — 실제로 2026-08-21 2차 정정에서 그렇게 정리했었다. 순회를 -넣는 순간 그 둘이 갈라진다(순회는 값만 앞당기고 통지는 안 한다). 그래서 이 -분리는 **한 번 철회됐던 `seen`/`computedAt` 분리가, 다른 이유로 되살아난 -것**이다 — 옛 근거("전파 시점에 갱신된 count가 캐시를 신선한 것으로 오인시킨다")는 -여전히 틀렸고, 지금 근거는 **순회가 값과 통지를 비대칭으로 앞당긴다**는 것이다. +## 6. State 밖의 소비자도 같은 판정을 쓴다 — `Effect` -**⚠️ [2026-08-21 `/code-review high`] 아래 희소 구현 메모는 "둘 다 상류에서 -복사"와 그대로는 안 맞는다** — 아래 §5의 7번이 소스. 두 맵을 명시적으로 다 -들고 시작하는 게 안전하고, 희소화는 그 규칙이 정해진 뒤 얹을 것. +`EpochMap`을 떼어낸 실익이 여기서 나온다. **`Effect`가 자기 `EpochMap`을 하나 +들고** 각 의존성의 내부 Observer가 그걸 `Update`하면, 한 파동에 여러 dep가 +깨워도 **첫 번째만 `true`** 라 `fn`이 한 번만 돈다. -**구현 메모 — `sourceEmitMap`은 희소 테이블로 두면 된다.** emit이 count를 안 -싣고 받는 쪽이 라이브로 읽으므로, 이 테이블에 실제로 필요한 정보는 **"순회가 -앞질러 흡수해서 아직 안 던진 소스가 무엇인가"** 뿐이다. 평상시엔 비어 있고 -순회가 발견했을 때만 채워지는 `pending` 집합으로 구현해도 위 세 규칙이 그대로 -성립한다(1번에서 지우고, 2번에서 있으면 던지고 지운다). +이건 `A → b`, `A → c`, `Effect(fn, b, c)`에서 **접어줄 공통 하류가 없어** +State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그 지점이 곧 +공통 하류가 된다. 계약 전량은 `base/effect-plan.md`의 "`Effect(fn, ...deps)`" +절이 소스. -## 3. 에이전트 분석 — 이게 실제로 무엇을 고치는가 +**그래서 `Observer` 클로저는 출처를 인자로 받는다** — +`fn(self, from: (Epoch | EpochSet)?)`. `:Compute`의 `fn(self, ...)`와 같은 +모양이고(설치 발화에는 출처가 없어 `nil`이다), +값이 아니라 **핸들과 메타데이터**만 넘기므로 "값을 안 실어주는 구독" 계약은 +안 깨진다. `base/source-state-plan.md`의 "`state:Observer(fn)`" 절이 소스. -**결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라 -`Get()`의 의미론을 바꾸는 결정이다.** - -- **고쳐진다 — 섞인 값.** 위 3단계에서 `D`가 `C:Get()`을 부르면, `C`도 자기 - `sourceCountMap`에서 `A`의 count가 자기가 기록한 것보다 앞선 걸 보고 **신호가 - 아직 안 왔어도 스스로 재계산**한다. 그래서 `D`는 항상 `(B_new, C_new)`를 - 얻는다. **`Get()`이 "지금 이 순간의 일관된 값"을 반환한다는 보장이 처음으로 - 성립**한다. -- **고쳐진다 — 중복 재계산.** 뒤늦게 `C` 쪽 신호가 도착해도 `sourceCountMap`이 - "이미 최신"이라 `rawInvalid`가 켜지지 않고, 따라서 재계산도 안 일어난다 - (§2의 2번/3번 규칙 — **[2026-08-21 정정]** 여기 "`rawInvalid`가 켜져도"라고 - 적혀 있었으나 §2 규칙상 켜지지 않는다). -- **⭐ [2026-08-21 확정] 중복 *통지*도 같이 접는다 — `source-state-plan.md`가 - "접지 않는다"고 확정해뒀던 것의 역전이다**(역전 원문은 - `archive/always-propagate-no-dedup-superseded.md`). 처음엔 "값만 - 고쳐지고 통지는 두 번 그대로"로 정리했는데, **사용자 판정으로 통지도 - 같은 장치로 접기로 했다**: *"중복 통지는 단순히, count 목록을 순회해보고 - 이미 모든 소스가 최신이면 무시하는게 맞아보인다. 특히 emit 은 자신 소스를 - 주게 되므로, 자신 소스 카운트만 빠르게 비교가 쉽다. 우리에게 중요한 점은, - 앞단의 변경이 뒤로 흘렀냐일 뿐이라서 해당 방식을 택하는데 있어 문제가 없다."* - - **판정은 O(1)이다** — emit이 **어느 source가 발행했는지**를 실어 오므로 - **그 소스 하나만** 비교하면 된다(전체 목록 순회가 아님). - - **⚠️ 이건 2026-08-14에 뒤집힌 옛 dedup과 다른 장치다.** 그때 폐기된 건 - *"이미 `invalid`면 더 안 내려보낸다"*로, `:Get()`을 안 부르는 Observer가 - **한 번 울고 영구히 침묵**하는 실패 모드가 있었다 - (`archive/invalidate-dedup-propagation-reversed.md`). 에포크 비교는 그 - 모드가 없다 — 매 `Set`마다 카운트가 **새 값**이라 항상 통과하고, 접히는 건 - **같은 에포크가 두 경로로 도착한 두 번째**뿐이다. - - **⚠️ [2026-08-21 철회 후 재도입] 여기 있던 "카운트를 `seen`/`computedAt` - 둘로 분리하라"는 요구는 한 번 철회됐다가 **다른 근거로 되살아났다** — - 지금 형태는 `sourceCountMap`/`sourceEmitMap`이고, 근거는 원래 적었던 - "전파 시점 갱신이 캐시를 오인시킨다"(틀림)가 아니라 **순회가 값만 앞당기고 - 통지는 안 한다**는 비대칭이다. §2의 "왜 테이블이 둘인가" 문단이 소스. - - 비교는 두 맵 모두 `source.count`와 같으면 삼킨다(단조 증가라 안전). -- **선례가 있다** — 값 자체를 비교하는 게 아니라 **버전/에포크를 비교해 - lazy하게 검증**하는 건 MobX(global state version + observing 검사), - Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준 - 기법이다. Fusion처럼 **그래프를 위상정렬해 eager로 미는 방식**(quad가 이미 - 기각한 것)의 대안으로 자주 쓰인다 — 즉 이 제안은 quad가 이미 택한 - pull 모델과 **결이 같다**. - -## 4. 비용 +## 7. 비용 사용자 추산(*"해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해 인덱싱 하는 정도"*)에 동의한다. 덧붙일 것 둘: -- 두 맵의 크기는 **그 노드 상류에 있는 서로 다른 루트 Source의 수**다. +- 두 맵의 크기는 **그 노드 상류에 있는 서로 다른 루트 `Epoch`의 수**다. 체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가 큰 경우는 드물다. -- **[2026-08-21 정정]** 순회 조건이 뒤집혔으므로 비용 구도도 뒤집힌다 — - **훑는 쪽이 흔한 경로**(`rawInvalid == false`, 즉 "안 바뀐 것 같다")다. - 그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 - **재계산은 안 도는** 경로다. 반대로 `rawInvalid == true`면 순회를 아예 - 건너뛰고 바로 재계산한다. +- **훑는 쪽(`rawInvalid == false`, 즉 "안 바뀐 것 같다")이 흔한 경로**다. + 그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 **재계산은 + 안 도는** 경로다. 반대로 `rawInvalid == true`면 순회를 아예 건너뛰고 바로 + 재계산한다. +- 맵 하나당 객체 하나가 늘지만(State당 둘), 옛 모양도 테이블 둘이었으므로 + 컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다. -## 5. 열린 질문 (채택 전에 답이 필요) - -1. **[해소, 2026-08-21] 선언 안 된 의존성 — 별도 UB 조항을 만들지 않는다.** - 에이전트가 "새 모델은 더 강한 약속을 하니 예외를 UB로 못 박아야 한다"고 - 제안했으나 **사용자가 기각**: *"그건 아니다. 이 동작으로 인해 이제 정말로 - 항상 state 는 get 이 최신을 던지는게 맞다. 상류의 상태를 물어보므로 그러함. - 이전과 다른게 없다고 생각한다."* — 선언 안 한 Source를 클로저로 읽는 건 - **지금 모델에서도 똑같이 stale**이고 이 변경이 악화시키는 게 없으므로, - 새 조항 없이 기존 "의존성은 선언한다"는 관례 그대로 둔다. -2. **동적 의존성.** 조건에 따라 다른 상류를 읽는 계산이면 `sourceCountMap`이 - 보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다. - 그대로 감수할지 확인. -3. **[2026-08-21 해소] 순회가 발견한 변경을 어떻게 처분하는가 — 두 테이블로 - 나눠 "값은 앞당기고 통지는 기다린다"로 확정.** - - **문제**(사용자 지적): 순회가 count를 최신으로 올려두면 뒤늦게 도착한 진짜 - emit이 삼켜져 **하류가 그 에포크를 영영 못 받는다.** 값은 맞는데 통지가 - 죽는, 2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열의 실패 모드다 - (`archive/invalidate-dedup-propagation-reversed.md`). - - **확정 해법**(사용자 제안, §2가 소스): 판정 기준을 **둘로 나눈다** — - *"'내 값이, 상류의 상태로 하여금 사용 가능하다' 를 보는 테이블과, '내가 - emit을 받을 때, 그걸 다시 던져야하는지 봐야하나' 를 보는걸 나누는거죠."* - 순회는 `sourceCountMap`만 올리고 **emit은 안 한다**. 나중에 진짜 emit이 - 오면 count는 같지만 `sourceEmitMap`이 달라 **그때 정상적으로 전파**된다 - (*"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*). 결과: - - 통지가 죽지 않는다. - - 순회가 emit을 안 하므로 **게이트를 새게 하는 경로가 아예 없다** — - `source = nil` 규약도, "게이트를 에포크 경계로" 같은 계약 반전도 필요 없다. - - `Get()`은 순회 덕에 항상 신선하고, 순회가 `sourceCountMap`을 실제로 - 올리므로 **다음 `Get()`이 같은 차이를 재발견해 또 재계산하는 낭비도 없다** - (에이전트가 냈던 대안 (c)의 유일한 약점이 여기서 사라진다). - - **같이 검토된 대안 — `rawEmit`을 태우고 `nil`을 던지는 안**(사용자 제안 1안): - 순회가 만든 emit을 **상류가 부르는 것과 같은 내부 진입점(`rawEmit`)** 으로 - 흘려서, 그 노드에 `Gate`가 껴 있으면 자연히 게이트를 타게 하고, 순회가 찾은 - 변경은 여러 소스일 수 있으니 하류엔 `source = nil`을 던진다는 안. - **구조 위생 쪽은 채택할 만하다** — 상류 emit과 내부 발생 emit이 같은 진입점을 - 쓰면 게이트가 붙은 노드에서 두 경로가 갈리지 않는다. 다만 **이 문제의 해법으로는 - 위 2안이 더 낫다**: - - 막고 있는 게이트는 보통 **순회하는 노드 자신이 아니라 상류**에 있다. 자기 - `rawEmit`을 태워봐야 상류 게이트는 안 물어보므로 **누출이 그대로 남는다.** - - `nil` emit은 받는 쪽마다 **전체 순회를 강제**하고, 그 순회가 다시 count를 - 올리면 같은 "영구 침묵" 문제가 하류에서 재발한다(연쇄). - - 2안은 순회가 애초에 emit을 안 하므로 두 문제가 **생기지 않는다.** - - **부수 확인**: `blocker:OffWithoutEmit()`처럼 emit 없이 푸는 경로에서 - `sourceEmitMap`이 뒤에 남아도, 그 소스의 **다음 진짜 emit**이 위 1번(둘 다 - 다름)으로 걸려 정상화된다 — 별도 조치가 필요 없다. - -4. **`:Emit()`(값을 제자리에서 mutate하고 알리는 경로)** — count만 올리면 되므로 - 그대로 동작한다. 확인만. -5. **weak 키.** `[source] -> count`를 weak-key로 두면 source가 죽을 때 항목이 - 사라진다. 다만 `base/relate-plan.md`가 경고하듯 **값이 키를 되참조하면 안 - 된다** — count는 숫자라 문제없다(테이블 identity 방식을 택하면 그 테이블이 - source를 참조하지 않게 할 것). -6. **[2026-08-21 반영 완료] `Get`의 계약 문구.** 사용자 지적(*"Get 이 항상 최신 - 상태를 가져온다라는 말이 여기서 무력화되는 부분"*)의 방향은 사실 **반대**였다 - — 옛 모델이 "최신"을 못 지키고 있었고 이 안이 그걸 지키게 만든다. - `base/source-state-plan.md`의 전파 모델 절은 **채택과 함께 그렇게 다시 - 썼다.** - -7. **[2026-08-21 전량 해소] 두 맵의 초기값·병합·재계산 시 갱신 범위.** - - **(a) 재계산 후 `sourceCountMap` 갱신 범위** — **자기가 읽은 상류 전부를 - 갱신한다**(사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 - 전부 업데이트 하는건 맞아보입니다"*). §2에 반영. - - ⚠️ 이 항목을 제기할 때 든 근거("`A:Set(); Z:Set()`이면 같은 값을 두 번 - 계산한다")는 **틀렸었다.** 전파는 동기라 `A:Set()`의 파동이 **완전히 - 끝난 뒤에** `Z:Set()`이 시작되므로, 그 사이 재계산은 `Z`의 옛 값을 읽는 - 게 맞고 통지가 두 번 나는 것도 맞다(사용자 정정: *"싱크라 set 의 emit 이 - 전부 전파 된 다음 z:set 이라 두번 나는게 맞긴 해요"*). 전부 갱신이 - 실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가 `Get()`으로 - 앞당겨 읽는 경우**뿐이다. - - **(b) 새 노드의 두 맵 초기값** — `sourceEmitMap`은 **비우고**, - `sourceCountMap`은 **전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`**. - §2의 "노드가 생길 때의 초기값" 문단이 소스. - - **(c) `:With` 병합 규칙** — (b)로 인해 **필요 없어졌다.** - -## 6. 곁가지 — 폴링용 sugar - -사용자 제안: *"폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다. -옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게 -Ref 로 변환해주는 등"*. 즉 "항상 최신 값을 필드로 들고 있는 박스"를 만드는 -순수 슈가. **이건 이 문서의 결정과 독립**이고(에포크를 채택하든 안 하든 쓸 수 -있다), `research/operator-sugar-plan.md`의 콤비네이터 계열에 더 가깝다 — -채택되면 그쪽으로 옮길 것. - -## 7. 구현 시 확인할 것 (채택 확정 후 남은 실무 항목) +## 8. 구현 시 확인할 것 - **역전된 두 서술은 `archive/always-propagate-no-dedup-superseded.md`에 있다** — "emit은 자기 `invalid`와 무관하게 **항상** 전파된다"와 "quad가 접지 않는 것은 - 중복 *통지*뿐이다". 지금 계약은 **"`invalid`로는 절대 안 접고, 같은 소스의 같은 - 에포크가 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면 2026-08-14에 - 폐기된 "영구 침묵" 버그로 되돌아간다(§3). -- **선언 안 된 의존성에 대한 UB 조항은 안 만든다** — §5의 1번(사용자 기각). -- **`luau-test` 스파이크 하나**: §1의 다이아몬드를 그대로 짜서 (a) 에포크 없이는 - 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 사라지는지 대조. -- **`sourceEmitMap`은 희소 `pending` 구현으로 시작해도 된다** — §2의 구현 메모. -- 곁가지였던 폴링 슈가는 §6 그대로 — 이 결정과 독립이고 `operator-sugar-plan.md` - 계열로 남는다. + 중복 *통지*뿐이다". 지금 계약은 **"`invalid`로는 절대 안 접고, 같은 `Epoch`의 + 같은 리비전이 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면 + 2026-08-14에 폐기된 "영구 침묵" 버그로 되돌아간다. +- **선언 안 된 의존성에 대한 UB 조항은 안 만든다.** 에이전트가 "새 모델은 더 + 강한 약속을 하니 예외를 UB로 못 박아야 한다"고 제안했으나 **사용자가 기각**: + *"그건 아니다. 이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을 + 던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고 + 생각한다."* — 선언 안 한 Source를 클로저로 읽는 건 **옛 모델에서도 똑같이 + stale**이었고 이 변경이 악화시키는 게 없다. +- **동적 의존성**은 `valueEpochMap`이 보수적 상위집합이 된다 — 틀리진 않고 + 재계산이 조금 더 잦아질 뿐이다. +- **`Source:Emit()`**(값을 제자리에서 mutate하고 알리는 경로)은 `Revision`만 + 갱신하면 그대로 동작한다(`:Set()`과 같은 연산 — §2). +- **`blocker:OffWithoutEmit()`처럼 emit 없이 푸는 경로**에서 `emitEpochMap`이 + 뒤에 남아도, 그 `Epoch`의 **다음 진짜 emit**이 §4의 1번(둘 다 다름)으로 + 걸려 정상화된다 — 별도 조치가 필요 없다. +- **`luau-test` 스파이크 하나**: §1의 다이아몬드를 그대로 짜서 (a) 리비전 비교 + 없이는 섞인 값이 실제로 관측되는지, (b) 비교를 넣으면 사라지는지 대조. +- **기각된 대안 — 게이트를 에포크 경계로.** 게이트가 **자기 자신을 하나의 + `Epoch`로** 내세우고(자기 `Revision`은 유보를 풀 때만 갱신) 하류가 상류 + 원천 대신 **그 게이트만** 추적하게 하는 안. 배치를 나를 필요가 없어져 + 페이로드가 단순해지지만, **`base/gate-plan.md` 3번의 공개 계약을 + 정면으로 깬다** — *"게이트를 통과하지 않은 값도 `:Get()`으로는 보인다."* + - **깨지는 경로**: 게이트가 붙들고 있는 동안 하류가 `:Get()`을 부르면 + §4의 재계산 판정이 `valueEpochMap:Refresh()`를 돈다. 그 맵이 추적하는 + 게 게이트 하나뿐이면 게이트의 `Revision`은 아직 안 올랐으므로 **"나는 + 최신"으로 오판하고 옛 캐시를 반환**한다. 지금처럼 원천 `Epoch`를 직접 + 추적해야 순회가 상류의 실제 리비전을 보고 스스로 낡음을 알아챈다. + - 즉 게이트는 **emit만 가로채지 값을 가리지 않는다**는 성질이 이 문서의 + 핵심 보장(`:Get()`은 항상 최신)과 한 몸이다. `Blocker` + (`base/blocker-plan.md`의 "`:Get()`엔 영향 없음")와 + `base/debounce-throttle-plan.md` §4도 같은 계약 위에 서 있으므로, + 이걸 뒤집으면 셋이 같이 무너진다. +- **기각된 대안 — 순회가 만든 emit을 `rawEmit`으로 흘리고 `nil`을 던지는 안.** + 구조 위생은 좋았으나(상류 emit과 내부 발생 emit이 같은 진입점) 이 문제의 + 해법으로는 못 쓴다 — (a) 막고 있는 게이트는 보통 **순회하는 노드 자신이 + 아니라 상류**에 있어 누출이 그대로 남고, (b) `nil` emit은 받는 쪽마다 전체 + 순회를 강제해 같은 "영구 침묵"이 하류에서 연쇄로 재발한다. 지금 안은 순회가 + 애초에 emit을 안 하므로 두 문제가 **생기지 않는다.** +- **곁가지 — 폴링용 슈가.** 사용자 제안: *"폴링을 위해서는 Apply(Realtime()) + 같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 + 있게 해주는것. 단순하게 `Ref` 로 변환해주는 등"*. **이 문서의 결정과 + 독립**이고 `research/operator-sugar-plan.md`의 콤비네이터 계열에 속한다. diff --git a/.claude/base/tween-plan.md b/.claude/base/tween-plan.md index f033f9a..4c847c8 100644 --- a/.claude/base/tween-plan.md +++ b/.claude/base/tween-plan.md @@ -185,7 +185,7 @@ State>`가 나옴 — Modifier/State/Source/StoreBind 코드엔 **핸들러 계층 UB 체크와도 안 부딪힘** — `Tween`는 `Ref`/`Observer`/ `Slot`류처럼 dispatch 참가자(`process`를 가진 Handler에 매칭되는 값)가 아니라 `None`/ -`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/ +`Tag`처럼 순수 raw 데이터 값(별도 `TweenBrand`)이라, Modifier 필드/ `State`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에 안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch 참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래 @@ -373,7 +373,7 @@ quad-roblox 레벨 편의 함수라 base 계약에 영향 없음. ## 패키지 경계 — `Tag`가 이미 밟은 것과 같은 분리 (2026-08-10 세션 확정) - **quad-base**: `Tween.luau` — 값 타입(`Tween(opts)` 팩토리, `isTween` - predicate/`TweenTag` Brand)만. 엔진 무관. + predicate/`TweenBrand` Brand)만. 엔진 무관. - **quad-roblox**: `Handlers/Property.luau`(기존 프로퍼티 세팅 로직에 `isTween` 분기 + 3-상태 릴레이션 저장 + override 정책 추가) + `Animate.luau`(편의 콤비네이터, 신규). diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index d1815a9..6474184 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -44,10 +44,16 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | 환경 | 필요한 것 | 해당 파일 | |---|---|---| -| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 13(런타임 부분), 17, 18, 19, 20 | -| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13(타입 부분), 14, 15, 16 | +| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 17, 18, 19, 20, 22 | +| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13, 14, 15, 16, 21, 23 | | **Roblox Studio** | 별도 계정으로 로그인(`HUMAN_TODO.md` 1번, `SAFETY.md` 준수) | 10 | +**[2026-08-21 정정]** 이 표가 `21`/`22`/`23`을 빠뜨린 채 `13`을 "런타임 부분/ +타입 부분"으로 쪼개 적고 있었다 — `13`의 런타임 절반은 2026-08-19에 `22`로 +분리돼 나갔으므로 지금 `13`은 **순수 타입 스파이크**다. **각 파일이 어느 +환경에서 도는지는 파일 맨 위 주석이 소스**이고, 이 표는 그걸 환경별로 묶어 +보여주는 편의 색인일 뿐이다 — 파일이 늘면 여기도 같이 고칠 것. + **16은 특히 `type function`이라는 비교적 최근/계속 진화 중인 Luau 기능을 쓰므로, luau-analyze 버전이 오래되면 아예 문법 자체를 못 알아볼 수 있음** — 그 경우는 실패가 아니라 "이 Luau 버전에서 type function 자체가 아직 @@ -88,7 +94,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | | `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `dispatch-core-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) | | `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 | -| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지 | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 `Brand` 절 | +| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지. **[2026-08-21] `rewrite-required/`로 이동** — 파일이 직접 구현해 쓰는 `Brand.set`/`Brand.get`이 인스턴스 브랜드 재작성으로 역전된 옛 API가 됐다(검증 대상 자체는 그대로 유효, 상태와 재작성 지침은 `STATUS.md`가 소스) | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 "⭐ 구현 — 인스턴스 브랜드" 절 | | `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 | ## 공통 유틸리티 diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index d94bae8..3c13a00 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -1,6 +1,11 @@ # 스파이크 상태판 — **폴더가 곧 상태** -> 마지막 갱신: **2026-08-21** — QA 4라운드 `F-4-1`로 `Dispatch.drive`의 +> 마지막 갱신: **2026-08-21** — `Brand`가 **인스턴스 브랜드**로 전면 +> 재작성되면서(`base/brand-plan.md`) 옛 `Brand.set`/`Brand.get`을 직접 구현해 +> 쓰던 `22`가 `done/` → `rewrite-required/` 이동(검증 대상인 `isRef`/`isPreRef` +> 포함 관계 자체는 그대로). 같은 날 `Epoch`/`EpochMap` 승격도 있었으나 그건 +> 이미 `rewrite-required/`에 있는 `05`의 지침에 반영돼 있다. 직전 갱신도 +> 같은 날 — QA 4라운드 `F-4-1`로 `Dispatch.drive`의 > props 순회가 **단일 일반화 `for`**로 정정되면서, 두 루프 버전을 검증하던 > `01`이 낡아 `done/` → `rewrite-required/` 이동(검증 대상인 순서 계약 > 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이 @@ -40,9 +45,9 @@ | 폴더 | 뜻 | 개수 | 누가 처리 | |---|---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | -| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 | +| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 7 | 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | -| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 17 | — | +| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — | **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 @@ -67,7 +72,10 @@ `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 아님(계약 자체는 위에서 이미 확정됨). -## 🟠 `rewrite-required/` — 스파이크가 낡음 (5건) +## 🟠 `rewrite-required/` — 스파이크가 낡음 + +(개수는 위 표와 폴더가 소스 — 여기서 다시 세지 않는다. 예전엔 이 제목이 +개수를 들고 있다가 실제와 어긋난 적이 있다.) **[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 @@ -86,6 +94,15 @@ 통과 상태로 `done/`에 두면 `01`은 구현이 안 하는 두 루프 순회를, `05`는 **이제 접히는 중복 통지가 안 접힌다는 것**을 "검증됨"으로 오독하게 된다. +**[2026-08-21 후속] `22`도 같은 이유로 합류** — `Brand`가 **인스턴스 +브랜드**로 전면 재작성되면서(`base/brand-plan.md`) 이 스파이크가 직접 구현해 +쓰는 `Brand.set`/`Brand.get`/`XxxTag`가 **역전된 옛 API**가 됐다 +(`archive/brand-shared-registry-reversed.md`). **검증 대상 자체는 그대로 +유효하다** — `isPreRef`/`isPostRef`가 서로 배타적 형제이고 둘 다 `isRef`엔 +`true`라는 포함 관계는 재작성 후에도 안 바뀌었다. 옮기는 이유는 결론이 +틀려서가 아니라, 통과 상태로 `done/`에 두면 **구현자가 그 파일의 `Brand` +구현을 참고 모델로 오독**하기 때문이다. + | 파일 | 상태 | 무엇을 고쳐야 하나 | |---|---|---| | `01-two-pass-array-hash-order.luau` | 옛 형태 기준으로는 ✅ 통과였음 | 숫자 `for` + 일반화 `for` **두 루프**로 짜여 있는데, 구현은 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — Luau의 일반화 `for`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 | @@ -93,6 +110,7 @@ | `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | | `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | +| `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) | | `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | ## ⚪ `not-run/` — 이 환경에서 못 돌림 @@ -104,10 +122,15 @@ |---|---| | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | -## ✅ `done/` — 통과 or 판정 끝 (17건) +## ✅ `done/` — 통과 or 판정 끝 -**지금 `done/`에 있는 런타임 스파이크는 9건**(`02`/`03`/`06`/`07`/`11`/`17`/ -`18`/`20`/`22`), **전원 통과**(crash 0 / FAIL 0). 나머지 8건은 타입 스파이크다. +(개수는 위 표와 폴더가 소스 — 여기서 다시 세지 않는다.) + +**지금 `done/`에 있는 런타임 스파이크는 `02`/`03`/`06`/`07`/`11`/`17`/`18`/`20`, +전원 통과**(crash 0 / FAIL 0). 나머지는 타입 스파이크다. +**[2026-08-21] `22`는 여기서 빠졌다** — `Brand` 인스턴스 브랜드 재작성으로 +파일이 쓰는 `Brand.set`/`Brand.get`이 옛 API가 되어 `rewrite-required/`로 +이동(검증 대상 자체는 유효, 위 표의 재작성 지침 참고). **[2026-08-21 정정]** 여기 "런타임 12개"라고 적혀 있었는데, 그 산술이 이미 `rewrite-required/`로 나간 `04`/`10`/`19`까지 포함한 옛 총계에서 이어져 온 것이라 실제와 안 맞았다(두 번째 `/code-review high` 발견). — **[열네 번째 세션] `04`/`19`는 @@ -126,7 +149,6 @@ | `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 | | `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) | | `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 | -| `22-runtime-ref-preref-postref-brand` | **[2026-08-19 신규]** `isPreRef`/`isPostRef`가 서로 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)임을 확인, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라냄 | **타입 스파이크 중 판정이 끝나 더 할 일 없는 것**: diff --git a/.claude/luau-test/done/22-runtime-ref-preref-postref-brand.luau b/.claude/luau-test/rewrite-required/22-runtime-ref-preref-postref-brand.luau similarity index 100% rename from .claude/luau-test/done/22-runtime-ref-preref-postref-brand.luau rename to .claude/luau-test/rewrite-required/22-runtime-ref-preref-postref-brand.luau diff --git a/.claude/project-context.md b/.claude/project-context.md index 7bb08e8..5682ee8 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -41,6 +41,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 - `.claude/reference/` — **[2026-08-07 신설]** base처럼 확정된 건 아니지만 base 문서가 근거로 인용하는 온디맨드 참고 자료(v1 내부 동작 스냅샷, Fusion/Vide 비교 리서치) — 항상 읽을 필요는 없고 인용될 때만 열어볼 것. + **[2026-08-21 확장]** 확정된 결정의 **근거 기록**(그 결정이 왜 그렇게 + 났는지)도 여기 둠 — `research/`를 떠났지만 `archive/` 대상은 아닌 것들. + 어떤 문서가 있는지는 `.claude/README.md`가 소스. - `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. 전부 후순위. **어떤 문서가 있는지·우선순위가 뭔지는 여기서 세지도 나열하지도 않고 `.claude/README.md`의 `research/` 표로 미룸**(개수뿐 아니라 파일명 diff --git a/.claude/qa-request/pre-implementation-qa-round4-followup.md b/.claude/qa-request/pre-implementation-qa-round4-followup.md index 7e3a806..6100433 100644 --- a/.claude/qa-request/pre-implementation-qa-round4-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round4-followup.md @@ -9,7 +9,7 @@ 않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록. **[2026-08-21] 3차 처리 — 아래 G절.** -`F-4`는 전부 닫혔고(`F-4-3`은 `research/slot-attach-decomposition.md`로 넘어감), +`F-4`는 전부 닫혔고(`F-4-3`은 `reference/slot-attach-decomposition.md`로 넘어감), 그 시점에 남았던 건 `F-3`의 (1)~(3)과 "+"였다(요약은 `G-3`, H절에서 닫힘). **[2026-08-21] 2차 처리 — 아래 F절.** @@ -1030,7 +1030,7 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" = 맞을지도. attachSlot 의 기능이 너무 다양해진게 문제같음. 이 부분에 있어서는 확장 논의를 하게 준비해두자."* -→ **`research/slot-attach-decomposition.md` 신설.** `setLength`를 어느 줄에 +→ **`reference/slot-attach-decomposition.md` 신설.** `setLength`를 어느 줄에 둘지 고르는 문제가 아니라 분해 문제라는 진단에 동의하고, 논의가 바로 시작될 수 있게 재료만 모아뒀다(**아무것도 확정 안 함**, `base/slot-plan.md`가 여전히 정본): @@ -1141,7 +1141,7 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" = ## H-4. `attachSlot` 분해 (확정·반영) -`research/slot-attach-decomposition.md`를 **확정**으로 승격하고 의사코드를 +`reference/slot-attach-decomposition.md`를 **확정**으로 승격하고 의사코드를 `base/slot-plan.md`에 반영했다. 결론은 후보 **(B)**: - **`materializeSlotTree(slot, physicalTarget, ownerKey, position)`** — 부기만. diff --git a/.claude/qa-request/pre-implementation-qa-round5-followup.md b/.claude/qa-request/pre-implementation-qa-round5-followup.md index ffec3d2..be325ec 100644 --- a/.claude/qa-request/pre-implementation-qa-round5-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round5-followup.md @@ -724,7 +724,7 @@ G절에서 신설한 `_baseObserver`(자기 `Offset`을 관측해 자식 offset | 2 | **`effect-plan.md`가 자기 자신과 모순** | 문서 최상단 시그니처가 `Effect(fn, state?)`이고 "**`Effect(fn, a, b, c)`처럼 trailing args로 받는 sugar는 의도적으로 안 만듦**"이라는 확정 문단이 그대로 남은 채, 같은 파일 아래에 `C-6`의 `Effect(fn, ...deps)` 절이 추가돼 있었다 — **역전 배너 없이 정반대 두 서술이 공존** | 시그니처를 `Effect(fn, ...deps)`로 갱신, 옛 문단에 🔄 역전 배너 + **옛 근거가 무너진 이유 둘**(`Ref`는 `:With`로 합칠 수 없어 애초에 의존성이 될 방법이 없었다 / 구독을 따로 걸면 합치는 노드 자체가 안 생겨 "감출 비용"이 없다) | | 3 | **`indexOfRaw`가 어디에도 정의돼 있지 않음** | 신설 `rawReplace`가 쓰는데 문서에 없었다. 구현자가 공개 `IndexOf`를 그대로 쓰면 **래핑된 자리에서 어긋난다**(그건 언래핑 기준 비교) | `indexOfRaw` 한 줄 정의 + `IndexOf`와의 차이 명시 | | 4 | **index/element 혼용 캐비엇 목록이 안 늘어남** | 기존 캐비엇이 `rawRemove`/`rawUnmount`만 나열하는데, 이번에 같은 불일치를 물려받은 `rawDetach`/`releaseElement`/`rawReplace`가 빠져 있었다 | 캐비엇에 셋 추가 | -| 5 | **`mountSlotTree`의 전제가 코드에 없었다** | `acc = slot.Offset:Get()`이 정확하려면 **`materializeSlotTree`가 먼저 돌아야** 하는데, 분해된 함수의 계약이 주석에 없었다(`research/slot-attach-decomposition.md`에 "중간 상태 처리"가 열린 항목이라 더 위험) | ⚠️ 전제 주석 추가 | +| 5 | **`mountSlotTree`의 전제가 코드에 없었다** | `acc = slot.Offset:Get()`이 정확하려면 **`materializeSlotTree`가 먼저 돌아야** 하는데, 분해된 함수의 계약이 주석에 없었다(`reference/slot-attach-decomposition.md`에 "중간 상태 처리"가 열린 항목이라 더 위험) | ⚠️ 전제 주석 추가 | | 6 | **`Replace`의 `destroyOld`가 base 본문에 없었다** | `rawReplace`에 인자가 있는데 CRUD 표/산문이 그 존재를 설명 안 함. followup은 처리 기록이지 소스가 아니다(이번 라운드 `CR-1`의 교훈 그대로) | 공개 `Replace`는 항상 파괴 / `:List`만 `_owned`를 넘긴다를 산문에 명시 | 부수로 인덱스도 같이 정리했다 — **`Gate` 이름이 `question.md` 1번(용어 diff --git a/.claude/qa-request/pre-implementation-qa-round5.md b/.claude/qa-request/pre-implementation-qa-round5.md index d7029d4..b56ee87 100644 --- a/.claude/qa-request/pre-implementation-qa-round5.md +++ b/.claude/qa-request/pre-implementation-qa-round5.md @@ -45,7 +45,7 @@ | `QT` | quad-types / 버전 체크 (4라운드 문항 없음) | `base/quad-types-plan.md` | | `IM` | **실제 커밋된 M1 코드** (문서 아님) | `quad-base/src`, `quad-types/src`, `type-version-check/src` | | `DE` | `Detach`/`_detached`/`KeyGone`/`Owned` (신규 확정) | `base/slot-plan.md` | -| `AS` | `attachSlot` 분해 (신규 확정) | `base/slot-plan.md`, `research/slot-attach-decomposition.md` | +| `AS` | `attachSlot` 분해 (신규 확정) | `base/slot-plan.md`, `reference/slot-attach-decomposition.md` | | `DC` | 디스패치 코어 심화 | `base/dispatch-core-plan.md` | | `SS` | Source/State 심화 | `base/source-state-plan.md` | | `LC` | 생명주기 배관 심화 | `base/lifecycle-pattern.md`, `base/relate-plan.md` | diff --git a/.claude/question.md b/.claude/question.md index cfbd300..23d98d4 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -40,40 +40,6 @@ `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, `archive/canexecute-inst-arg-reversed.md` 하단 addendum 참고.) -- **[해소됨, 2026-08-18] `DI` → `D`(Declarative) 확정** — 원문과 근거는 - `archive/question-resolved.md`. 요지: `DI`가 "Dependency Injection"과 - 완전히 겹쳐 실제 오해 전례가 있었고, `D`는 Instance 전용이 아닌 declare - 요소 전반으로 확장 가능하며 `D.FrameModifier`류 타입 프리픽스도 짧게 - 유지된다. 미뤄뒀던 유일한 사유(한 글자 식별자의 검색성/자기설명력)는 - "문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로 - 보완하기로 같이 확정. 코퍼스 반영 완료 — - `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절. -- **[해소됨, 2026-08-19] `PopOnly` → `Detach` 확정** — 원문과 근거는 - `archive/question-resolved.md`. 요지: 이미 있는 - `Extract`(호출자가 직접 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데, - `Detach`는 "화면(부모 계층)에서만 떼어낼 뿐 관리 주체는 여전히 - reconcile"이라는 뜻이라 `Extract`의 "소유권을 통째로 넘긴다"와 자연스럽게 - 구분되고, `nil`(파괴)과의 대비도 더 직접적으로 드러남. 공개 표면 위치도 - 같이 확정 — `Slot`이 함수(팩토리)라 `Slot.Detach`처럼 붙이려면 - callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라 - **패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`의 - "`nil` 리턴은 파괴가 기본" 절. -- **`Gate`(2순위, 2026-08-21 신설)**: `Blocker`/`Debounce`/`Throttle` 아래의 - 공용 게이트 노드 이름. 사용자 지적 — *"프리미티브 명을 Gater? 뭔가 이상하게 - 들어간다는게 약간의 문제"* — 코퍼스가 `Blocker`/`Modifier`/`Observer`처럼 - `-er`를 많이 쓰는데 `Gater`는 영어로 어색하다. 에이전트 권고는 **`Gate` - 그대로**(`gate`는 이미 행위자가 아니라 **장치**를 가리키는 명사라 `-er`가 - 불필요 — `Source`/`Ref`/`Slot`/`Tween`도 같은 계열), 대안 후보는 - `Valve`/`Relay`. **[2026-08-21 해소] `Gate`로 확정**(탑레벨 생성자를 안 - 만들고 `state:Gate(setup)` 메소드로 가면서 `Gater` 문제 자체가 사라짐 — - 메소드 자리에서는 `:With`/`:Compute`와 나란히 자연스럽다). 노드 타입 이름은 - `GateNode`. `base/gate-plan.md`의 1번이 소스. -- **`Epoch`의 리비전 필드 증가 방식(3순위, 2026-08-21 신설)**: `bit32` 랩 - (uint32 랩어라운드라 double 포화가 없음, 사용자가 염두에 둔 쪽) vs 평이한 - `+1`(할당·연산 더 쌈, 포화가 `2^53`이라 `bit32`의 `2^32` 랩보다 충돌 거리가 - 오히려 넓음). **둘 다 도달 불가능이라 실질 위험은 없고 취향 문제다.** - 필드 이름은 `Revision` 권고(코퍼스 공개 필드 PascalCase 관례). - `research/epoch-brand-composition.md` §4의 4번. - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. @@ -109,13 +75,18 @@ 안 바꾸고 대기열에만 올림(의사코드는 새로 쓰는 자리부터 `nextValue`를 쓰기 시작했음). - **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임 - nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를 - branded 타입 전부로 일반화) — `brand-plan.md`의 `Brand` - 절에서 동작/구현 방식은 확정, "OOP 인스턴스의 클래스명을 얻는 느낌"을 - 전달할 더 나은 이름이 있는지가 열린 질문(사용자가 직접 제기) — `Tag`는 - 이미 quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로 + nominal 타입 판별 통합 메커니즘 — `base/brand-plan.md`에서 동작/구현 + 방식은 확정, 이름만 열린 질문(사용자가 직접 제기). `Tag`는 이미 + quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로 "type namespace"류를 사용자가 검토했으나 미확정. **(2026-08-08 재확인)** 사용자가 다시 짚었지만 여전히 미정. + - **[2026-08-21 갱신] 표면이 바뀌어서 "OOP 인스턴스의 클래스명을 얻는 + 느낌"이라는 원래 요구는 이제 안 맞는다** — 인스턴스 브랜드로 재작성되며 + 역조회(`Brand.get`)가 없어졌고, 지금 하는 일은 **집합 멤버십** + (`SomeBrand:is(x)`)이다. 이름 후보도 그 방향으로 다시 볼 것. + - **메소드 케이싱도 같이 볼 것** — `:register`/`:is`가 소문자인데 quad + 공개 표면 관례는 PascalCase다(`:Get`/`:Set`). base 내부 유틸이라 지금은 + 기존 `Brand.set`/`Brand.get` 관례를 이었지만, 이름을 정할 때 같이 정리. - **`Tag`/`Added`/`Removed`/`Merged`(3순위, 사소함, 2026-08-08 세 번째 세션 array-part 값 객체 재설계 때 확정된 API 표면)**: `base/tag-plan.md`가 "열린 질문 없음, 값 모양/메커니즘/retract/패키지 배치 전부 확정, 이름 @@ -142,164 +113,6 @@ ## 3. 낮은 우선순위 — 열려 있지만 급하지 않음 -- **[해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] M2가 M3의 `Blocker.luau`에 - 구조적으로 의존하던 순서 문제 — "게이팅 먼저"로 결정.** 사용자 판정: - *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가 - 필요함."* 즉 로드맵 순서를 유지하지 않고 게이팅을 M2로 앞당긴다. **다만 - 앞당기는 대상이 `Blocker` 그 자체가 아니라 그 아래의 공용 `Gate` 노드로 - 바뀌었고**(같은 라운드 `DT-4`), 그 표면/이름이 미정이라 **새 열린 항목으로 - 이어진다 — 바로 아래 `Gate` 항목.** 아래는 해소 전 서술: 이대로 각주만 두고 - 로드맵 순서를 유지할지, - `Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로 - 앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법 - 때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가 - `getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작 - `Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직 - 없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔 - 임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전 - 필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`의 - "ROADMAP.md 마일스톤 정합성" 절. -- **[해소, 2026-08-21 구현 전 QA 5라운드 H절] `mountInst`의 삽입 위치 + 중첩 - offset 결함 — `Dispatch.getOffsetAt(ownerKey, i)` 신설로 확정.** - `setOffsetSource(None)`은 얼리 리턴하고, 숫자가 필요한 쪽이 `getOffsetAt`을 - 직접 부른다(사용자안 — 병렬 배열을 안 늘리는 pull 방식). 베이스는 `isSlot` - 분기의 정체가 "베이스가 있나"임을 명확히 했고(베이스는 따로 저장하지 않고 - Slot의 `.Offset`을 그대로 읽는다 — 최상위 물리 inst는 항상 0), 중첩 Slot은 - 자기 `Offset`을 관측해 깊은 전파를 한다. 반영하다 **재마운트가 `Offset` Source를 새로 만들던 결함**도 - 같이 잡았다(identity 재사용). 상세는 - `qa-request/pre-implementation-qa-round5-followup.md`의 H절. - 아래는 열려 있던 시점의 서술: 둘이 한 덩어리다: - (a) `setOffsetSource`의 `None`이 "아무것도 안 차지함"과 "발행 채널 없음" - 두 뜻을 겸하고 있어 **plain 요소의 offset 숫자가 계산조차 안 된다**(그래서 - DOM류 백엔드가 삽입 위치를 알 방법이 없다 — 사용자 지적), (b) `recompute`가 - `sum = 0`에서 시작해 `ownerKey.Offset`을 안 읽으므로 **depth ≥ 2에서 중첩 - Slot의 자식 offset이 부모 베이스만큼 어긋난다**(이번에 발견, 지금까지 depth 1만 - 써서 안 드러났음). 제안은 `bk.offsetList` 신설(항상 숫자 계산) + - `mountInst(target, element, index)` + `recompute`의 `base` 시드 + 중첩 Slot이 - 자기 `Offset`을 관측해 재계산하는 구독 하나 — - `qa-request/pre-implementation-qa-round5-followup.md`의 G절이 소스. -- **[해소, 2026-08-21] offset이 바뀌면 이미 배치된 물리 노드를 옮겨야 하는가 — - 아니오.** **사용자 확정**: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고 - 빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"* — DOM류는 삽입/삭제 - 자체가 뒤 형제를 물리적으로 밀고 당기므로, **이미 놓인 노드를 다시 옮길 일이 - 없다.** offset 숫자는 그 자리가 **다음에** insert/remove할 때 쓰는 것뿐이라는 - 기존 서술이 그대로 맞다. 이게 성립하려면 백엔드 op이 **아토믹한 최소 단위** - (`mountInst`/`unmountInst`/reposition)여야 한다는 게 같이 확인된 요구사항. - 아래는 열려 있던 시점의 서술: `mountInst(target, element, - index)`가 **삽입 시점의 위치만** 받는 일회성 호출이라, 이미 마운트된 요소의 - 물리 위치는 offset이 바뀌어도 갱신되지 않는다. Roblox는 `LayoutOrder`가 - 프로퍼티라 `updateFn`이 반응형으로 처리하면 되지만 **DOM은 물리 순서 자체가 - 배치**다. `dispatch-core-plan.md`는 "quad-web의 offset 핸들러는 no-op이고 숫자는 - *다음에* 스스로 insert/remove할 때만 쓴다"고 적어뒀는데, 앞 형제의 길이가 변해 - 뒤 형제들의 offset이 밀리는 흔한 경우에 **이미 놓인 노드를 옮길지**가 그 서술만 - 으론 안 갈린다. 선택지는 (a) 옮긴다(백엔드가 offset 변경을 관측해 재배치), - (b) 안 옮긴다(그러면 DOM에선 순서가 실제로 어긋남), (c) `:List`가 재조정 때 - 필요한 것만 명시적으로 다시 `mountInst`. quad-web이 실제로 생길 때까지 미룰 수 - 있으나, **계약을 지금 정해두지 않으면 M6 구현이 (b)를 전제로 굳는다.** -- **[해소, 2026-08-21] `:List` 재조정의 `getOffsetAt` 비용 — 접두합 캐시로.** - **사용자 제안 채택**: *"getOffsetAt 은 compute 된걸 캐시해도 될듯. length - 변경되는 뒤는 캐시가 무효화되도록 shouldRecomputeAfter 등을 둬서 특정 인덱스 - 초과부는 offset 다시 계산하고, 해당 값 위치 자체는 유효하므로 그것을 통해 더 - length 를 이어붙이면 될듯."* → `bk.offsetCache` + **`bk.invalidAfter`**("여기까지는 유효")로 반영 - (`base/dispatch-core-plan.md`). **무효화 규칙은 하나** — - `invalidAfter = min(invalidAfter, i)`(`setLength`도 splice도 자기 인덱스까지, - 베이스 변경만 `0`). `recompute`도 이 캐시 위에 얹혀 O(N)이라 - **"캐시를 누가 채우나"라는 갈래가 없어졌다**(사용자 정정: 함수를 나눌 이유가 - 없다). 아래는 열려 있던 시점의 서술: - `settle`이 키마다 `rawAdd`/`rawReplace`를 부르고 그 각각이 `getOffsetAt`(O(i))을 - 부르므로, 이미 마운트된 리스트의 데이터가 통째로 바뀌면 **O(N²)**다(최초 마운트는 - `_mounted == false`라 얼리 리턴이 막아준다). `DC-9`에서 `setOffsetSource`의 즉시 - 계산이 O(N²)인 걸 "배치 등록 1회니 감수"로 판단했지만 **이건 매 reconcile이라 - 빈도가 다르다.** 해법은 있다 — reconcile이 `pos`처럼 **절대 offset도 러닝 - 누적**으로 들고 다니면 O(n)(그게 `mountSlotTree`가 이미 하는 방식). 그렇게 - 할지, 아니면 실측 전엔 그냥 둘지 판단 필요. -- **[해소, 2026-08-21 같은 날] 게이트가 유보했다 내보내는 emit이 싣는 출처 — - `emit(self)` + 흡수 집합.** `GateNode`가 흡수한 소스를 `withheld` 집합에 - 들고 있다가, 풀 때(=flush) **집합을 그 자리에서 떼어내 새 테이블로 스왑하고** - 자기를 출처로 그 배치를 하류에 넘긴다. 하류는 출처가 게이트면 그 집합의 소스들에 평소 규칙을 - 적용한다. **`setup` 시그니처는 안 바뀐다** — 집합을 채우는 건 정책이 아니라 - 노드이기 때문. `base/gate-plan.md`의 4번, `base/state-epoch-plan.md` §2. -- **[해소, 2026-08-21 같은 날] State 에포크 — 새 노드의 두 맵 초기값과 - `:With` 병합.** `sourceEmitMap`은 **비우고**(어떤 emit이 와도 "처음 보는 - 것"으로 걸리는 게 맞다 — 새 노드는 개념적으로 emit을 받아본 적이 없다), - `sourceCountMap`은 **전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`** - (비워두면 순회가 훑을 목록 자체가 없어 "유효하다"로 오판한다 — *"'내가 뭘 - 추적하고 있나' 가 필요하죠"*). 그래서 `:With` 병합 규칙은 **필요 없어졌다.** - 재계산 시 갱신 범위(전부 갱신)도 확정. `base/state-epoch-plan.md`의 §2·§5 7번. -- **[해소, 2026-08-21 같은 날] `Gate` — 소스 없는 emit(빈 배치)은 **아무것도 - 안 한다**.** 에이전트 권고("빈 배치 = 무조건 통지")는 **사용자 기각** — - *"쌓아둔것 자체가 없는데 뒤로 넘긴다는건 이상합니다 … 표면적으로 보면 State - 중간에 Emit 을 추가하는 격"*. 새 규칙도 아니다: `blocker-plan.md`가 이미 - "`HasBlockedEmit`이 false면 아무 것도 안 함(idempotent)"으로 확정해뒀고, - `withheld`는 그 플래그의 일반화다. **따름정리로 `Effect(fn, ...deps)`의 설치 - 구간 억제는 `Gate` 소비자가 아니게 됐다** — `Effect` 내부 플래그로 처리하고, - `effect-plan.md`에 있던 "`Gate`보다 뒤" 순서 제약도 사라졌다. - `base/gate-plan.md`의 7·8번. - - **같이 제기됐던 "재진입 계약"은 열린 항목이 아니었다**(사용자 지적으로 - 2026-08-21 정리) — `blocker-plan.md`의 재진입은 **같은 인스턴스 중첩**을 - 말하는 것이지 정책의 `emit()` 호출과 무관하고, 끝나지 않는 되먹임은 - 2026-08-04 확정 원칙대로 **UB**이며, 유한한 재진입은 flush 진입 시 스왑으로 - 이미 안전하다. `gate-plan.md`의 6번. -- **[해소, 2026-08-21] 공용 게이트 노드의 이름과 표면 — `state:Gate(setup)` - 메소드 + `GateNode`로 확정.** 탑레벨 프리미티브는 안 만들고, `Blocker`는 - `state:Block(blocker)` 안에서 그 배선을 쓴다(사용자: *"Gate 는 따로 - 프리미티브 없이 state:Gate( (emit) -> ()->() ) 처럼 선언되고 마치 Compute - 처럼 GateNode(ComputeNode 처럼) 생성된다"*). `Get()`엔 영향 없음(통지만 - 막음)까지 확정. **[2026-08-21 정정]** 여기 "남은 것은 사용자 판단이 아니라 - 구현 시 정할 것들"이라 적었으나, 두 번째 `/code-review high`가 빈 배치 - emit을 잠시 사용자 판단 항목으로 되돌렸다 — **같은 날 해소돼(바로 위 항목) - 원래 서술로 돌아왔다.** 구현 시 - 정하면 되는 건 생명주기와 M2 범위뿐 — `base/gate-plan.md`가 - 소스. 아래는 열려 있던 시점의 서술: 위 항목의 결정("게이팅 먼저")에 따라 base에 만들 것이 - `Blocker`가 아니라 **상류 emit을 가로채 정책이 통과 여부를 정하는 공용 게이트 - 노드**로 확정됐다(`Blocker`/`Debounce`/`Throttle`이 그 위의 정책). 사용자 - 스케치는 `Gate(function(emit) return function() ... end end)` 2단 구조이고, - **공개 API로 낸다**(사용자: *"이 API가 비공개일 이유는 없어보인다"*). 남은 것 — - (a) **이름**(사용자: *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의 - 문제"*, 에이전트 권고는 `Gate` 그대로 — `gate`는 이미 장치를 가리키는 명사), - (b) M2에 `Gate`만 넣을지 `Blocker`까지 넣을지, 그리고 생명주기 계약. - **[2026-08-21 해소]** 여기 있던 "`:Apply` 팩토리인가"와 "`Blocker`가 그 위에 - 어떻게 얹히는가"는 닫혔다 — **사용자 확정으로 `Gate`는 `:Apply`가 아니라 - `:With`류 State 메소드**(*"state 의 전파를 손대는 작업이라 with 처럼 다른 - 노드가 나는게 맞음"*)이고, 그러면 `Blocker`는 이미 확정된 - `state:Block(blocker)` 메소드가 내부에서 그걸 부르면 되므로 배선 문제 자체가 - 없어진다. 상세는 `base/gate-plan.md`. -- **[해소, 2026-08-21] State 재계산/전파 판정을 "소스 에포크 비교"로 바꿀지 — - 채택 확정**(사용자: *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다. - 채택하면 될것 같아요."*). 규칙 전량은 `base/state-epoch-plan.md`, 구현은 M3. - 아래는 열려 있던 시점의 서술: 사용자 제안: 각 State가 자기 상류 루트 - `Source`들의 카운트를 들고 있다가 `Get()` 때 비교해 재계산 여부를 정한다. - 동기는 성능이 아니라 **정확성** — DFS 전파 도중 Observer가 `Get()`을 부르면 - 아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어가는 glitch가 지금 모델에 - 실재한다. 에이전트 분석 결과 진단·방향 모두 타당하고 선례도 있다 - (MobX/Adapton류 버전 검증). **[2026-08-21 갱신 — 여기 있던 "중복 통지는 안 - 고쳐지고 선언 안 된 의존성을 UB로 명문화해야 한다"는 서술은 같은 날 둘 다 - 뒤집혔다]**: 중복 통지도 **같은 장치로 접고**, UB 조항은 사용자 기각으로 - 빠졌다. 이어진 3·4차 정정으로 **기제는 사실상 다 정해졌다** — 순회는 - `rawInvalid`가 **false**일 때만 돌고(목적은 "못 받은 emit 받기"), emit은 - count 없이 **발행 source만** 싣고, 순회가 발견한 변경은 **테이블 둘**로 - 처분한다(`sourceCountMap`은 순회가 앞당겨 올리고, `sourceEmitMap`은 상류의 - 진짜 emit을 기다림 — *"emit 바로 안하고 상류가 emit 해줄 때 까지 - 기다립니다"*). 그래서 통지가 죽지도 게이트를 새지도 않아 `source = nil` - 규약도 필요 없어졌다. **남은 판단은 채택 여부 자체 하나**이고, State 내부 표현을 - 바꾸는 결정이라 M3 뒤로 미루면 되돌리는 비용이 크다. 상세는 - `base/state-epoch-plan.md`. -- **[해소, 2026-08-21 구현 전 QA 5라운드 `C-4`] `Dispatch.setLength`의 Observer - 앵커 — 물리 target으로 확정(4라운드 `D-56` 역전).** `setLength`가 - `(ownerKey, i, len, anchor)`로 4번째 인자를 받아 **부기 키와 생명주기 앵커를 - 분리**한다. 그래서 `bindLifetime`은 항상 물리 Instance만 상대하고, - `isBoundAlive`의 세 번째 분기(형태 미정으로 열려 있던 것)도 **필요 자체가 - 없어졌다.** 역전 원문은 `archive/bindlifetime-slot-owner-reversed.md`, 지금 - 결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단. 아래는 - 열려 있던 시점의 서술: 그때 확정(4라운드 `D-56`)은 - "`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하라"인데, - 사용자가 그 전제 자체에 의문을 제기했다: *"애초에 Slot 이 effect 나 다른 - 요소들을 소유할 수가 없다 … 실제 observer/effect 는 실제 inst 에 불림 … - 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할 - 부분."* 대안은 **부기 키(`ownerKey`)와 생명주기 앵커(물리 `physicalTarget`)를 - 분리**하는 것 — 그러면 `bindLifetime`은 항상 Instance만 받고, - `isBoundAlive`의 세 번째 분기(지금 ⚠️ 미정)도 통째로 불필요해진다. 상세와 - 트레이싱은 `qa-request/pre-implementation-qa-round5-followup.md`. - **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설, 같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level @@ -334,36 +147,6 @@ `getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자 탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전 필요**, `base/store-plan.md`의 "타입 추론 문제" 절. -- **[해소, 2026-08-21] `Detach` 홀드 중 키가 사라졌을 때의 처분** — - 선택지 (c)로 확정: `updateFn`을 **`KeyGone`으로 한 번 더 불러 처분을 - 묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라 - **`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가 - 닿고 소유권도 유지됨), owner가 죽으면 `activateList`가 설치한 `Effect`가 - 정리한다. 원래 갭이 - 치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가 - **GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`의 - "Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절. -- **[해소, 2026-08-21] `attachSlot` 책임 분해** — **(B) 분해 채택으로 확정**, - `base/slot-plan.md`에 반영 완료(`materializeSlotTree`/`mountSlotTree`/얇은 - `attachSlot`). 근거 기록은 `research/slot-attach-decomposition.md`. - 아래는 그 열려 있던 시점의 서술: 한 - 함수가 부모 등록(offset/length) / `:List` 실체화 / 마운트 상태 전이 / 배치 - 게이팅 / 자식 배치 / 재귀를 다 지고 있어서, **"부모에게 알리는 길이의 - 최종값은 flush가 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 동시에 - 만족되지 않는다**(지금은 후자를 지키고 전자를 포기 — 부모 `recompute`가 - 1회 헛돎). 사용자 판단으로 확장 논의 대기 — 책임 목록/순서 제약 출처/분해 - 후보 넷은 `research/slot-attach-decomposition.md`. **M6(`:List`) 착수 전 - 필요**, 선행으로 아래 `Detach` 보관 위치가 먼저 닫히는 게 나음. -- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** — - 같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별 - claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼 - `bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야 - 하므로), **[2026-08-20 QA 4라운드] 이름도 `groupClaimKeys`로 확정**. - **[해소, 2026-08-21 QA 5라운드 `AT-1`] 키도 `(inst, groupValue) → k`로 확정** - (사용자: *"group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 - 필요가 없고, 그건 key->name 이 유일성을 검증해준다"*), `nameClaims`보다 - **위치 claim을 먼저** 본다 — `base/attribute-plan.md`의 "이름 소유권" 절. - **이 항목은 닫혔다.** - **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가 @@ -376,10 +159,6 @@ 문서화만** 했는데(`base/attribute-plan.md` "메커니즘" 절), 원자적 롤백(그룹 `process`에만 국소적인 unwind)을 넣을지는 열어둠. 지금 결정 불필요 — M10 구현 시점에 판단. -- **[해소됨, 2026-08-18] `Attribute.Merged`의 이름 중복** — `Merged`(겹치면 - error)와 `Overridden`(겹치면 뒤가 이김)을 **둘 다 제공**하는 것으로 확정 - (제3안). 근거·파급은 `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인 - array-part 값 객체" 절. - **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고. 채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를 넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은 diff --git a/.claude/research/epoch-brand-composition.md b/.claude/reference/epoch-brand-composition.md similarity index 73% rename from .claude/research/epoch-brand-composition.md rename to .claude/reference/epoch-brand-composition.md index b78a602..2b9856f 100644 --- a/.claude/research/epoch-brand-composition.md +++ b/.claude/reference/epoch-brand-composition.md @@ -1,14 +1,30 @@ -# `Epoch` 인터페이스 + `EpochMap` 컴포지션, 그리고 `Brand` 인스턴스화 (2026-08-21 신설) +# `Epoch` 인터페이스 + `EpochMap` 컴포지션, 그리고 `Brand` 인스턴스화 — 결정 근거 기록 (2026-08-21) -**상태**: research — **사용자 제안, 두 차례 회신으로 §4가 사실상 전부 닫혔다** -(남은 미정은 리비전 증가를 `bit32` 랩으로 할지 평이한 `+1`로 할지 하나뿐이고, -그건 어느 쪽이든 실질 위험이 없다). **다만 아직 `base/`로 승격하지 않았다** — -승격하면 `state-epoch-plan.md`/`source-state-plan.md`/`effect-plan.md`/ -`brand-plan.md` 넷을 같이 고쳐야 하므로 사용자 확인 후에 한다. -`base/state-epoch-plan.md`/`base/gate-plan.md`/`base/brand-plan.md`가 여전히 -정본이다. 발단은 "다중 의존성 `Effect`에서 한 파동에 `fn`이 두 번 도는" 갭 +**상태**: **[2026-08-21] 전량 확정·승격 완료.** 이 문서는 이제 "왜 그렇게 +정했나"의 근거 기록이고, **지금 유효한 설계는 `base/`가 소스**다 — +`base/state-epoch-plan.md`(`Epoch`/`EpochMap`/State의 두 맵), +`base/brand-plan.md`(인스턴스 브랜드), `base/source-state-plan.md`(`Source`가 +`Epoch`를 구조적으로 만족 + `Observer` 클로저 시그니처), +`base/effect-plan.md`(`Effect`가 자기 `EpochMap`을 듦). +**[2026-08-21 `research/` → `reference/` 이동]** 확정된 뒤엔 "상의가 더 필요한 +설계"가 아니라 "다른 문서가 근거로 인용하는 온디맨드 자료"이므로 +(`slot-attach-decomposition.md`와 같은 처리). + +**[2026-08-21] 열린 항목 없음** — 마지막까지 남았던 리비전 증가 방식도 +같은 날 `bit32` 랩으로 확정됐다(§4의 4번). + +**⚠️ [2026-08-22] 아래 본문의 `{Epoch}` 표기는 제안 당시 표기 그대로다** — +승격 후 실측에서 그게 Luau의 **배열**(`{[number]: Epoch}`)이라 실제 게이트 +배치(`{[Epoch]: true}` 집합)와 안 맞는 게 드러나 **`EpochSet`으로 +확정**됐다(`base/state-epoch-plan.md` §3). 마찬가지로 `Observer` 클로저의 +`from`은 **옵셔널**이다(설치 발화엔 출처가 없다). 정본은 언제나 `base/`. + +발단은 "다중 의존성 `Effect`에서 한 파동에 `fn`이 두 번 도는" 갭 (`base/effect-plan.md`의 다중 deps 절, 2026-08-21 대화에서 에이전트가 제기). +아래는 그 결론에 이른 제안·평가·회신을 그대로 보존한 것 — 원래 서술은 +"승격 대기 중인 제안"이었다. + ## 1. 제안 (사용자 원문 요지) 1. **에포크 부기를 State에서 떼어내 컴포지션 가능한 객체로.** 가칭 @@ -36,7 +52,8 @@ - **`EpochMap` 분리는 이미 코퍼스가 발견한 구분을 형식화한다.** 같은 날 `state-epoch-plan.md`가 맵을 둘로 가른 이유가 정확히 "값 유효성"과 "전파 - dedup"이 **비대칭으로 움직인다**는 것이었다(§2의 "왜 테이블이 둘인가"). + dedup"이 **비대칭으로 움직인다**는 것이었다(승격 후 정본은 + `base/state-epoch-plan.md` §4의 "왜 둘인가 — 순회 때문이다" 문단). 후자만 떼어내 재사용 가능한 객체로 만들면, **노드가 아닌 소비자(leaf)도 같은 판정을 쓸 수 있다** — 지금 State에만 있어서 못 쓰던 것. - **다중 dep `Effect` 갭이 이걸로 정확히 닫힌다.** `A → b`, `A → c`, @@ -46,8 +63,8 @@ 노드로 수렴시키기"보다 낫다 — 노드를 더 안 만들고, `effect-plan.md`가 확정한 "의존성 N개면 내부 Observer도 N개" 구조를 안 건드린다. - **게이트를 페이로드에서 빼는 것도 맞다.** 하류는 게이트 identity를 **한 - 번도 안 쓴다**(에포크 경계로 만드는 안은 `state-epoch-plan.md` §5-3에서 - 이미 기각됨). 배치가 "그 전파에만 쓰는 일회성 스냅샷"이라는 성질도 + 번도 안 쓴다**(에포크 경계로 만드는 안은 `base/state-epoch-plan.md` §8의 + "기각된 대안 — 게이트를 에포크 경계로" 항목에서 기각됨). 배치가 "그 전파에만 쓰는 일회성 스냅샷"이라는 성질도 그대로 유지된다. - **`Epoch`로의 일반화가 실제로 계약을 정확하게 만든다.** 맵이 `Source`에서 요구하는 건 **identity + 단조 증가 카운터** 둘뿐이다. 그걸 이름 붙이면 @@ -112,14 +129,19 @@ - **숫자**: 할당 0, 비교도 더 쌈. 위험은 도달 불가능한 `2^53`뿐. - **에이전트 권고도 숫자(`Revision`)** — 트윈처럼 매 프레임 `Set`하는 소스가 여럿이면 테이블안은 GC 압력을 만든다. - - **증가 방식 — `bit32` 랩 vs 평이한 `+1`, 아직 안 정함.** 사용자는 - `bit32`가 uint32 안에서 **랩어라운드**한다는 걸 근거로 그 구조를 - 염두에 뒀다(`bit32.bnot(-1) == 0`, `bit32.bnot(0) == 4294967295`). - 맞다 — 랩이면 double 포화(`n + 1 == n`)가 아예 안 생긴다. **다만 충돌 - 거리는 오히려 짧아진다**: 평이한 `+1`은 `2^53`에서 포화하고, `bit32` - 랩은 `2^32`마다 한 바퀴라 "정확히 `2^32`만큼 벌어진 두 리비전"이 같아 - 보인다. 둘 다 도달 불가능이라 **어느 쪽이든 실질 위험은 없고**, 안전 - 마진만 보면 평이한 `+1`이 넓다. + - **[해소, 2026-08-21] `bit32.bnot(-rev)`로 확정.** 사용자가 인용한 + `bit32.bnot(-1) == 0`, `bit32.bnot(-0) == 4294967295`, + `bit32.bnot(-4294967295) == 4294967294`가 **예시가 아니라 연산 자체** + 였다 — `bit32.bnot(-a)`는 `a > 0`이면 `a - 1`, `0`이면 `4294967295`인 + **랩어라운드 감소**이고, 갱신과 랩이 **FASTCALL 하나**로 끝난다. + 랩이라 double 포화(`n + 1 == n`)가 안 생긴다. 충돌 거리 자체는 + 오히려 짧아지지만(`2^53` vs `2^32`) **둘 다 도달 불가능이라 정확성은 + 동률**이고, 사용자가 hot path 비용을 근거로 골랐다: *"매번 도는 + 코드인지라, 값 싸게 native call + num 연산으로 가볍게 가고 싶어요."* + - **⚠️ 에이전트가 한 번 잘못 옮겼다** — 형태를 `band(rev + 1, mask)`로 + 적고 "덧셈 위에 fastcall이 하나 더 얹힌다"고 단서까지 달았는데, + 사용자 정정(*"제가 말한건, bit32.bnot(-a) 입니다"*)으로 둘 다 틀린 + 게 확인됐다. 실측·정본은 `base/state-epoch-plan.md` §2. 5. **[해소] State는 `EpochMap`을 둘 컴포지션하고, `:Sync`는 필수 연산이 아니다 — 에이전트 착오 정정.** 에이전트가 "재계산 후 전부 최신으로 맞추려면 `Update` 외의 연산이 필요하다"고 적었으나, **`Update`가 @@ -129,7 +151,8 @@ 본 탓이다. - **초기화도 같은 연산 하나로 끝난다** — `:With`/`:Compute`의 deps를 전부 `Update`하고 **반환값은 안 보고** `rawInvalid = true`로 둔다. - 이미 확정된 노드 생성 규칙(`base/state-epoch-plan.md` §2)과 정확히 + 이미 확정된 노드 생성 규칙(`base/state-epoch-plan.md` §4의 "노드가 + 생길 때의 초기값" 절)과 정확히 같은 동작이다. - **내부 최적화(사용자 제안)**: `Update`가 목록을 돌 때 diff 때문에 읽기가 들어가는데, **한 번 다름을 찾으면 반환값이 이미 `true`로 @@ -152,10 +175,13 @@ `Debug`뿐, 2026-08-21 확인) — 전환 비용은 **문서뿐**이다. `Brand`라는 이름 자체는 여전히 용어 정리 대기(`question.md` 1번). -## 관련 문서 +## 관련 문서 (전부 승격 반영 완료) -- `base/state-epoch-plan.md` — 지금 확정된 두 맵과 수신 규칙(§2). +- `base/state-epoch-plan.md` — `Epoch`/`EpochMap`과 State의 두 맵, 수신 규칙. +- `base/brand-plan.md` — 인스턴스 브랜드(옛 단일 레지스트리는 + `archive/brand-shared-registry-reversed.md`). +- `base/source-state-plan.md` — `Source`가 `Epoch`를 구조적으로 만족, + `state:Observer(fn)`의 `fn(self, from)` 계약. +- `base/effect-plan.md` — `Effect`가 자기 `EpochMap`을 들어 다중 deps 중복 + 발화를 접는다(이 제안이 닫은 갭). - `base/gate-plan.md` — 배치 페이로드(4번), 빈 배치 무통지(8번). -- `base/brand-plan.md` — 현행 단일 레지스트리 설계와 서브타입 OR 합성. -- `base/effect-plan.md` — 다중 deps 절(이 제안이 닫으려는 갭). -- `base/source-state-plan.md` — `state:Observer(fn)` 계약. diff --git a/.claude/research/slot-attach-decomposition.md b/.claude/reference/slot-attach-decomposition.md similarity index 95% rename from .claude/research/slot-attach-decomposition.md rename to .claude/reference/slot-attach-decomposition.md index 5730067..7cc5905 100644 --- a/.claude/research/slot-attach-decomposition.md +++ b/.claude/reference/slot-attach-decomposition.md @@ -4,6 +4,8 @@ 반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한 설계는 `base/slot-plan.md`의 "재귀 메커니즘" 절**(`materializeSlotTree` / `mountSlotTree` / 얇은 `attachSlot`)이 소스다. +**[2026-08-21 `research/` → `reference/` 이동]** 확정된 뒤엔 "상의가 더 필요한 +설계"가 아니라 "다른 문서가 근거로 인용하는 온디맨드 자료"이므로. **사용자 확정 근거**(2026-08-21): *"함수 분해는 확정해도 좋을것 같음. 이게 하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던 부분이고, 지금 @@ -14,6 +16,16 @@ 아래는 그 결론에 이른 조사/논거를 그대로 보존한 것 — 원래 서술은 "논의 전 준비 자료"였다. +**⚠️ [2026-08-21 후속] 아래 2절의 제약 `C7`("부기 갱신이 물리 트리 조작보다 +먼저")은 그 뒤 *일반 계약으로서는* 폐기됐다** — 같은 날 `native*` 주입 op 계층이 +확정되면서(`archive/bookkeeping-before-physical-reversed.md`), "자기 자리를 +정하는 것 먼저 / 뒤를 미는 것 나중" 하나로 줄었다. **이 문서의 결론은 그대로 +유효하다** — 배치 경로(`materializeSlotTree` → `mountSlotTree`)가 부기를 먼저 +끝내는 것은 `C7`이 아니라 **`C6`가 요구하는 별개 사안**이고, 그 역전 문서도 +그렇게 명시한다. 다만 아래 표만 단독으로 읽고 `C7`을 지금도 유효한 일반 +규칙으로 옮겨 쓰지 말 것 — 지금 유효한 서술은 +`base/dispatch-core-plan.md`의 "일반 계약 — 물리와" 절이 소스다. + **왜 생겼나**: 2026-08-21 구현 전 QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를 flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가 아니라고 짚었다 — *"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 83be192..e64536a 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1725,4 +1725,67 @@ State의 재계산/전파 판정은 **소스 에포크 비교 채택**으로 닫 한 파동에 `fn`이 두 번 도는 갭**(공통 하류가 없어 에포크 dedup이 못 접는다)이고, `Effect`가 자기 `EpochMap`을 들면 그게 공통 하류가 되어 닫힌다. **사실상 전량 확정됐으나 세션 길이 때문에 `base/` 승격은 다음 세션으로 미뤄졌다** — -`research/epoch-brand-composition.md`가 소스, `todos.md` 000번이 진입점. +`reference/epoch-brand-composition.md`가 소스, `todos.md` 000번이 진입점. + +--- + +## 2026-08-21 (세 번째) — `Epoch`/`EpochMap`/`Brand` 전면 승격, 그리고 해소 기록 flatten + +원문: `session/2026-08-21-03-epoch-brand-promotion-and-flatten.md` + +앞 세션이 미뤄둔 승격을 실제로 수행했다. **`Epoch`**(`{Revision: number}`, +그 자체로 키가 되는 unique 테이블 — `Source`가 구조적으로 만족하고 +`EpochBrand`에도 등록됨)와 **`EpochMap`**(`:Update(Epoch|{Epoch}) -> boolean`이 +"뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`)이 `base/`에 들어갔고, +State는 그걸 **둘** 컴포지션한다(`sourceCountMap`/`sourceEmitMap` → +`valueEpochMap`/`emitEpochMap`). **`Brand`는 인스턴스 브랜드로 전면 +재작성**됐다 — `Brand()` + `:register`/`:is`, **다중 태깅 허용**, 역조회 +`Brand.get`은 제거(옛 표면은 `archive/brand-shared-registry-reversed.md`). +그 부수로 **`effect-plan.md`의 다중 의존성 중복 발화 미해결 항목이 +닫혔다**(`EffectHandle`이 자기 `EpochMap`을 들어 첫 번째만 통과시킴). +**마지막 미정이던 리비전 갱신 방식도 같은 세션에 `bit32.bnot(-rev)`로 +확정**됐다 — 사용자 논거는 *"2^53 포화는 어차피 도달 불가능한데 그걸 +피하겠다고 값을 double 영역까지 키울 이유가 없다, 매번 도는 코드라 값싸게 +가고 싶다"*. **에이전트가 이 형태를 `band(rev + 1, mask)`로 잘못 옮기고 +"그러니 `bit32`가 더 싼 건 아니다"라는 단서까지 붙였다가 사용자에게 +정정당했다**(*"제가 말한건, bit32.bnot(-a) 입니다"*) — 사용자가 근거로 든 +REPL 출력 셋이 예시가 아니라 **연산 자체**였다. `luau` 실측 결과 +`bit32.bnot(-a)`는 `a > 0`이면 `a - 1`, `0`이면 `4294967295`인 **랩어라운드 +감소**이고 갱신과 랩이 **FASTCALL 하나**로 끝난다. 즉 사용자 서술이 맞았고 +에이전트 단서가 틀렸다. 따름정리로 리비전은 **증가가 아니라 감소**하며, +`==`/`~=`만 쓰는 지금 규칙에서만 무해하다는 경고를 `base/`에 남겼다. +**`Epoch`/`EpochMap`/`Brand`에 열린 설계 항목은 없다.** + +커밋 전 `/code-review high`가 **9건을 더 냈고 전부 유효**했다(감사자 3라운드가 +수렴한 뒤였다 — 둘이 보는 축이 다르다는 `conventions.md` 서술의 재확인). +치명적인 둘은 **표기가 실제 메커니즘과 안 맞던 것**이다: (1) `{Epoch}`가 +Luau에선 **배열**인데 실제 게이트 배치는 `{[Epoch]: true}` **집합**이라, 그대로 +`ipairs`로 구현하면 유보됐다 풀린 emit이 전부 삼켜진다 → `EpochSet`으로 확정, +(2) 새 노드 시딩("상류의 `Epoch`를 전부 끌어와")이 **확정된 `EpochMap` 표면으로 +표현 불가능**했다(`:With`의 상류는 State이지 `Epoch`가 아니고, 키 열거/병합 +연산이 없었음) → `:TrackFrom` 신설. 그 외 `GateNode` 예외 미기록, 설치 발화의 +`from`이 non-optional, `2^32` 랩을 "똑같이 도달 불가능"이라 한 **틀린 수치 +근거**(같은 척도로 285년 vs 72분 — 실제 안전 근거는 도달 시간이 아니라 충돌 +조건이 한 점이라는 것), 옛 이름 잔재(`TweenTag` 3곳/`Effect(fn, state?)` 4곳), +`§` 참조 3곳. + +**에이전트가 이름 붙인 연산 둘은 사용자 검토로 확정**됐다 — `:Refresh`는 그대로 +(*"Update 에 인자 없는건 좀 아니야 … 리프레시는 내가 받았던걸 처리하겠다는거라 +표면적 의미 자체가 다르지"*), `:Absorb`는 **`:TrackFrom`으로 개명** +(*"absorb 는 … 상위 요소에서 제거할것만 같은 이름"* + `gate-plan.md`가 이미 +"흡수 집합"을 다른 뜻으로 씀). 이 세션의 반복 교훈은 하나 — **승격은 문장을 +옮기는 작업이 아니라 표기가 가리키는 것이 실제로 성립하는지 확인하는 +작업**이다(`bit32` 형태, `{Epoch}` 타입, 시딩 표현 셋 다 같은 유형이었다). + +**확정된 결정의 근거 기록이 갈 자리를 정했다 — `reference/`.** +`slot-attach-decomposition.md`와 `epoch-brand-composition.md` 둘 다 +`research/`(아직 상의 필요)도 `archive/`(뒤집혔거나 기각됨)도 아니라 +"`base/`가 근거로 인용하는 온디맨드 자료"이므로. 폴더 기준 자체에 이 용도를 +명문화했다. + +**flatten** — 사용자 지적("재정정 기록이 쌓인 부분")대로 세 군데를 걷어냈다. +`question.md`가 스스로 정한 규칙(해소되면 archive로 옮김)을 어기고 다시 +절반이 `[해소]`로 차 있어 **16건을 일괄 이관**(421→208줄), `todos.md`의 +"M3 착수 전 필요" 목록도 절반 넘게 해소 항목이라 실제로 열린 둘만 남겼다. +이관한 히스토리의 원문은 소급해 고치지 않고 **머리에 "그 뒤 이름이 바뀌었다" +경고만** 달았다. diff --git a/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md b/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md index 32cbbde..47ebfc1 100644 --- a/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md +++ b/.claude/session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md @@ -7,7 +7,7 @@ **소스 관계**: 지금 유효한 설계는 항상 `base/slot-plan.md`가 소스이고, 처리 경과 요약은 `qa-request/pre-implementation-qa-round4-followup.md`의 -H절, 분해 근거는 `research/slot-attach-decomposition.md`. 이 파일은 그 +H절, 분해 근거는 `reference/slot-attach-decomposition.md`. 이 파일은 그 결정들이 **어떤 논의를 거쳐 나왔는지**의 원문 기록이다. --- @@ -127,7 +127,7 @@ detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어 `ChildAdded` 핸들러가 볼 때 서브트리의 `Length`/`Offset`이 이미 최종값이라는 점뿐이고, 옛 코드는 거기서 **미완성 스냅샷**을 보여줬다. 즉 이 변화는 동치성 손실이 아니라 **엄밀히 더 정확해지는 방향**이다. 분석 원문은 -`research/slot-attach-decomposition.md` 7절. +`reference/slot-attach-decomposition.md` 7절. ## 6. 이 세션이 안 한 것 diff --git a/.claude/session/2026-08-21-03-epoch-brand-promotion-and-flatten.md b/.claude/session/2026-08-21-03-epoch-brand-promotion-and-flatten.md new file mode 100644 index 0000000..20ac594 --- /dev/null +++ b/.claude/session/2026-08-21-03-epoch-brand-promotion-and-flatten.md @@ -0,0 +1,336 @@ +# 2026-08-21-03 — `Epoch`/`EpochMap`/`Brand` 전면 승격 + 해소 기록 flatten + +**요지**: 앞 세션(`2026-08-21-02-qa-round5-and-gate-epoch-research.md`)이 +컨텍스트 피로로 미뤄둔 승격을 실제로 수행하고, 그 과정에서 코퍼스 곳곳에 쌓여 +있던 `[해소]`/`[정정]` 층을 걷어냈다. 지금 유효한 설계는 전부 `base/`가 소스 — +이 파일은 "무엇을 어디로 옮겼나"의 기록이다. + +## 1. 사용자 지시 + +두 갈래였다. + +1. **질문**: *"slot-attach-decomposition.md 은 이제 확정 이야기이기에, 리서치 + 대상이 아니지 않나요? 또, 이것이 base에 적용되어 있습니까?"* +2. **작업 지시**: `state-epoch-plan.md`/`source-state-plan.md`/`effect-plan.md`/ + `brand-plan.md` 넷을 전면 승격하고, *"이 승격을 하며 더이상 필요 없어진 + 요소들은 archive 하세요. 특히 재정정 기록이 쌓인 부분이 있는데, 풀어가며 + archive 해서 flatten 해야할 필요가 보입니다."* + +## 2. 1번 질문에 대한 답 — 둘 다 예 + +- **`base/slot-plan.md`에 이미 반영돼 있다** — `materializeSlotTree` / + `mountSlotTree` / 두 줄짜리 `attachSlot` 래퍼가 그 문서의 "재귀 메커니즘" + 절에 의사코드로 들어가 있고, 그 문서가 근거로 이 파일을 네 곳에서 인용한다. +- **`research/`에 있을 이유는 없다** — `.claude/README.md`의 폴더 기준상 + `research/`는 "아직 착수 전, 상의 필요"다. + +**어디로 옮길지**가 실제 판단이었다. `archive/`는 기준이 "뒤집혔거나 기각됨" +이라 채택된 결정의 근거 기록엔 안 맞고, `reference/`의 기준("결정 자체가 +아니라 다른 문서가 근거로 인용하는 온디맨드 자료")엔 정확히 맞는다. 그래서 +**`reference/`로 옮기고 폴더 기준 자체에 그 용도를 명문화**했다 +(`README.md` 폴더 기준표 + `project-context.md`). 선례도 있다 — +`quad-v1-architecture.md`/`comparison-fusion-vide.md`가 `base/`에서 같은 +이유로 이동해온 것. + +같은 처리를 `epoch-brand-composition.md`에도 적용했다(승격이 끝나면 성격이 +똑같아지므로). + +## 3. 승격 — 무엇이 어떻게 바뀌었나 + +### `base/state-epoch-plan.md` (재작성) + +- **`Epoch` 인터페이스 신설** — `type Epoch = { Revision: number }`, 그 자체로 + 키가 되는 unique 테이블. `Source`가 구조적으로 만족하고 `EpochBrand`에 + 등록된다. "루트 `Source`의 에포크"라는 좁은 서술이 전부 여기로 일반화됨. +- **`EpochMap` 신설** — `:Update(Epoch|{Epoch}) -> boolean`(반환값의 뜻은 + "뒤로 전파가 필요한가"), `:Refresh()`, `:Sync()`. 부기가 State에서 떨어져 + 나와 재사용 가능한 객체가 됐다. + - **`:Refresh`는 이 세션이 이름만 붙인 것**이다 — "순회"라는 연산 자체는 + 이미 확정돼 있었고(`rawInvalid == false`일 때 자기 맵을 훑는다), 맵이 + 키를 소유하므로 인자 없는 형태가 자연스러워서 그렇게 적었다. 새 설계가 + 아니다. +- **State의 두 맵이 `EpochMap` 둘이 됐다** — `sourceCountMap` → + `valueEpochMap`, `sourceEmitMap` → `emitEpochMap`. 이름을 바꾼 이유는 + "source"가 더 이상 정확하지 않아서다. +- **문서 구조를 §1~§8로 다시 짰다.** 옛 §3(에이전트 분석)과 §5(열린 질문)에 + 결론과 정정 경위가 섞여 있었는데, 결론은 규칙 본문(§2~§5)으로 올리고 + 경위는 걷어냈다(원문은 `qa-request/pre-implementation-qa-round5-followup.md` + M·N절과 `reference/epoch-brand-composition.md`에 있다). + +### `base/brand-plan.md` (전면 재작성) + +- **인스턴스 브랜드** — `Brand()`가 브랜드마다 weak 집합 하나를 들고, + `SomeBrand:register(x)` / `SomeBrand:is(x)`. **다중 태깅**이 이 재작성의 + 존재 이유다(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`). +- **역조회 `Brand.get`은 없어졌다.** +- **유지된 것**: weak-key 레지스트리, 테이블 아이덴티티, duck-typing 기각 + 근거 둘, 포함 관계 predicate 합성, `Brand`가 `None`에 의존하지 않는다는 + 결정, Luau narrowing 주의. +- **새로 명시한 것 하나** — 다중 태깅이 가능해져도 **포함 관계는 계속 + predicate 합성으로 쓴다**(`Source`를 `StateBrand`에도 등록하지 않는다). + 등록 지점이 흩어지면 조용한 버그가 되고 포함 관계가 코드에서 사라지기 + 때문. 정당한 다중 등록은 `Epoch`처럼 **다른 축의 계약**뿐이다. +- 옛 표면은 `archive/brand-shared-registry-reversed.md`. + +### `base/source-state-plan.md` + +- "Source가 State를 만족함" 절에 **`Epoch`도 같은 방식으로 만족**한다는 항목 + 추가(`Revision`이 공개여야 하는 이유 포함). +- `state:Observer(fn)` 절에 **`fn(self, from: Epoch | {Epoch})`** 시그니처 + 확정 추가. "값을 안 실어주는 구독" 계약이 안 깨지는 이유도 같이. +- 승격 대기 ⚠️ 배너 제거, 그 절들의 "에포크" 표현을 "리비전"으로 통일. + +### `base/effect-plan.md` + +- **⚠️ 미해결이던 다중 의존성 중복 발화가 닫혔다** — `EffectHandle`이 + `EpochMap`을 하나 들고 각 내부 Observer가 받은 `from`으로 `Update`, + `true`일 때만 `fn`을 돌린다. `Effect`가 곧 deps의 공통 하류가 된다. +- **`Ref` 의존성은 이 맵에 안 낀다**(이 세션이 명시) — `Ref`는 `Epoch`가 + 아니고 `:Callback`으로 발화해 `from`이 없다. 공통 상류 문제 자체가 없다. + +### `base/gate-plan.md` + +승격 대기 배너를 반영 완료로 바꾸고, 페이로드/`withheld`/`emitEpochMap` +표현을 `Epoch` 어휘로 통일. **기제는 하나도 안 바뀌었다.** + +## 4. flatten — 걷어낸 것들 + +사용자가 지목한 "재정정 기록이 쌓인 부분"은 실제로 세 군데였다. + +1. **`question.md`가 다시 해소 항목으로 절반이 찼다.** 그 문서 스스로 + *"항목을 해소하면 여기서 지우고 `archive/question-resolved.md`에 근거와 + 함께 옮길 것 — 다시 쌓이면 같은 문제가 반복됨"*이라 규정해뒀는데, + 2026-08-21 하루에 여러 건이 닫히면서 예고대로 재발했다. **16건을 일괄 + 이관**하고(`Gate` 이름 항목 포함), 파일이 421줄 → 208줄이 됐다. + - 이관분 중 `Epoch` 관련 서술은 **이관 시점 표현 그대로 뒀고**, 대신 + 이관 섹션 머리에 "필드 이름이 그 뒤 바뀌었다"는 경고를 붙였다. 히스토리 + 문서의 원문을 소급해 고치지 않는다는 관례를 지키면서 오독만 막는 처리. +2. **`todos.md`의 "M3 착수 전에 결론이 필요한 항목 목록"이 절반 넘게 `[해소]` + 였다.** 실제로 열려 있는 건 둘뿐(중간 State GC 미검증, `store:GetDynamic`) + 인데 해소 항목 일곱이 섞여 있어 "지금 할 일"로 안 읽혔다. 걷어냄. +3. **`todos.md` 000번**(다음 세션 첫 작업 = 이 승격)은 완료됐으므로 삭제하고, + 00번 안에 승격 완료 사실만 남겼다. + +## 5. 마지막 미정도 같은 세션에 닫힘 — 리비전 증가는 `bit32` 랩 + +승격 보고 직후 사용자가 그 자리에서 정했다: *"리비전 증가는 bit32 로 +두고싶어요. 그건 luau 에서 native call 이라 아주 빨라요. 반면 double 의 +연산이 느린편인데, 희소 수준이 아니라, 사실상 만나는걸 수년간 보기 어려운 +라운드되어 동일해 무시되는 경우를 막기 위해 double 까지 올려야할 이유를 +모르겠어요. 매번 도는 코드인지라, 값 싸게 native call + num 연산으로 가볍게 +가고 싶어요."* + +논거의 핵심은 **"`2^53` 포화는 어차피 도달 불가능한 시나리오인데, 그걸 +피하겠다고 값을 double 영역까지 키울 이유가 없다"**이고, 매 `Set`마다 도는 +hot path라는 것. + +**⚠️ 에이전트가 형태를 잘못 옮겼고, 사용자가 그 자리에서 정정했다.** +에이전트는 이걸 `bit32.band(rev + 1, 0xFFFFFFFF)`로 적고, 거기에 "그러니 +`bit32`라서 증가가 더 싼 건 아니다 — `n + 1`은 어느 쪽이든 double 덧셈이고 +`bit32`는 fastcall을 하나 더 얹는다"는 단서까지 달았다. 사용자 정정: + +> *"잠시만요. 제가 말한건, bit32.bnot(-a) 입니다"* — 그리고 REPL 출력 셋을 +> 그대로 제시했다(`bit32.bnot(-1)` → `0`, `bit32.bnot(-0)` → `4294967295`, +> `bit32.bnot(-4294967295)` → `4294967294`). + +**즉 그 세 줄은 예시가 아니라 연산 자체였다.** `luau`로 재확인한 결과: + +``` +bump(a) = bit32.bnot(-a) +0 → 4294967295 | 1 → 0 | 2 → 1 | 4294967295 → 4294967294 +``` + +`a > 0`이면 `a - 1`, `0`이면 `4294967295`인 **랩어라운드 감소**이고, **갱신과 +랩이 FASTCALL 하나로** 끝난다. 그래서: + +- **에이전트가 붙였던 단서는 틀렸다** — `band(rev + 1, mask)`라는 **다른 + 형태**를 놓고 한 비교였다. `bnot(-a)`는 덧셈 위에 얹히는 게 아니라 갱신 + 자체를 대체하므로, *"native call 이라 아주 빨라요"*라는 사용자 서술이 맞다. +- **따름정리는 방향만 바뀌어 유효하다** — 단조 증가가 아닌 게 아니라 아예 + **감소**한다. 지금 규칙이 `==`/`~=`만 쓰기 때문에 무해하지만, 순서 비교를 + 넣고 싶어지면 이 결정부터 되짚어야 한다. `Revision`이라는 이름이 순서를 + 뜻하지 않는다는 것도 같이 적어뒀다. + +**교훈**: 사용자가 근거로 든 REPL 출력을 "그 함수가 랩한다는 예시"로만 읽고 +연산 형태를 에이전트가 임의로 재구성했다. `conventions.md`의 "사용자 발언을 +근거로 인용할 때는 결론만 적지 말고 논거까지 남길 것" 항목이 겨냥하는 실패에 +가깝다 — 이 경우엔 논거를 남기긴 했는데 **형태를 바꿔 남겼다.** + +**그래서 `Epoch`/`EpochMap`/`Brand`에 열린 설계 항목은 하나도 남지 않았다.** + +`doc-check.py` ERROR 0 유지(WARN 30건은 전부 이 세션 이전부터 있던 것 — +축약 파일명 인용과 날짜 없는 시한부 주장). + + +## 6. 감사 루프 기록 + +`conventions.md`의 "핸드오버 준비하고 커밋해" 절차대로 **한 턴에 하나씩**, +라운드마다 각도를 바꿔 돌렸다. + +### 1라운드 — `base/` 정합성 + diff 범위 + +발견 넷 중 **하나는 stale**(감사자가 `bit32` 편집 이전 스냅샷을 읽어 +"리비전 증가 방식이 아직 미정"이라 보고했으나 다섯 곳 전부 이미 갱신돼 +있었음 — 감사자가 도는 도중 메인 세션이 같은 파일을 고치면 생기는 일이라, +**감사 리포트를 받으면 항상 현재 파일로 재확인할 것**). 나머지 셋은 유효: + +1. **`state-epoch-plan.md`가 "§8에서 기각됨"으로 실재하지 않는 내용을 + 가리켰다** — 재작성하면서 "게이트를 에포크 경계로" 기각 논거를 떨어뜨렸고, + `gate-plan.md`와 `reference/`는 또 **다른** 번호(§5-3)를 대고 있었다. + → §8에 실제 논거를 쓰고 세 포인터를 절 제목으로 통일. **`doc-check.py`는 + 산문 속 `§번호`를 검증하지 못한다** — 문서를 재작성할 때 §번호 포인터는 + 손으로 훑어야 한다. +2. `ROADMAP.md` M7이 `isState`/`isSource`를 아직 "공유 레지스트리 기반"으로 + 서술 → 멤버십 기반으로. +3. **스파이크 `22`가 역전된 `Brand.set`/`Brand.get`을 직접 구현한 채 + `done/`에 있었다** → `rewrite-required/`로 이동. 검증 대상(포함 관계)은 + 유효하므로 결론이 틀려서가 아니라 **구현자가 그 파일의 `Brand` 구현을 + 참고 모델로 오독**하는 걸 막기 위함. `05`와 같은 처리. + +### 2라운드 — 인덱스 레이어 + `luau-test` + 1라운드 이후 diff + +여섯 건, 전부 유효: + +1. **`state-epoch-plan.md`가 자기 문서 안에서 모순**했다 — §2가 "`Revision`을 + **증가**시킨다"라고 적어놓고 20줄 뒤에 "확정된 방식은 증가가 아니라 + **감소**한다"라고 반박. `bit32.bnot(-a)` 정정을 반영하면서 앞쪽 문장을 + 안 고친 것. → 방향을 안 담는 "갱신한다"로. +2. `source-state-plan.md`에 **같은 문장이 복붙돼 있었다** — 같이 정정. +3. `README.md`의 `effect-plan.md` 행이 dedup 대칭을 아직 "미확인"으로 서술 + (본문은 `EF-3`에서 이미 "성립함"으로 확정). 세션 전부터 있던 stale. +4. `STATUS.md`의 `✅ done/` 절이 **같은 파일 위쪽과 어긋났다** — 배너와 표는 + `22` 이동을 반영했는데 절 제목("17건")과 본문 나열("런타임 9건 …/`22`")은 + 옛 상태. → 하드코딩 개수를 빼고 폴더/표를 소스로. +5. `luau-test/README.md`의 "실행 환경 세 갈래" 표가 `21`/`22`/`23`을 + 빠뜨리고 `13`을 아직 "런타임/타입 부분"으로 쪼개 적고 있었다(런타임 + 절반은 2026-08-19에 `22`로 분리돼 나갔다). → 실제와 맞추고, 파일 헤더가 + 소스임을 명시. +6. `reference/slot-attach-decomposition.md`의 제약 `C7`이 그 뒤 `native*` + 계층 확정으로 **일반 계약으로서는 폐기**됐는데 문서에 아무 표시가 + 없었다. 결론 자체는 유효하지만(배치 경로가 부기를 먼저 끝내는 건 `C6` + 요구사항이라 별개) 표만 떼어 읽으면 오독하므로 상단에 캐비엇 배너. + `reference/comparison-fusion-vide.md`가 이미 같은 형태의 캐비엇을 달고 + 있어 선례를 따랐다. + +**교훈 하나** — 4·5번은 둘 다 **"같은 문서 안에서 위쪽만 갱신되고 아래쪽이 +남은"** 형태다. 개수를 제목에 박아두면 정확히 이렇게 갈라지므로, 이번에 +`STATUS.md`의 절 제목 두 곳에서 하드코딩 개수를 뺐다. + +### 3라운드 — `archive/` 배너 정합성 + `qa-request/`의 "반영 완료" 주장 + +**확실한 모순 발견 0건 — 수렴.** 감사자가 대조한 것: `archive/`의 배너 +포인터 넷(`brand-shared-registry-reversed` / `question-resolved`의 이관 섹션 / +`always-propagate-no-dedup-superseded` / `invalidate-dedup-propagation-reversed` / +`bookkeeping-before-physical-reversed`), 2라운드가 고친 여섯 자리, 그리고 +`round4/5-followup.md`가 소스라고 선언한 항목들. + +특히 **2라운드에서 내가 새로 쓴 `C7` 캐비엇**(*"배치 경로가 부기를 먼저 +끝내는 건 `C7`이 아니라 `C6`가 요구하는 별개 사안"*)이 +`base/dispatch-core-plan.md`의 "일반 계약 — 물리와" 절 4번과 정확히 대응함이 +확인됐다 — 새로 쓴 서술이 기존 확정과 어긋나지 않는지가 이 라운드의 핵심 +질문이었다. + +유일한 지적은 문체였다 — §8에 "`Revision`만 **올리면**"이라는 방향 어감이 +남아 있던 것(§2가 이미 "방향은 계약이 아니다"로 커버하고 있어 모순은 아님). +같이 정리했다. + +**수렴 판정**: 3라운드에서 새 발견 0건이므로 `conventions.md`의 루프 종료 +조건을 만족한다. 총 소비는 서브에이전트 3패스(각도: `base/` 정합성 → +인덱스 레이어+`luau-test` → `archive/`+`qa-request/`). + +## 7. 커밋 전 `/code-review high` — 감사 3라운드가 못 본 축에서 9건 + +`conventions.md`가 명문화한 그대로였다 — **감사자와 code-review는 보는 축이 +다르다.** 감사자 3라운드가 수렴(0건)한 뒤 사용자가 `/code-review high`를 +돌리자 **9건이 더 나왔고 전부 유효**했다. 감사자는 코퍼스 전체의 **의미론적 +정합성**(A 문서 결정 ↔ B 문서 서술)을 보고, code-review는 **diff 자체의 +결함**(이번에 새로 쓴 서술 안의 모순, 새 표면이 기존 계약과 충돌하는가)을 +본다. + +### 구현을 실제로 막았을 것들 + +1. **⭐ `{Epoch}` 표기가 실제 배치 모양과 달랐다.** Luau에서 `{Epoch}`는 + **배열**(`{[number]: Epoch}`)인데, 실제로 넘어오는 게이트 배치는 + `gate-plan.md` 4번이 확정한 `withheld : { [epoch] : true }` — **집합**이다. + 이 표기를 믿고 `ipairs`로 구현하면 배치 순회에서 **원소가 0개**가 되어 + 유보됐다 풀린 emit이 전부 조용히 삼켜진다. `gate-plan.md` 4번이 애초에 + 고치려던 바로 그 버그였다. → `type EpochSet = { [Epoch]: true }`로 확정. + **집합이어야 하는 이유는 게이트 쪽 요구**다(흡수·unfold가 저절로 접혀야 + 함). 이건 사용자 원 제안의 `Epoch|{Epoch}` 표기를 에이전트가 그대로 + 옮기면서 **Luau 타입으로서 뭘 뜻하는지 확인 안 한** 결과다. +2. **⭐ 새 노드 시딩이 확정된 `EpochMap` 표면으로 표현 불가능했다.** + "상류의 `Epoch`를 전부 끌어와 채운다"인데 `:With(a, b)`의 상류는 + **State이지 `Epoch`가 아니다.** 상류가 추적 중인 루트 집합은 그 State의 + `valueEpochMap` 안에만 있는데 §3 표면엔 **키 열거도 병합도 없었다.** + → `:TrackFrom(other)` 신설(가칭 `Absorb`, 같은 날 개명 — §8 참고). + `:Refresh`와 같은 성격으로 **이미 확정된 동작에 이름을 붙인 것**이지 + 새 설계가 아니다(사용자 확정 문구가 이미 *"전부 가져와서, 실제 count 로 + 둡니다"*였다). dep이 `Epoch`면 `:Sync`, State면 `:TrackFrom`으로 갈리는 + 것도 같이 적었다. +3. **`GateNode` 예외가 §4에 기록돼 있지 않았다.** §4 의사코드는 + `emitEpochMap:Update(from)`을 **수신 시점에 무조건** 부르는데, + `gate-plan.md` 4번은 게이트가 **전파할 때** `:Sync(batch)`로 갱신하는 + 것으로 확정돼 있다. §4대로 구현하면 그 계약이 조용히 깨진다. +4. **설치 시 즉시 1회 발화에 `from`이 없다.** `fn(self, from)`을 + non-optional로 선언해뒀는데 등록 시점 발화엔 출처가 없다 — + `Effect`의 확정 클로저가 `Update(nil)`을 부르게 된다. → 옵셔널로 바꾸고, + `Effect`의 억제 플래그가 `Update`보다 **먼저** 와야 함을 명시(순서를 + 뒤집으면 설치 발화가 맵을 건드려 그 파동의 첫 진짜 emit이 접힌다). + +### 근거·표기 정확성 + +5. **`2^32` 랩이 "똑같이 도달 불가능"이라는 근거가 틀렸다.** 같은 척도(초당 + 100만 `Set`)로 `2^53`은 285년인데 **`2^32`는 약 72분**이다(20만 배 차이). + 실제로 안전한 이유는 **도달 시간이 아니라 충돌 조건이 한 점**이라는 것 + ("정확히 `2^32`만큼 뒤처진 항목"이라야 하고, 한 바퀴 중 한 번이라도 + 건드려지면 갱신됨). 이 문서가 바로 위 항목에서 *"근거를 정확히 적을 것"* + 이라 스스로 규정해놓고 20줄 뒤에 어긴 셈이라 그대로 정정했다. +6. `:Sync`를 "초기화에만 쓴다"고 적었으나 `gate-plan.md`가 flush 경로에서도 + 쓴다 → "반환값이 필요 없다고 이미 아는 곳 둘"로 정정. +7. `ROADMAP.md`의 `Brand.luau` predicate 목록에 `isEpoch` 누락. +8. **역전된 옛 이름 잔재** — `TweenTag`(3곳)와 `Effect(fn, state?)`(4곳). + 후자는 code-review가 짚은 것보다 실제로 더 많았다 + (`architecture.md`/`lifecycle-hooks-plan.md`/`ROADMAP.md`/`README.md`). +9. **절 재편에 안 따라온 `§` 참조 3곳** — 1라운드가 같은 유형을 잡았는데도 + 남아 있었다. `doc-check.py`가 `§번호`를 검증 못 하는 사각지대다. + +**교훈**: 1·2번은 둘 다 **"사용자 제안의 표기를 그대로 옮겼는데 그게 실제 +메커니즘과 안 맞는" 유형**이다. 승격은 문장을 옮기는 작업이 아니라 **표기가 +가리키는 것이 실제로 성립하는지 확인하는 작업**이라는 게 이번의 교훈. + +## 8. `:Refresh`/`:TrackFrom` — 에이전트가 이름 붙인 둘, 사용자 검토로 확정 + +`/code-review`까지 처리한 뒤, **에이전트가 임의로 이름 붙인 연산 둘을 +사용자에게 명시적으로 올렸다.** 동작 자체는 회신에 이미 확정돼 있었지만 표면 +이름은 에이전트 판단이었기 때문 — `conventions.md`의 "애매하면 임의로 정하지 +말고 그 자리에서 사용자에게 보고할 것"에 해당한다. + +**`:Refresh` — 그대로 확정.** 사용자가 `:Update`와 합치지 않는 이유를 직접 +정리했다: *"Update 에 인자 없는건 좀 아니야. 뭔가 받아서 받은것들에 대해서 +처리하겠다는건데, 리프레시는 아무래도 내가 받았던걸 처리하겠다는거라 +표면적 의미 자체가 다르지."* — **인자를 받아 그것을 처리하는 연산**과 +**자기가 이미 들고 있는 것을 처리하는 연산**은 표면적 의미가 달라서 오버로드로 +합치면 안 된다는 것. 표면이 하나 줄어드는 것보다 이 구분이 값이 크다. + +**`:Absorb` → `:TrackFrom`으로 개명.** 사용자 지적: *"absorb 는 조금 상위 +요소꺼를 흡수해서 상위 요소에서 제거할것만 같은 이름이긴 하네."* 맞다 — 이 +연산은 `other`를 **전혀 안 건드린다.** 덧붙여 `base/gate-plan.md`가 이미 +"흡수 집합"을 **다른 뜻**(emit을 붙들고 있음)으로 쓰고 있어, 한 코퍼스 안에 +같은 단어가 두 의미로 놓이는 문제도 있었다. + +후보를 넷 올렸고(`TrackFrom`/`SeedFrom`/`Include`/`Adopt`) 권장안이 채택됐다. +`TrackFrom`을 고른 근거 셋: + +1. **이 맵의 존재 이유를 사용자가 표현한 말이 "추적"이었다** — + *"'내가 뭘 추적하고 있나' 가 필요하죠"*(§4의 시딩 규칙 근거). 코퍼스가 이 + 맵을 설명하는 말과 메소드 이름이 일치한다. +2. **`From`이 방향을 못박아 비파괴가 드러난다** — `Absorb`가 실패한 지점. +3. **`SeedFrom`보다 오래 간다** — 동적 의존성으로 "생성 이후에 키를 더하는" + 자리가 생겨도 이름이 그대로 맞다. `Seed`는 그때 거짓말이 된다. + +배제한 것도 근거가 있다 — `Extend`/`Inherit`은 `quad2-try`의 `Base:Extends` +OOP 상속이 **확인된 죽은 접근**이라 그 어휘를 되살리면 오독을 부르고, +`Extract`는 코퍼스에서 이미 "소유권을 통째로 넘긴다"는 뜻이다. + +**두 이름 다 `question.md`에 안 올린다** — 같은 자리에서 확정됐으므로 열린 +항목이 아니다. diff --git a/.claude/todos.md b/.claude/todos.md index 79be7fb..774e8b2 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -5,38 +5,28 @@ (`.claude/question.md`, `luau-test/STATUS.md` 등). -000. **⭐⭐⭐ [2026-08-21 신설] 다음 세션의 첫 작업 — `Epoch`/`EpochMap`/`Brand` - 제안을 `base/`로 승격할 것.** 설계는 **사실상 전량 확정**됐고 - (`research/epoch-brand-composition.md`가 소스, §4에 결정 상태 전부), - 같은 날 세션이 너무 길어져 **사용자 지시로 승격만 다음 세션에 미뤘다** - (*"전면 승격을 하고 싶지만, 이 세션은 너무 길어요. 컨텍스트 피로 상 - 핸드오버 이후 승격조치를 해야할것 같습니다"*). - - **고쳐야 할 문서 넷**: `base/state-epoch-plan.md`(두 맵 → `EpochMap` 둘, - "Source의 에포크" → `Epoch`), `base/source-state-plan.md`(`Source`가 - `Epoch`를 구조적으로 만족 + `Observer` 클로저가 `fn(self, from)`), - `base/effect-plan.md`(`Effect`가 자기 `EpochMap`을 들어 다중 dep 중복 - 발화를 접음 — 그 문서의 ⚠️ 미해결 항목이 이걸로 닫힌다), - `base/brand-plan.md`(인스턴스 브랜드로 전면 재작성). - 넷 다 상단에 이 제안을 가리키는 ⚠️ 배너가 이미 달려 있다. - - **남은 미정은 하나** — 리비전 증가를 `bit32` 랩으로 할지 평이한 `+1`로 - 할지(둘 다 실질 위험 없음, `question.md` 1번). - - **감사는 승격 이후에 돌린다**(사용자 지시) — 지금 돌리면 곧 바뀔 서술을 - 감사하게 된다. - 00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~5라운드 전부 `base/` 반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서 **M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`는 `state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의 - 재계산/전파 판정은 **소스 에포크 비교** 채택으로(`base/state-epoch-plan.md`, + 재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md`, 구현은 M3) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다. + **[2026-08-21 후속] `Epoch`/`EpochMap`/`Brand` 승격도 같은 날 완료** — + 부기가 재사용 가능한 `EpochMap`으로 떨어져 나오고, 판정 인터페이스가 + `Source`에서 `Epoch`로 일반화되고, `Brand`가 인스턴스 브랜드로 재작성됐다 + (근거 기록은 `reference/epoch-brand-composition.md`, 옛 `Brand` 표면은 + `archive/brand-shared-registry-reversed.md`). 그 부수로 `effect-plan.md`의 + 다중 의존성 중복 발화 미해결 항목도 닫혔다. **[같은 날] 마지막 미정이던 + 리비전 증가 방식도 `bit32` 랩으로 확정** — `Epoch`/`EpochMap`/`Brand`에 + 열린 설계 항목은 **하나도 없다**(`base/state-epoch-plan.md` §2). **[2026-08-21 경위]** 같은 날 `/code-review high`가 "게이트가 유보했다 내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로 되돌아갔으나, **사용자가 그 자리에서 `emit(self)` + 흡수 집합으로 확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번). 같이 - 제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 count 전부 갱신 / - 새 노드는 `sourceEmitMap`은 비우고 `sourceCountMap`은 실제 count로 채운 뒤 + 제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 리비전 전부 갱신 / + 새 노드는 `emitEpochMap`은 비우고 `valueEpochMap`은 실제 리비전으로 채운 뒤 `rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) — - `state-epoch-plan.md` §5 7번. **[같은 날 두 번째 `/code-review high`]** + `base/state-epoch-plan.md` §4. **[같은 날 두 번째 `/code-review high`]** 7건이 더 나왔고 전부 유효했는데(재진입 시 빈 배치가 새어 변경이 증발하던 것, `OffWithoutEmit`이 흡수 집합을 안 비우던 것 등), 그중 사용자 판단으로 올라갔던 둘도 같은 날 닫혔다 — "재진입 계약"은 애초에 **잘못 옮긴 @@ -54,7 +44,7 @@ **`Detach` 보존 주체(`userdata` → `slot._detached`)**, **`KeyGone` 센티널**, **`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해** (`materializeSlotTree` + `mountSlotTree`, 근거는 - `research/slot-attach-decomposition.md`)가 전부 확정·반영됐다. + `reference/slot-attach-decomposition.md`)가 전부 확정·반영됐다. **[2026-08-21 정정] 5라운드 문항지를 만들었다** — 4라운드 처리 때는 사용자 지시("이후 stale 만 잡는것으로 끝낼 수 있어보임")로 안 만들기로 했으나, 같은 날 사용자가 5라운드를 요청("4차에서 예로 넘어갔던건 스킵하고, 새로운 @@ -121,45 +111,19 @@ 재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤 정합성" 절 참고). - **아래는 M3 착수 전에 결론이 필요한 항목 목록**(M0/M2는 여전히 막혀 - 있지 않음, 0번 항목 참고 — **단, M2가 M3의 `Blocker.luau`를 선당겨야 - 하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md` - 3번에도 올라가 - 있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한 - 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인은 판단이 아니라 구현 시 - 검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/` - 문서에도 ⚠️로 표시돼 있다: - - **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md` - M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau` - (또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수 - 전 필요**. `qa-request/pre-implementation-qa-round3.md`의 - "ROADMAP.md 마일스톤 정합성" 절. + **아래는 M3 착수 전에 결론이 필요한 항목 목록** — M0/M2는 막혀 있지 + 않다(0번 항목 참고). 둘 다 `question.md` 3번에도 있고 각 `base/` 문서에도 + ⚠️로 표시돼 있다. **[2026-08-21 정리]** 여기 쌓여 있던 `[해소]` 항목들 + (`Blocker.luau` 마일스톤 순서, 그룹 `Attribute` 위치 claim 키, + `SetAndDispose`, `PopOnly`→`Detach`, `KeyGone` 처분, `Store` 미선언 키 + 타입 에러, dedup 경로 대칭)은 **전부 `archive/question-resolved.md`와 + 각 `base/` 문서로 옮겼다** — 목록이 절반 넘게 해소 항목으로 차 있어 + "지금 할 일"로 읽히지 않던 것을 걷어낸 것. - **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong / 하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** - - **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) — - 방향은 확정, 키 설계가 미정. M10 착수 전 필요. - - **[2026-08-20 해소]** `SetAndDispose` 방향 — **`source:SetAndDispose(value)` - 콜론 메서드로 확정**(`:Set`과 한 세트). `state:Apply` 시그니처엔 영향 - 없음(`Apply` 오버라이딩은 `Source`→`State` 단방향 때문에 타입이 안 - 성립해서 애초에 불가). `base/slot-plan.md`의 `dispose` 절. - - **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md` - `:Unsubscribe()` 절) — M3 착수 전 확인. - - **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면 - 위치도 `None`과 같은 최상위 export로 같이 확정). 원문은 - `archive/question-resolved.md`. - - **[2026-08-21 해소]** `Detach` 홀드 중 키가 사라졌을 때의 처분 — - **`KeyGone` 센티널로 `updateFn`에게 묻는 것**으로 확정. detach 요소는 - `slot._detached` 필드가 보유하고 owner 사망 시 `activateList`가 건 - `Effect`가 정리. - `base/slot-plan.md`의 "`KeyGone`" 절. - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. - - **[2026-08-19 해소]** `Store` 미선언 키가 실제로 타입 에러가 - 나는지 — **예, 확인됨**(`luau-test/done/21-type-store-undeclared-key-rejected.luau`, - `ProcessStoreType`이 합성한 레코드 타입은 인덱서가 없어 미선언 키 - 접근이 정확히 `TypeError`로 거부됨). `base/store-plan.md`의 "Store = - Source들의 이름 붙은 모음" 절의 "확인 요구" 표시도 해소로 갱신 필요. 0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).** `question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy diff --git a/ROADMAP.md b/ROADMAP.md index 3076c79..1fa2673 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,13 +42,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 것은 "이미 invalid면 전파 중단되는지"가 **아니라** 그 반대: **emit은 자기 invalid 상태와 무관하게 전파되고**, 중복 재계산은 `:Get()` 시점 캐시로만 막히는지(**[2026-08-21]** 그 뒤 "항상"에서 - "같은 에포크의 두 번째만 접힘"으로 좁혀졌다 — 아래 참고). 특히 `:Get()`을 안 부르는 + "같은 `Epoch`의 같은 리비전이 두 번째로 도착했을 때만 접힘"으로 좁혀졌다 — 아래 참고). 특히 `:Get()`을 안 부르는 `Observer`가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터 침묵했음(`archive/invalidate-dedup-propagation-reversed.md`). 스파이크 `05-store-state-diamond-propagation.luau`는 **[2026-08-19 재작성 완료]** 그 모델("emit은 항상 전파 + `:Get()` 시점 캐시로만 dedup")로 재검증 통과 — **[2026-08-21] 그 모델이 다시 바뀌어 - `rewrite-required/`로 되돌아갔다**(소스 에포크 채택으로 다이아몬드 + `rewrite-required/`로 되돌아갔다**(`Epoch` 리비전 비교 채택으로 다이아몬드 Observer가 이제 변경당 **1회**만 울어야 함, `base/state-epoch-plan.md`)) - [x] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute(self: Source, ...) -> State`류, self 타이핑 + State 참조 혼합)이 @@ -152,8 +152,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `process(inst,k,v,index) -> (hintValue)->()` **3종** — `isHandlable`도 `inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는 `process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션)) -- [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/ - `Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ +- [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`가 + 브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`, + **다중 태깅 허용**(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`). + 옛 공유 레지스트리 + `Brand.get(x) -> tag`는 + `archive/brand-shared-registry-reversed.md`. **[2026-08-22] `isEpoch`도 + 여기 포함** — `Epoch` 인터페이스 확정으로 `EpochBrand`가 생겼고 + `base/state-epoch-plan.md`/`base/source-state-plan.md`가 "런타임 분기는 + `isEpoch`로"라고 확정했다 — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ `isAttributeKey`/`isAttribute`/`isTween`/`isBlocker`/`isSource`/ `isStore`/`isSlot`/`isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 세션 추가 — 원래 태그 목록에서 빠져있었음. **[정정, 2026-08-09 @@ -256,6 +262,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 형태까지는 M2에 필요하다. 경위는 `qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤 정합성" 절과 `pre-implementation-qa-round5-followup.md`. + **[2026-08-21 추가] `GateNode`가 `emitEpochMap`을 쓰므로 `EpochMap.luau` + (M3 목록에 있음)도 M2에 같이 들어와야 한다** — `base/gate-plan.md` 4번, + `base/state-epoch-plan.md` §3. - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 @@ -349,14 +358,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 **공용 게이트 노드는 M2에서 이미 만들어져 있다**("게이팅 먼저" 결정, 위 M2 각주 + `base/gate-plan.md`). M3에서 `Blocker`를 짤 때는 그 노드를 **다시 만들지 말고 그 위의 정책으로** 얹을 것. -- [ ] **[2026-08-21 5라운드 — 채택 확정]** State의 재계산/전파 판정은 - **소스 에포크 비교**다(`base/state-epoch-plan.md`) — `invalid` 플래그가 - 아니다. 아래 `Source.luau`/`State.luau`가 이걸 전제로 짜여야 한다: - 노드가 `sourceCountMap`(값 유효성)/`sourceEmitMap`(전파 dedup) 두 - 테이블을 들고, emit은 발행 source만 싣고, 순회는 `rawInvalid == false` - 일 때만 돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 - 중복 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 +- [ ] **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의 + 재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`) + — `invalid` 플래그가 아니다. 아래 `Source.luau`/`State.luau`가 이걸 + 전제로 짜여야 한다: `Source`가 `type Epoch = { Revision: number }`를 + 구조적으로 만족하고(`EpochBrand`에도 등록), 부기는 재사용 가능한 + **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`, `:Refresh`, `:Sync`, + `:TrackFrom` — `EpochSet = {[Epoch]: true}`, **배열 아님**) + 으로 떼어내며, State가 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 + 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 + **출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만 + 돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복 + 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 (`luau-test/STATUS.md`). +- [ ] `EpochMap.luau` — 위 부기 객체. `Effect`(M3)와 `GateNode`(M2)도 같은 + 것을 쓰므로 `State.luau`에 묻지 말고 별도 모듈로 낼 것. + **`GateNode`가 M2로 앞당겨졌으므로 이 모듈도 실제로는 M2에 필요하다** + (위 M2 게이팅 각주). - [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) @@ -370,10 +388,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14 열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록 -- [ ] `Effect(fn, state?)`(`base/effect-plan.md`) — `state` 생략 시 설치 - 1회+leaf 사망 시 확정 정리, `state` 지정 시 내부적으로 - `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React - `useEffect` 동형). Observer 구현 이후에 착수(의존 관계). +- [ ] `Effect(fn, ...deps)`(`base/effect-plan.md`, **[2026-08-21 5라운드 + `C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치 + 1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독** + (State/Source는 `Observer`, `Ref`는 `:Callback`)을 걸어 + 재실행+cleanup 체이닝(React `useEffect` 동형). **`EffectHandle`이 + `EpochMap`을 하나 들어** 공통 상류로 인한 중복 발화를 접고, 설치 구간 + 억제 플래그가 그 `Update`보다 먼저 와야 함 + (`base/state-epoch-plan.md`). Observer 구현 이후에 착수(의존 관계). `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션). @@ -663,7 +685,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수 경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서" 나던 버그 클래스가 구조적으로 사라짐. 근거 기록은 - `research/slot-attach-decomposition.md`. + `reference/slot-attach-decomposition.md`. **관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가 "부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의 `Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음). @@ -736,9 +758,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) - [ ] `:Peek<>(key): T|State|nil` 필드 읽기 접근자 + - `isState(x)`/`isSource(x): boolean`(`Brand` 공유 레지스트리 기반 — - `modifier-plan.md` 9번, `brand-plan.md`의 `Brand` 절, M2의 - `Brand.luau`에 이미 구현돼 있어야 함) + `isState(x)`/`isSource(x): boolean`(**[2026-08-21 갱신]** 인스턴스 + 브랜드 멤버십 기반 — `isSource(x)`는 `SourceBrand:is(x)`, `isState`는 + 그 위에 `StateBrand:is(x)`를 OR로 얹은 상위 개념. 옛 "공유 레지스트리 + + `Brand.get(x) == SourceTag`" 서술은 역전됨, + `archive/brand-shared-registry-reversed.md`. `modifier-plan.md` 9번, + `brand-plan.md`의 "`isX` wrapper" 절, M2의 `Brand.luau`에 이미 + 구현돼 있어야 함) - [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None` 센티널 (이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + 이를 `nil`로 재디스패치하는 base 내장 `NoneHandler` @@ -970,7 +996,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. - [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/ - `TweenTag` Brand, `Value: T` plain만 받고 State 재귀 없음) + `TweenBrand`, `Value: T` plain만 받고 State 재귀 없음) - [ ] `Handlers/Property.luau`에 `isTween(realv)` 분기 추가(기존 `Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯 (`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈