design: Epoch/EpochMap/Brand 전면 승격 + 해소 기록 flatten

앞 세션이 컨텍스트 피로로 미뤄둔 승격(todos 000번)을 수행하고, 코퍼스에
쌓여 있던 [해소]/[정정] 층을 걷어냈다. 감사 3라운드 + /code-review high로
15건을 잡아 전부 반영했다.

승격 — base/ 넷 + 파급 넷
- state-epoch-plan.md 재작성: Epoch 인터페이스({Revision:number}, 그 자체로
  키가 되는 unique 테이블, Source가 구조적으로 만족)와 EpochMap(Update/
  Refresh/Sync/TrackFrom) 신설. State는 EpochMap을 둘 컴포지션 —
  sourceCountMap/sourceEmitMap -> valueEpochMap/emitEpochMap. §1~§8로 재편.
- brand-plan.md 전면 재작성: 공유 레지스트리 + Brand.get(객체당 태그 하나)
  -> 인스턴스 브랜드 Brand() + :register/:is, 다중 태깅 허용. 발단은 Source가
  SourceBrand이면서 동시에 EpochBrand여야 하는데 옛 모양으로 표현 불가.
  역조회는 제거(전수 조사에서 쓰는 자리 0). weak-key/테이블 아이덴티티/
  duck-typing 기각 근거/predicate 합성은 전부 유지.
- source-state-plan.md: Source가 Epoch도 구조적으로 만족(Revision은 공개여야
  타입 레벨에서 성립), Observer 클로저가 fn(self, from: (Epoch|EpochSet)?).
-  effect-plan.md: 다중 의존성 중복 발화 미해결 항목이 닫힘 —
  EffectHandle이 자기 EpochMap을 들어 첫 번째만 통과시킨다.
- gate-plan.md/architecture.md/bind-system-plan.md/ROADMAP.md 어휘 통일,
  EpochMap.luau가 M2에도 필요하다는 것 반영(GateNode가 씀).

리비전 갱신은 bit32.bnot(-rev) — 사용자 확정
- a>0이면 a-1, 0이면 4294967295인 랩어라운드 감소. 갱신과 랩이 FASTCALL
  하나로 끝난다(luau 실측). 근거: 2^53 포화는 도달 불가능한데 그걸 피하려고
  값을 double 영역까지 키울 이유가 없다, 매번 도는 hot path다.
- 에이전트가 이걸 band(rev+1, mask)로 잘못 옮기고 "그러니 bit32가 더 싼 건
  아니다"라는 틀린 단서까지 달았다가 사용자 정정("제가 말한건 bit32.bnot(-a)
  입니다"). 세 문서에 정정 경위를 남겼다.
- 따름정리: 리비전은 증가가 아니라 감소한다. ==/~= 만 쓰는 지금 규칙에서만
  무해하다는 경고를 §2에 명시.

archive / flatten
- archive/brand-shared-registry-reversed.md 신설(옛 Brand 표면 원문).
- question.md 421->208줄: 해소 항목 18건을 archive/question-resolved.md로
  이관. 그 문서가 스스로 정한 규칙("해소하면 여기서 지우고 archive로")을
  다시 어기고 있었다. 이관분의 옛 필드명은 소급 수정하지 않고 머리에 경고만.
- todos.md 000번 삭제, "M3 착수 전 필요" 목록에서 해소 항목 일곱 제거
  (실제로 열린 건 중간 State GC와 store:GetDynamic 둘뿐).
- research/ -> reference/ 이동 둘(epoch-brand-composition,
  slot-attach-decomposition). 확정된 결정의 근거 기록은 research(상의 필요)도
  archive(뒤집힘)도 아니므로, reference의 폴더 기준에 그 용도를 명문화했다.

감사 3라운드(각도: base 정합성 / 인덱스+luau-test / archive+qa-request)
- §8에 "기각된 대안 — 게이트를 에포크 경계로" 논거 신설(재작성 때 떨어뜨렸고,
  두 문서가 서로 다른 없는 §번호를 대고 있었다).
- luau-test 스파이크 22를 done/ -> rewrite-required/(옛 Brand.set/get을 직접
  구현). STATUS.md 개수와 절 제목의 하드코딩 개수 정리.
- state-epoch/source-state가 "Revision을 증가시킨다"고 적어놓고 20줄 뒤에
  "감소한다"로 반박하던 자기모순 정정.

커밋 전 /code-review high — 9건, 전부 유효(감사자가 못 보는 축)
-  {Epoch}는 Luau에서 배열인데 실제 게이트 배치는 {[Epoch]:true} 집합.
  그대로 ipairs로 구현하면 유보됐다 풀린 emit이 전부 삼켜진다(gate-plan 4번이
  애초에 고치려던 그 버그) -> EpochSet으로 확정.
-  새 노드 시딩이 확정된 EpochMap 표면으로 표현 불가능했다(:With의 상류는
  State이지 Epoch가 아니고, 키 열거/병합 연산이 없었음) -> :TrackFrom 신설.
- GateNode 예외(emitEpochMap을 전파 시점에 갱신)가 §4에 미기록.
- 설치 발화엔 from이 없다 -> 옵셔널로, Effect의 억제 플래그가 Update보다
  먼저여야 함을 명시.
- 2^32 랩을 "똑같이 도달 불가능"이라 한 근거가 틀렸다(같은 척도로 285년 vs
  72분). 실제 안전 근거는 충돌 조건이 한 점이라는 것으로 정정.
- 그 외 :Sync 용도 충돌, isEpoch 누락, TweenTag 3곳, Effect(fn,state?) 4곳,
  § 참조 3곳.

에이전트가 이름 붙인 연산 둘은 사용자 검토로 확정
- :Refresh 유지 — "Update는 받은 것을 처리, Refresh는 내가 받았던 걸 처리라
  표면적 의미 자체가 다르다"(오버로드로 합치지 않음).
- :Absorb -> :TrackFrom 개명 — absorb는 상위에서 제거할 것처럼 읽히고,
  gate-plan이 이미 "흡수 집합"을 다른 뜻으로 쓴다. TrackFrom은 이 맵의 존재
  이유("내가 뭘 추적하고 있나")를 그대로 쓰고 From이 비파괴를 못박는다.

Epoch/EpochMap/Brand에 열린 설계 항목 없음. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
This commit is contained in:
qwreey 2026-08-22 01:27:18 +09:00
parent 168d3d8dcc
commit 0498816c10
Signed by: qwreey
GPG key ID: D28DB79297A214BD
30 changed files with 1630 additions and 785 deletions

View file

@ -22,7 +22,7 @@
| 폴더 | 기준 | | 폴더 | 기준 |
|---|---| |---|---|
| `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `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/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | | `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/`로 승격)까지. **처리 결과의 소스는 이 파일**). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `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/` 로그와의 중복 방지) | | `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` 포인터로 압축 | | `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)) | | `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<number>`(스케줄 시점에만 폴링) 허용. 이름은 `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`) | | `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<number>`(스케줄 시점에만 폴링) 허용. 이름은 `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<T>.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | | `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<T>.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`와 동일) | | `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<T> "Name"]`(구 `Attribute<T>`) — `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` 재사용은 불가) | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `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`가 실제 사례이자 수정 사례 | | `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를 안 탄다"는 옛 명확화 전면 정정 | | `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`. **분리는 순수 이동** | | `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<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)``Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)``Tween` opts를 `T\|State<T>`로 받아 `: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`)와 동급, 맨 뒤 | | `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`가 최종 이름, 용어 대기열에서도 제외 | | `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 | | `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 신설) ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설)
@ -81,19 +81,19 @@
| `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선. **[2026-08-07 `base/`→`reference/` 이동]** v2의 결정 자체가 아니라 다른 문서가 인용하는 온디맨드 자료라 항상 읽을 필요는 없음 | | `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-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) 제공 | | `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/` — 아직 착수 전, 상의 필요 ## `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 설계 시 훅 확장 지점만 고려 | | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 |
| `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 | | `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 |
| `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 | | `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번 절은 이제 해소된 항목만 남음 | 하 — 배경 자료, 더 이상 사용자 판단 대기 상태 아님 | | `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`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `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/` 스파이크 실측만 남음 | | `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<State<T>|T>``State<T>` 평탄화 항목 신설(백로그) — `State<State<T>>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | | `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<State<T>|T>``State<T>` 평탄화 항목 신설(백로그) — `State<State<T>>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 |
| `quad-recursive-acronym.md` | **[2026-08-14 신설]** GNU/WINE류로 `Quad`를 재귀 약어화하는 카피 브레인스토밍 — 설계 결정도 착수 게이팅도 아니고 나중에 README.md 헤딩 등에 쓸 캐치프레이즈 후보 모음. 자학 개그 방향(기각)과 지연평가/재귀·커링/펑터/클로저를 자랑하는 방향(채택 후보, 미확정) 정리 | 하 — 카피 소재, 설계 상의 필요 없음. 사용자가 최종 문구 고르면 반영 | | `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 코어 구현 시점까지 미결 | | `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` 소재 후보 | | `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` 신설로 대체됨 | | `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 순서 계약의 자동 귀결이라 새로 내주는 자유도 없음. 양쪽 논거 보존 | | `preref-order-unguaranteed-withdrawn.md` | **[철회됨, 2026-08-14 아홉 번째 세션 신설]** 복수 `PreRef`/`PostRef` 간 fire 순서를 "배열 index 순서 보장"에서 **미보장으로 바꾸려던 안** — 같은 세션에 제안·철회, 현재 계약은 **보장**(2026-08-07 결정 그대로). 반례는 `FastQuery(...) -> PreRef`류 조합(앞자리 항목이 뒤 항목의 전제를 만들어주는 정당한 합성), 보장 비용 0 + 배열 파트 index 순서 계약의 자동 귀결이라 새로 내주는 자유도 없음. 양쪽 논거 보존 |

View file

@ -18,7 +18,10 @@
**역전 이유**: 플래그가 아니라 에포크로 판정하면 (a) DFS 전파 도중 `Get()` **역전 이유**: 플래그가 아니라 에포크로 판정하면 (a) DFS 전파 도중 `Get()`
섞인 값을 캐시하는 glitch가 없어지고, (b) 그 판정이 중복 통지까지 O(1)로 접는다. 섞인 값을 캐시하는 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("이게 실제로
무엇을 고치는가")은 규칙 본문으로 흡수됐다.
--- ---

View file

@ -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에 세운 성질이 그대로 유지된다.

View file

@ -921,3 +921,299 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
툴링 체인(pesde types emit 등)의 지원 시점은 외부 사정이고 관측 수단이 툴링 체인(pesde types emit 등)의 지원 시점은 외부 사정이고 관측 수단이
에이전트에 없다. 그래서 질문을 닫고 `HUMAN_TODO.md` 8번(사용자가 시점을 에이전트에 없다. 그래서 질문을 닫고 `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/`에 반영됨.)

View file

@ -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`) │ ├── 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`) │ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형) — Handler는 Dispatch/Attribute.luau(아래) (`base/attribute-plan.md`)
│ ├── AttributeKey.luau # 단일 키 `AttributeKey<<T>>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치) │ ├── AttributeKey.luau # 단일 키 `AttributeKey<<T>>(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 세션 재설계) │ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 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`) │ ├── Effect.luau # `Effect(fn, ...deps)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
│ ├── Dispatch/ │ ├── 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) │ │ ├── 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 클로저를 반환) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
@ -322,7 +322,7 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로
프리미티브 타입 자신의 공개 어휘**라는 것: 프리미티브 타입 자신의 공개 어휘**라는 것:
1. 프리미티브 타입 생성자, `Type(args)` 스타일: `Source(default)`/ 1. 프리미티브 타입 생성자, `Type(args)` 스타일: `Source(default)`/
`Ref(default)`/`Store({defaults})`/`Modifier()`/`Relate()`/ `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)`/ 2. 그 인스턴스의 콜론 메서드: `state:Get()`/`:With(...)`/`:Compute(fn)`/
`:Observer(fn)`/`:Apply(factory)`/`:Peek(key)`, `source:Set(v)`/`:Emit()`, `:Observer(fn)`/`:Apply(factory)`/`:Peek(key)`, `source:Set(v)`/`:Emit()`,
`ref:Set(v)`/`:Callback(fn)`/`:Wait(thread?)`, `observer:Subscribe()`/ `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`/ `isPostRef`/`isModifier`/`isObserver`/... `Brand` 절), 생명주기 게이트(`canExecute`/
`bindLifetime`, `base/lifecycle-pattern.md`), 그리고 **프리미티브가 `bindLifetime`, `base/lifecycle-pattern.md`), 그리고 **프리미티브가
아닌** 내부 엔진/레지스트리의 네임스페이스 멤버(`Dispatch.process`/ 아닌** 내부 엔진/레지스트리의 네임스페이스 멤버(`Dispatch.process`/
`getHandler`/`addHandler`/`drive`, `Brand.set`/`get`) — 이 셋은 "타입 `getHandler`/`addHandler`/`drive`, `Brand()`의 `:register`/`:is`) — 이 셋은 "타입
고유의 어휘"가 아니라 여러 타입에 걸쳐 쓰이거나(`isX`류) 프리미티브 고유의 어휘"가 아니라 여러 타입에 걸쳐 쓰이거나(`isX`류) 프리미티브
자체가 아닌 것(Dispatch/Brand는 `Type(args)` 생성자가 없는 내부 엔진)의 자체가 아닌 것(Dispatch는 `Type(args)` 생성자가 없는 내부 엔진이고,
`Brand`는 생성자가 있지만 사용자 표면이 아닌 base 내부 유틸 — 사용자에게
노출되는 건 `isX` wrapper들이다)의
구성원이라 PascalCase 대상이 아님. Handler 계약 필드(`isHandlable`/ 구성원이라 PascalCase 대상이 아님. Handler 계약 필드(`isHandlable`/
`priority`/`process` — 2026-08-13 다섯 번째 세션에 `retract``process` `priority`/`process` — 2026-08-13 다섯 번째 세션에 `retract``process`
반환값으로 합쳐지기 전엔 4종이었음)도 여기 속함 — 이건 애초에 "함수"라기보다 반환값으로 합쳐지기 전엔 4종이었음)도 여기 속함 — 이건 애초에 "함수"라기보다
@ -452,11 +454,12 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬
**emit 전파는 자기 `invalid` 상태와 무관함**. 한때 `source-state-plan.md` **emit 전파는 자기 `invalid` 상태와 무관함**. 한때 `source-state-plan.md`
"이미 `invalid`면 전파 중단"으로 서술했으나 `Observer` 계약과 모순돼 역전됨 — "이미 `invalid`면 전파 중단"으로 서술했으나 `Observer` 계약과 모순돼 역전됨 —
`archive/invalidate-dedup-propagation-reversed.md`. **[2026-08-21 갱신]** `archive/invalidate-dedup-propagation-reversed.md`. **[2026-08-21 갱신]**
전파를 접는 판정은 이제 `invalid`가 아니라 **소스 에포크 비교**가 한다 — 전파를 접는 판정은 이제 `invalid`가 아니라 **`Epoch` 리비전 비교**가 한다 —
같은 소스의 같은 에포크가 두 경로로 도착하면 두 번째는 접히고(다이아몬드에서 같은 `Epoch`의 같은 리비전이 두 경로로 도착하면 두 번째는 접히고(다이아몬드에서
값도 통지도 한 번), DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch도 같이 값도 통지도 한 번), DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch도 같이
사라진다. 규칙 전량은 `base/state-epoch-plan.md`, 명시적 게이트는 사라진다. 그 부기는 재사용 가능한 **`EpochMap`**으로 떼어져 있어 State가 아닌
`base/gate-plan.md`(`state:Gate`)와 그 위의 `Blocker`). State는 쓰기 대상이 아니고, 값을 쓰는 소비자(`Effect`)도 같은 판정을 쓴다. 규칙 전량은 `base/state-epoch-plan.md`,
명시적 게이트는 `base/gate-plan.md`(`state:Gate`)와 그 위의 `Blocker`). State는 쓰기 대상이 아니고, 값을 쓰는
경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 — 경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 —
`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07] `:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07]
읽기는 `:Get()` 하나로 통일 — 프로퍼티 읽기 표기는 Ref의 `.Value` 전용으로 좁혀짐. **[표기 정정, 2026-08-18]** 여기 소문자 `.value`로 적혀 있었음). 값 하나만 읽기는 `:Get()` 하나로 통일 — 프로퍼티 읽기 표기는 Ref의 `.Value` 전용으로 좁혀짐. **[표기 정정, 2026-08-18]** 여기 소문자 `.value`로 적혀 있었음). 값 하나만

View file

@ -56,7 +56,8 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치
store-bind 가능(`None`/`nil`로 disconnect) → **`base/event-plan.md`**. 단 이벤트 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`**. `isState`를 branded 타입 전부로 일반화) → **`base/brand-plan.md`**.
- **`Tag` / `Attribute` 특수 키** → **`base/tag-plan.md`** / - **`Tag` / `Attribute` 특수 키** → **`base/tag-plan.md`** /
**`base/attribute-plan.md`**. 이 문서가 예전에 다루던 타입 파라미터화 문제 **`base/attribute-plan.md`**. 이 문서가 예전에 다루던 타입 파라미터화 문제

View file

@ -2,20 +2,17 @@
> **[2026-08-13 아홉 번째 세션] `bind-system-plan.md`에서 분리됨.** > **[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` 자체만 용어 정리 **상태**: base — 동작/구현 방식은 확정, **이름 `Brand` 자체만 용어 정리
대기**(`question.md` 1번). 대기**(`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 여덟 번째 세션) ## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션)
**배경**: `isState`(2026-08-07 다섯 번째 세션 확정, `:Peek<<T>>(key): **배경**: `isState`(2026-08-07 다섯 번째 세션 확정, `:Peek<<T>>(key):
@ -37,62 +34,111 @@ Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서
읽기 부작용도 안 만든다 — 아래 duck-typing 기각 근거 두 개가 정확히 이 읽기 부작용도 안 만든다 — 아래 duck-typing 기각 근거 두 개가 정확히 이
한 줄에서 나온다. 한 줄에서 나온다.
**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 ## ⭐ 구현 — 인스턴스 브랜드, 브랜드마다 자기 weak 집합 하나 (2026-08-21 확정)
사용(문자열 아님).**
``` **`Brand()`가 브랜드 객체 하나를 만든다.** 그 객체가 weak-key 집합 하나를
local Brand = {} 들고, 값은 **자기가 속한 브랜드에 스스로 등록**한다.
local registry = setmetatable({}, {__mode = "k"})
function Brand.set(x, tag) registry[x] = tag end ```lua
function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값 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, local ObserverBrand, EffectBrand, TagBrand, AttributeBrand, TweenBrand,
StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, PostRefTag, BlockerBrand, StateBrand, SourceBrand, StoreBrand, SlotBrand,
ModifierTag = RefBrand, PreRefBrand, PostRefBrand, ModifierBrand, EpochBrand =
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand(),
Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand()
-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: -- 각 타입의 모든 생성 지점(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) local function isSource(x)
return Brand.get(x) == SourceTag return SourceBrand:is(x)
end end
local function isState(x) local function isState(x)
return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족 return isSource(x) or StateBrand:is(x) -- Source가 State를 구조적으로 만족
end end
local function isPreRef(x) local function isPreRef(x)
return Brand.get(x) == PreRefTag return PreRefBrand:is(x)
end end
local function isPostRef(x) -- [2026-08-14 아홉 번째 세션] PostRef 확정 local function isPostRef(x) -- [2026-08-14 아홉 번째 세션] PostRef 확정
return Brand.get(x) == PostRefTag return PostRefBrand:is(x)
end end
local function isRef(x) local function isRef(x)
-- PreRef/PostRef가 Ref 런타임을 재사용 = 둘 다 Ref의 한 종류 -- 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 end
``` ```
**⭐ 다중 태깅이 가능해져도 포함 관계는 계속 이 합성으로 쓴다.** *"`Source`를
`StateBrand`에도 같이 등록하면 `isState`가 한 줄이 되지 않나"*는 하지 않는다 —
등록 지점이 여러 곳에 흩어지면 "어느 브랜드에 등록하는 걸 빠뜨렸나"가 조용한
버그가 되고, 포함 관계가 코드 모양에서 사라진다. **각 타입은 자기 브랜드에만
등록하고, 포함 관계는 predicate 한 곳에 쓴다.** 사용자 확인: *"여전합니다.
`PreRefBrand` 가 존재할테니. 거기에 `is()` 를 해서, 코드에 전부 드러나는거
똑같습니다."*
- **예외는 "구조적 인터페이스"뿐**`Source``EpochBrand`에 같이 등록하는
건 포함 관계가 아니라 **서로 다른 축의 계약을 동시에 만족**하는 것이라
합성으로 표현할 수가 없다(`isEpoch`가 `isSource`를 알아야 할 이유가 없고,
`Source`가 아닌 원천도 참여해야 한다). 이게 다중 등록의 정당한 용례다.
**정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을
뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만 뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만
염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을 염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을
@ -104,7 +150,7 @@ end
문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "`isState(x): boolean` 필요" 절에 있던 "별도 `isSource` 불필요" 서술은 문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "`isState(x): boolean` 필요" 절에 있던 "별도 `isSource` 불필요" 서술은
`session/2026-08-07-08-none-sentinel-dispatch-brand.md`에서 이미 정정됨. `session/2026-08-07-08-none-sentinel-dispatch-brand.md`에서 이미 정정됨.
**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 **갭 보강 — `isRef`/`isPreRef`/`isModifier`가 목록에서 빠져있던 것
추가(2026-08-07 열 번째 세션), 이후 `isRef`/`isPreRef` 관계 자체가 추가(2026-08-07 열 번째 세션), 이후 `isRef`/`isPreRef` 관계 자체가
재정정됨(2026-08-09 열한 번째 세션).** 처음엔 `isRef`/`isPreRef`를 재정정됨(2026-08-09 열한 번째 세션).** 처음엔 `isRef`/`isPreRef`를
`isObserver`와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로 `isObserver`와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로
@ -113,9 +159,9 @@ end
**`PreRef`도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함 **`PreRef`도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함
방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적**이라는 지적으로 뒤집힘. 방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적**이라는 지적으로 뒤집힘.
- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`Brand.get(x) == - **`isPreRef(x)`가 가장 구체적인 항등 체크**(`PreRefBrand:is(x)`),
PreRefTag`), **`isRef(x)`는 그 위에 `Brand.get(x)==RefTag`를 OR로 **`isRef(x)`는 그 위에 `RefBrand:is(x)`를 OR로 얹은 상위 개념** — 즉
얹은 상위 개념** — 즉 이제 **`isRef(preRefInstance)``true`.** **`isRef(preRefInstance)``true`.**
- **`(v=Ref)` children 배열 leaf 매치 핸들러(`Dispatch/Leaf.luau`)는 - **`(v=Ref)` children 배열 leaf 매치 핸들러(`Dispatch/Leaf.luau`)는
이제 `isHandlable``isRef(v) and not isPreRef(v) and not isPostRef(v)` 이제 `isHandlable``isRef(v) and not isPreRef(v) and not isPostRef(v)`
명시적으로 좁혀야 함**(**[2026-08-14 아홉 번째 세션]** `PostRef` 확정으로 명시적으로 좁혀야 함**(**[2026-08-14 아홉 번째 세션]** `PostRef` 확정으로
@ -124,41 +170,39 @@ end
명시적으로 말해야 하는 모양으로 바뀜(두 pre-pass 소진이 이미 걸러줘 명시적으로 말해야 하는 모양으로 바뀜(두 pre-pass 소진이 이미 걸러줘
정상 경로에선 거의 안 걸리지만, `base/ref-plan.md`의 두 동적 경로 가드 정상 경로에선 거의 안 걸리지만, `base/ref-plan.md`의 두 동적 경로 가드
Handler와 이 조합이 같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장). Handler와 이 조합이 같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장).
`isModifier`도 같은 단순 항등(`Brand.get(x) == ModifierTag`, 상위 개념 `isModifier`도 같은 단순 항등(`ModifierBrand:is(x)`, 상위 개념 없음).
없음).
- **`PostRef``PreRef`와 완전히 같은 포함 방향** — `Ref` 런타임을 그대로 - **`PostRef``PreRef`와 완전히 같은 포함 방향** — `Ref` 런타임을 그대로
재사용하고 브랜드 태그만 다르므로 `isRef(postRefInstance)``true`. 재사용하고 브랜드만 다르므로 `isRef(postRefInstance)``true`.
`isRef`는 이제 `{Ref, PreRef, PostRef}` 셋을 통과시키는 상위 개념이고, `isRef`는 이제 `{Ref, PreRef, PostRef}` 셋을 통과시키는 상위 개념이고,
`isPreRef`/`isPostRef`가 각각 가장 구체적인 항등 — `PreRef`/`PostRef` `isPreRef`/`isPostRef`가 각각 가장 구체적인 항등 — `PreRef`/`PostRef`
사이엔 포함 관계가 없음(서로 배타적인 형제). 사이엔 포함 관계가 없음(서로 배타적인 형제).
**같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** — **같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** —
`Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등 `SlotBrand:is(x)`/`EffectBrand:is(x)`인 단순 항등 predicate, 브랜드
predicate, 태그 자체는 원래부터 목록에 있었지만(`SlotTag`) `isX` 자체는 원래부터 목록에 있었지만 `isX` wrapper로 명시적으로 안 적혀 있던
wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md` 것을 `base/modifier-plan.md`
"Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이
들어오면 즉시 error" 절이 필요로 해서 이번에 들어오면 즉시 error" 절이 필요로 해서 이번에
같이 적음. 같이 적음.
**[정정, 2026-08-18 구현 전 QA] `Brand`는 아무 의존성도 갖지 않는다 — **[정정, 2026-08-18 구현 전 QA] `Brand`는 아무 의존성도 갖지 않는다 —
`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 `Brand.get(x)`가 범용 `None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 판별 창구가 범용
introspection 창구 역할까지 겸하려면 `None`도 빠지면 안 되므로 *"`Brand.get`이 introspection을 겸하려면 `None`도 빠지면 안 되므로 *"내부적으로 `x == None`
내부적으로 `x == None`먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에 먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에 레지스트리 조회로 폴백하며,
레지스트리 조회로 폴백하며, `isNone`이 그 특수 분기의 구현체가 된다고 했다. `isNone`이 그 특수 분기의 구현체가 된다고 했다. 사용자 판정: *"Brand 는 None 을
사용자 판정: *"Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에 참조할 필요는 없음. Brand 자체는 아에 의존성 없고, None 도 테깅되는건 맞으나,
의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v == isNone 대신 필요한 곳에서 v == None 하면 되는 일, 혹은 isNone 구현 자체를
None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일."* 그렇게 해주면 되는 일."*
- **`Brand → None` 의존을 만들지 않는다** — 특수 분기를 넣는 순간 가장 - **`Brand → None` 의존을 만들지 않는다** — 특수 분기를 넣는 순간 가장
밑바닥 유틸이어야 할 `Brand`가 다른 프리미티브를 참조하게 된다. 밑바닥 유틸이어야 할 `Brand`가 다른 프리미티브를 참조하게 된다.
- **`isNone`은 그냥 `v == None`** — 그런 이름의 함수를 두더라도 구현이 - **`isNone`은 그냥 `v == None`** — 그런 이름의 함수를 두더라도 구현이
레퍼런스 비교 한 줄이면 된다. 싱글턴이라 그게 제일 싸고 정확하다는 판단 레퍼런스 비교 한 줄이면 된다. 싱글턴이라 그게 제일 싸고 정확하다는 판단
자체는 그대로 유효. 자체는 그대로 유효.
- **`None` 자체를 레지스트리에 평범하게 태깅하는 건 무방**(사용자가 - **`None``NoneBrand`에 평범하게 등록하는 것 자체는 무방**(사용자가
허용) — 그러면 특수 분기 없이도 `Brand.get(None)`이 답을 준다. 즉 허용) — 다만 **[2026-08-21]** 역조회 창구가 없어졌으므로 등록의 유일한
"범용 introspection 창구"를 지키고 싶으면 **특수 분기가 아니라 평범한 효용은 `NoneBrand:is(x)`뿐이고, 그건 `v == None`이 더 싸다. 어느 쪽이든
등록**으로 지킨다. 등록을 안 하기로 하면 `None`은 그 창구에서 빠지는 `Brand` 쪽 코드는 그대로다.
것을 받아들인다 — 어느 쪽이든 `Brand` 쪽 코드는 그대로다.
**duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는 **duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는
이유 — 서로 독립된 두 가지(2026-08-20 `B-4`에서 분리 명시)**: 이유 — 서로 독립된 두 가지(2026-08-20 `B-4`에서 분리 명시)**:
@ -173,11 +217,13 @@ None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되
`isHandlable` 계약(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)과 `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-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과
같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이 같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이
GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 GC되면 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 레지스트리인
레지스트리인 Observer의 `:Subscribe` 레지스트리와는 반대 성격). Observer의 `:Subscribe` 레지스트리와는 반대 성격).
**Luau 타입 narrowing은 자동으로 안 됨 — 명시적 `::` 캐스팅 필요(사용자 **Luau 타입 narrowing은 자동으로 안 됨 — 명시적 `::` 캐스팅 필요(사용자
확인, Luau가 원래 그렇게 동작함).** `isX(v)`가 참이어도 Luau 컴파일러가 확인, Luau가 원래 그렇게 동작함).** `isX(v)`가 참이어도 Luau 컴파일러가
@ -187,6 +233,10 @@ State<any> ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는
패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만 패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만
"자동 narrowing"을 기대하면 안 됨. "자동 narrowing"을 기대하면 안 됨.
**이름은 전부 가칭 — `Brand`/`ObserverTag`류 포함 용어 정리 대상, **마일스톤**: **커밋된 M1 코드는 아직 `Brand`를 안 쓴다**(`quad-base/src`는
`.claude/question.md`에 반영.** `init.luau`/`Relate.luau`/`Debug`뿐, 2026-08-21 확인) — 이 재작성의 전환
비용은 문서뿐이었다. 실제 구현은 각 프리미티브가 만들어지는 마일스톤에서
같이 간다.
**이름은 전부 가칭 — `Brand`/`ObserverBrand`류 포함 용어 정리 대상,
`.claude/question.md`에 반영.**

View file

@ -294,8 +294,8 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정"
### 그래서 정정 후 그림 (권고) ### 그래서 정정 후 그림 (권고)
- **emit(무효화 신호)은 구독자에게 전파된다.** State가 이미 `invalid`인지는 - **emit(무효화 신호)은 구독자에게 전파된다.** State가 이미 `invalid`인지는
전파 여부와 무관. **[2026-08-21 갱신]** "항상"은 빠졌다 — 소스 에포크 전파 여부와 무관. **[2026-08-21 갱신]** "항상"은 빠졌다 — `Epoch` 리비전
비교를 채택하면서 **같은 에포크가 두 번째로 도착하면 접힌다** 비교를 채택하면서 **같은 `Epoch`의 같은 리비전이 두 번째로 도착하면 접힌다**
(`base/state-epoch-plan.md`). `invalid`로 접는 것이 금지인 건 그대로. (`base/state-epoch-plan.md`). `invalid`로 접는 것이 금지인 건 그대로.
- 다이아몬드 중복 *재계산*은 **pull-recompute가 이미 구조적으로** - 다이아몬드 중복 *재계산*은 **pull-recompute가 이미 구조적으로**
막음(`architecture.md`의 서술이 맞음) — 별도 장치 불필요. 막음(`architecture.md`의 서술이 맞음) — 별도 장치 불필요.
@ -330,8 +330,8 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정"
> 위로 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음 > 위로 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음
> `invalid`가 꺼짐. > `invalid`가 꺼짐.
**[2026-08-21 후속]** 위는 2026-08-14 시점 확정 원문이다. 그 뒤 소스 에포크 **[2026-08-21 후속]** 위는 2026-08-14 시점 확정 원문이다. 그 뒤 `Epoch` 리비전
비교를 채택하면서 "항상"에 예외가 하나 생겼다 — **같은 소스의 같은 에포크가 비교를 채택하면서 "항상"에 예외가 하나 생겼다 — **같은 `Epoch`의 같은 리비전이
두 번째로 도착하면 접힌다**(`base/state-epoch-plan.md`). `invalid`로 접는 것이 두 번째로 도착하면 접힌다**(`base/state-epoch-plan.md`). `invalid`로 접는 것이
금지라는 이 문단의 요지는 그대로다. 금지라는 이 문단의 요지는 그대로다.

View file

@ -266,21 +266,41 @@ Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect
성립하지 않는 게 확인됐다(`base/gate-plan.md`의 8번) — 설치 구간엔 어떤 성립하지 않는 게 확인됐다(`base/gate-plan.md`의 8번) — 설치 구간엔 어떤
`Set`도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가 `Set`도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가
없다. 설치 중 발화를 누르는 플래그 하나면 되고 새 메커니즘이 필요 없다. 없다. 설치 중 발화를 누르는 플래그 하나면 되고 새 메커니즘이 필요 없다.
- **⚠️ [2026-08-21 신설 — 미해결] 의존성들이 공통 상류를 공유하면 한 파동에 - **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만
`fn`이 여러 번 돈다.** `A → b`, `A → c`, `Effect(fn, b, c)`에서 `A:Set()` 돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`,
한 번에 `b`가 자기 observer를, `c`가 자기 observer를 **각각 정당하게** 깨워 `A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를,
`fn`이 두 번 돈다. **소스 에포크 dedup으로는 안 접힌다**`b``c`는 서로 `c`가 자기 observer를 **각각 정당하게** 깨워 `fn`이 두 번 돌았다. State 층
다른 노드라 접어줄 **공통 하류가 없고**, 둘 다 규칙 1에 정당하게 걸린다 dedup으론 안 접힌다 — `b``c`는 서로 다른 노드라 접어줄 **공통 하류가
(`base/state-epoch-plan.md`). 위의 "설치 구간 억제"도 이건 안 덮는다(그건 없고**, 둘 다 §4의 1번 규칙에 정당하게 걸린다(`base/state-epoch-plan.md`).
등록 시점만). 위의 "설치 구간 억제"도 이건 안 덮는다(그건 등록 시점만).
- **해법 후보**: `Effect`**자기 `EpochMap`을 하나 들고** 각 내부 observer가 - **확정된 해법**: `EffectHandle``EpochMap`**하나** 들고, 각 내부
그걸 `Update`해서 첫 번째만 통과시키는 것 — Observer의 클로저가 받은 `from`으로 그걸 `Update`한다. **`true`일 때만
`research/epoch-brand-composition.md`가 정확히 이걸 노린 제안이고 `fn`을 부른다.** `Effect`가 곧 그 dep들의 **공통 하류**가 되므로, 한
승격 대기 중이다. (검토했다 접은 대안: deps를 하나의 파생 노드로 수렴시켜 파동에 몇 개가 깨우든 첫 번째만 통과한다.
다이아몬드 dedup에 태우기 — 노드가 늘고 "N deps → N observers" 구조를 ```lua
바꿔야 해서 위 안보다 못하다.) -- 각 dep의 내부 Observer가 공통으로 거는 클로저
- **그 제안을 안 쓴다면** `useEffect`처럼 "N번 돌아도 무방"으로 계약을 function(self, from)
명시하는 선택지도 있다. **어느 쪽이든 M3 구현 전에 정할 것.** 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도 - **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도
N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를 N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를
같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피 같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피

View file

@ -16,11 +16,13 @@ GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부
표면을 주기도 하구요."* 아래 본문 중 "공개 프리미티브로 꺼낸다"류 서술은 그 표면을 주기도 하구요."* 아래 본문 중 "공개 프리미티브로 꺼낸다"류 서술은 그
이전 시점 표현이니 이 배너 기준으로 읽을 것. 이전 시점 표현이니 이 배너 기준으로 읽을 것.
**⚠️ [2026-08-21] emit 페이로드 표현을 바꾸는 제안이 `research/`에 대기 중** **⚠️ [2026-08-21 반영 완료] emit 페이로드는 `Epoch | EpochSet`이다**
(`research/epoch-brand-composition.md`) — 하류가 게이트 identity를 한 번도 안 (`EpochSet = { [Epoch]: true }` — **배열이 아니라 집합**,
쓰므로 페이로드를 `Epoch|{Epoch}`로 일반화하는 안. 아래 4번의 **기제(흡수 집합, `base/state-epoch-plan.md` §3) — 하류가
flush 시 스왑, 게이트-게이트 unfold, 빈 배치 무통지)는 그대로 유효**하고 넘기는 게이트 identity를 한 번도 안 쓰므로 게이트 노드 자체는 안 싣고 **떼어낸
값의 타입만 바뀐다. 승격 전엔 이 문서가 정본. `Epoch` 집합 스냅샷**만 넘긴다(근거 기록은
`reference/epoch-brand-composition.md`). 아래 4번의 기제(흡수 집합, flush 시
스왑, 게이트-게이트 unfold, 빈 배치 무통지)는 그대로다.
**한 줄**: `Blocker`가 쓰던 "게이티드 State 노드"를 한 겹 일반화해서, **상류 **한 줄**: `Blocker`가 쓰던 "게이티드 State 노드"를 한 겹 일반화해서, **상류
emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 **`state:Gate(setup)` emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 **`state:Gate(setup)`
@ -119,29 +121,29 @@ end)
`Debounce`/`Throttle`도 emit-gate로 확정돼 있어(`base/debounce-throttle-plan.md` `Debounce`/`Throttle`도 emit-gate로 확정돼 있어(`base/debounce-throttle-plan.md`
§4) `Gate`도 같은 계약으로 통일한다. 공개 계약 문구: **"게이트를 통과하지 §4) `Gate`도 같은 계약으로 통일한다. 공개 계약 문구: **"게이트를 통과하지
않은 값도 `:Get()`으로는 보인다."** 않은 값도 `:Get()`으로는 보인다."**
`base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서 §5의 3번이 `base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서가 "게이트를
"게이트를 에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 뒤집지 않기
뒤집지 않기 위해서다. 위해서다 — 그 문서 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목.
4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — `emit(self)` + 흡수 4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — `emit(self)` + 흡수
집합.** 문제는 실재했다: 확정 `setup``(emit: () -> ()) -> (() -> ())` 집합.** 문제는 실재했다: 확정 `setup``(emit: () -> ()) -> (() -> ())`
양쪽 다 source를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부 양쪽 다 출처를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부
`[source]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무 `[epoch]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무
원천도 못 지목한 채 도착해 **하류에서 삼켜진다**(2026-08-21 원천도 못 지목한 채 도착해 **하류에서 삼켜진다**(2026-08-21
`/code-review high` 발견). `/code-review high` 발견).
**확정 기제**(사용자 안, 에이전트가 냈던 (a) `nil` 전체 확인 / (b) 소스마다 **확정 기제**(사용자 안, 에이전트가 냈던 (a) `nil` 전체 확인 / (b) 소스마다
개별 emit은 기각 — 각각 O(1) 판정을 깨거나 `Blocker`의 "정확히 1회"를 깬다): 개별 emit은 기각 — 각각 O(1) 판정을 깨거나 `Blocker`의 "정확히 1회"를 깬다):
- `GateNode`가 **자기를 거쳐간 소스 집합**을 들고 있는다 — - `GateNode`가 **자기를 거쳐간 `Epoch` 집합**을 들고 있는다 —
`withheld : { [source] : true }`, **weak key**(소스 맵과 같은 이유, `withheld : { [epoch] : true }`, **weak key**(`EpochMap`과 같은 이유,
`state-epoch-plan.md` §5의 5번). `state-epoch-plan.md` §3).
- **⭐ [2026-08-21 단순화] 통과와 유보를 구분하지 않는다.** 상류 emit이 - **⭐ [2026-08-21 단순화] 통과와 유보를 구분하지 않는다.** 상류 emit이
오면 **정책을 실행하기 전에 무조건** 그 출처를 `withheld`에 넣고, 그 오면 **정책을 실행하기 전에 무조건** 그 출처를 `withheld`에 넣고, 그
다음 정책을 실행한다. 정책이 `emit()`을 부르면 게이트가 하류에 전파하고, 다음 정책을 실행한다. 정책이 `emit()`을 부르면 게이트가 하류에 전파하고,
안 부르면 집합에 그대로 쌓인다. 안 부르면 집합에 그대로 쌓인다.
- **⚠️ [2026-08-21 `/code-review high`] "무조건"은 *정책의 통과/유보와 - **⚠️ [2026-08-21 `/code-review high`] "무조건"은 *정책의 통과/유보와
무관하게*라는 뜻이지 *수신 규칙을 건너뛴다*는 뜻이 아니다.** 게이트도 무관하게*라는 뜻이지 *수신 규칙을 건너뛴다*는 뜻이 아니다.** 게이트도
평범한 노드처럼 `state-epoch-plan.md` §2의 규칙 1~3을 **먼저** 적용하고, 평범한 노드처럼 `state-epoch-plan.md` §4의 규칙 1~3을 **먼저** 적용하고,
3번(둘 다 같음)으로 삼켜진 emit은 **정책도 안 돌고 집합에도 안 3번(둘 다 같음)으로 삼켜진 emit은 **정책도 안 돌고 집합에도 안
들어간다.** 안 그러면 다이아몬드(`A→B→G`, `A→C→G`)에서 `A:Set()` 들어간다.** 안 그러면 다이아몬드(`A→B→G`, `A→C→G`)에서 `A:Set()`
번에 정책이 두 번 돌아, `Throttle`의 leading 통과 직후 두 번째 emit이 번에 정책이 두 번 돌아, `Throttle`의 leading 통과 직후 두 번째 emit이
@ -166,17 +168,17 @@ end)
emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다 emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다
``` ```
하류가 순회하는 것은 `gate._withheld`가 아니라 **받은 `batch`** 다. 하류가 순회하는 것은 `gate._withheld`가 아니라 **받은 `batch`** 다.
중첩 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다. 같은 에포크가 중첩 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다. 같은 리비전이
중첩으로 두 번 도달하는 경우는 애초에 문제가 아니다 — 하류 count가 이미 중첩으로 두 번 도달하는 경우는 애초에 문제가 아니다 — 하류 맵이 이미
최신이라 규칙 3으로 삼켜진다. 최신이라 규칙 3으로 삼켜진다.
- **그래서 게이트는 언제나 자기(와 그 배치)를 출처로 낸다** — 그냥 - **그래서 게이트는 언제나 자기(와 그 배치)를 출처로 낸다** — 그냥
통과시킬 때도 상류 출처를 그대로 넘기지 않는다. 사용자: *"후행 노드들은 한개가 지연된거로 통과시킬 때도 상류 출처를 그대로 넘기지 않는다. 사용자: *"후행 노드들은 한개가 지연된거로
생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가 생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가
없습니다."* 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정 없습니다."* 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정
규칙은 완전히 같다. 규칙은 완전히 같다.
- 하류가 **평범한 노드**면, 출처가 `GateNode`일 때 그 집합을 순회하며 각 - 하류가 **평범한 노드**면 그냥 `EpochMap:Update(batch)`가 집합을 순회하고,
소스에 평소 규칙(1~3)을 적용하고, 하나라도 걸리면 **받은 출처를 그대로** 하나라도 걸리면 **받은 배치를 그대로** 더 아래로 넘긴다
더 아래로 넘긴다(`state-epoch-plan.md` §2). (`state-epoch-plan.md` §4 — 단일이든 집합이든 같은 규칙이다).
- **⭐ [2026-08-21 신설] 하류가 또 다른 게이트면 — 받은 집합을 풀어 - **⭐ [2026-08-21 신설] 하류가 또 다른 게이트면 — 받은 집합을 풀어
자기 `withheld`에 합친다.** 게이트가 게이트 emit을 받는 경우가 정의돼 자기 `withheld`에 합친다.** 게이트가 게이트 emit을 받는 경우가 정의돼
있지 않던 구멍이었다(사용자 발견). **출처를 그대로 넘기면 안 된다** 있지 않던 구멍이었다(사용자 발견). **출처를 그대로 넘기면 안 된다**
@ -184,27 +186,29 @@ end)
하류 게이트가 그 참조만 들고 유보했다가 나중에 풀면 이미 지나간 배치를 하류 게이트가 그 참조만 들고 유보했다가 나중에 풀면 이미 지나간 배치를
내보내게 된다. 그래서 수신 시점에 **풀어서 옮겨 담아야** 한다: 내보내게 된다. 그래서 수신 시점에 **풀어서 옮겨 담아야** 한다:
``` ```
-- 출처가 Source면 그 하나를, 게이트 배치면 그 배치 전부를 편다 -- 출처가 Epoch 하나면 그 하나를, 배치면 그 배치 전부를 편다
for source in unfold(origin) do for epoch in unfold(from) do
self._withheld[source] = true self._withheld[epoch] = true
end end
-- 그 다음 평소대로 정책 실행 -- 그 다음 평소대로 정책 실행
``` ```
게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이 게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이
먼저 풀리든 정보가 안 샌다. 먼저 풀리든 정보가 안 샌다.
- **게이트의 `sourceEmitMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다 - **게이트의 `emitEpochMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다
(집합 전체에 대해 한꺼번에). 그래야 "내가 하류로 던진 에포크"라는 맵의 (집합 전체에 대해 한꺼번에, `:Sync(batch)`). **이건 `state-epoch-plan.md`
뜻이 게이트에서도 참이 된다 — 유보 중 같은 에포크가 다른 경로로 또 오면 §4 의사코드의 유일한 예외이고, 그 문서에 예외로 기록돼 있다.** 그래야 "내가 하류로 던진
규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에 있으므로 무해하다. 리비전"이라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중 같은 리비전이
다른 경로로 또 오면 규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에
있으므로 무해하다.
- **⭐ [2026-08-21 `/code-review high`] emit 없이 푸는 경로는 집합을 - **⭐ [2026-08-21 `/code-review high`] emit 없이 푸는 경로는 집합을
*버려야* 한다.** `blocker:OffWithoutEmit()`은 정의상 "밀린 전파를 버리며 *버려야* 한다.** `blocker:OffWithoutEmit()`은 정의상 "밀린 전파를 버리며
끈다"(`base/blocker-plan.md`)이므로, 그 경로도 **`withheld`를 비운다** 끈다"(`base/blocker-plan.md`)이므로, 그 경로도 **`withheld`를 비운다**
(전파는 안 하고 새 테이블로 스왑). 안 그러면 `Dispatch.drive`의 배치 (전파는 안 하고 새 테이블로 스왑). 안 그러면 `Dispatch.drive`의 배치
게이팅이 매 프레임 `On()` → … → `OffWithoutEmit()`을 도는 동안 집합이 게이팅이 매 프레임 `On()` → … → `OffWithoutEmit()`을 도는 동안 집합이
**단조 증가**하고, 나중에 아무 소스나 한 번 통과하는 순간 **버리기로 했던 **단조 증가**하고, 나중에 아무 `Epoch`나 한 번 통과하는 순간 **버리기로
옛 소스들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다. 했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다.
- 그렇게 비우고 나면 하류의 `sourceEmitMap`은 뒤에 남지만, 그 소스의 다음 - 그렇게 비우고 나면 하류의 `emitEpochMap`은 뒤에 남지만, 그 `Epoch`
진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요. 다음 진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요.
**⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라 **⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라
**노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화). **노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화).

View file

@ -134,8 +134,8 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관
### `OnDestroyed(fn)` ### `OnDestroyed(fn)`
`Effect(function() return fn end)`를 반환하는 팩토리. `Effect(fn, state?)`가 `Effect(function() return fn end)`를 반환하는 팩토리. `Effect(fn, ...deps)`가
`state` 생략 시 "설치 시 즉시 1회 실행 + 반환값이 leaf 사망 시 정확히 **deps 생략 시** "설치 시 즉시 1회 실행 + 반환값이 leaf 사망 시 정확히
1회 호출되는 cleanup"이라는 기존 계약(`base/effect-plan.md` 28행)을 1회 호출되는 cleanup"이라는 기존 계약(`base/effect-plan.md` 28행)을
그대로 재사용 — 다만 여기서는 **설치 단계에서 실행되는 함수가 `fn` 그대로 재사용 — 다만 여기서는 **설치 단계에서 실행되는 함수가 `fn`
자신이 아니라 `function() return fn end`라는 래퍼**라는 점에 주의. 자신이 아니라 `function() return fn end`라는 래퍼**라는 점에 주의.

View file

@ -1955,7 +1955,7 @@ nil/None 금지)는 그대로.
> 않는다**는 것이었다(`setLength` 슬롯이 하나뿐이라 원리적으로 불가능). > 않는다**는 것이었다(`setLength` 슬롯이 하나뿐이라 원리적으로 불가능).
> **결론: `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해하고 > **결론: `materializeSlotTree`(부기) + `mountSlotTree`(물리)로 분해하고
> `attachSlot`은 그 둘을 부르는 두 줄짜리 래퍼로 남긴다** — 이름/시그니처/ > `attachSlot`은 그 둘을 부르는 두 줄짜리 래퍼로 남긴다** — 이름/시그니처/
> 호출부 전부 불변. 근거 기록은 `research/slot-attach-decomposition.md`. > 호출부 전부 불변. 근거 기록은 `reference/slot-attach-decomposition.md`.
**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는 **✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할 `Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할
@ -2006,7 +2006,7 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
-- [전면 재작성, 2026-08-21 구현 전 QA 4라운드 확정] 옛 단일 `attachSlot` -- [전면 재작성, 2026-08-21 구현 전 QA 4라운드 확정] 옛 단일 `attachSlot`
-- **비공개 재귀 둘 + 얇은 공개 진입점**으로 분해. 공개 표면(이름/시그니처/ -- **비공개 재귀 둘 + 얇은 공개 진입점**으로 분해. 공개 표면(이름/시그니처/
-- 호출부 셋)은 하나도 안 바뀐다 — 쪼갠 건 함수가 아니라 **재귀**다. -- 호출부 셋)은 하나도 안 바뀐다 — 쪼갠 건 함수가 아니라 **재귀**다.
-- 근거와 대안 비교는 `research/slot-attach-decomposition.md`. -- 근거와 대안 비교는 `reference/slot-attach-decomposition.md`.
-- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다. -- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다.
local function materializeSlotTree(slot, physicalTarget, ownerKey, position) local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
@ -2086,7 +2086,7 @@ end
-- 아래 `acc``slot.Offset:Get()`에서 시작하는데, 그 값이 최종값이 되는 건 -- 아래 `acc``slot.Offset:Get()`에서 시작하는데, 그 값이 최종값이 되는 건
-- materialize의 마지막 `recompute`가 끝난 뒤다. 순서를 뒤집거나 materialize를 -- materialize의 마지막 `recompute`가 끝난 뒤다. 순서를 뒤집거나 materialize를
-- 건너뛰고 부르면 물리 삽입 위치가 조용히 어긋난다(공개 `attachSlot`이 둘을 -- 건너뛰고 부르면 물리 삽입 위치가 조용히 어긋난다(공개 `attachSlot`이 둘을
-- 붙여 부르는 것이 이 계약의 전부 — `research/slot-attach-decomposition.md`의 -- 붙여 부르는 것이 이 계약의 전부 — `reference/slot-attach-decomposition.md`의
-- "prepare만 하고 mount 안 한 중간 상태" 항목이 아직 열려 있는 이유이기도 하다). -- "prepare만 하고 mount 안 한 중간 상태" 항목이 아직 열려 있는 이유이기도 하다).
local function mountSlotTree(slot, physicalTarget) local function mountSlotTree(slot, physicalTarget)
slot._mounted = true slot._mounted = true
@ -2117,7 +2117,7 @@ end
``` ```
**왜 쪼갰나 — 이득 넷**(상세와 기각된 대안은 **왜 쪼갰나 — 이득 넷**(상세와 기각된 대안은
`research/slot-attach-decomposition.md`): `reference/slot-attach-decomposition.md`):
1. **C6와 C7이 처음으로 동시에 만족된다.** 옛 코드는 `setLength`의 자리가 1. **C6와 C7이 처음으로 동시에 만족된다.** 옛 코드는 `setLength`의 자리가
하나뿐이라 "최종값으로 등록"(C6)과 "부기가 물리보다 먼저"(C7) 중 하나를 하나뿐이라 "최종값으로 등록"(C6)과 "부기가 물리보다 먼저"(C7) 중 하나를

View file

@ -94,6 +94,18 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
불필요 — Source 객체 자체가 저장소 역할을 함. 이 모델은 이전에 불필요 — Source 객체 자체가 저장소 역할을 함. 이 모델은 이전에
검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/ 검토했던 "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 상속과는 - **이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 OOP 상속과는
다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류 다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류
매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임
@ -180,14 +192,6 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
왜 프리미티브가 아니라 탑레벨 싱글톤인지는 `base/dispatch-core-plan.md` 왜 프리미티브가 아니라 탑레벨 싱글톤인지는 `base/dispatch-core-plan.md`
"Dispatch는 프리미티브가 아니다" 절 참고. "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()` 시점에만) ## 전파 모델 확정: push-invalidate(신호만) / pull-recompute(`Get()` 시점에만)
**Fusion식 eager 노드·생성순 정렬은 안 만듦.** **Fusion식 eager 노드·생성순 정렬은 안 만듦.**
@ -195,11 +199,12 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
- `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만 - `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만
쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다 쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다
업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구). 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구).
- **⭐ [2026-08-21 재확정 — 판정 주체가 `invalid`에서 소스 에포크로 바뀜] - **⭐ [2026-08-21 재확정 — 판정 주체가 `invalid`에서 `Epoch` 리비전으로 바뀜]
emit은 자기 `invalid` 상태와 **무관하게** 전파되고, 접히는 것은 오직 emit은 자기 `invalid` 상태와 **무관하게** 전파되고, 접히는 것은 오직
**같은 소스의 같은 에포크가 두 번째로 도착했을 때**뿐이다.** 판정 규칙 **같은 `Epoch`의 같은 리비전이 두 번째로 도착했을 때**뿐이다.** 판정 규칙
전량은 **`base/state-epoch-plan.md`가 소스** — 여기선 이 절의 다른 서술과 전량은 **`base/state-epoch-plan.md`가 소스** — 여기선 이 절의 다른 서술과
어긋나지 않게 요지만 적는다. 어긋나지 않게 요지만 적는다. 여기 있던 "emit은 *항상* 전파된다"는 무조건
서술의 역전 원문은 `archive/always-propagate-no-dedup-superseded.md`.
- **`invalid`(구현 이름 `rawInvalid`) 플래그의 역할은 "내 캐시가 낡았다"는 - **`invalid`(구현 이름 `rawInvalid`) 플래그의 역할은 "내 캐시가 낡았다"는
표시 하나뿐** — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 표시 하나뿐** — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면
상류로 올라가 재계산하고, 그 결과를 캐시에 넣고, 플래그를 끈다. 상류로 올라가 재계산하고, 그 결과를 캐시에 넣고, 플래그를 끈다.
@ -207,15 +212,13 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
방식이 폐기된 이유는 `:Get()`을 호출하지 않는 `Observer`(아래 방식이 폐기된 이유는 `:Get()`을 호출하지 않는 `Observer`(아래
"`state:Observer(fn)`" 절이 명시적으로 허용하는 사용법)가 **한 번 울고 "`state:Observer(fn)`" 절이 명시적으로 허용하는 사용법)가 **한 번 울고
영구히 침묵**하기 때문이고, 그 실패 모드는 지금도 유효하다(원문은 영구히 침묵**하기 때문이고, 그 실패 모드는 지금도 유효하다(원문은
`archive/invalidate-dedup-propagation-reversed.md`). 에포크 비교엔 그 모드가 `archive/invalidate-dedup-propagation-reversed.md`). 리비전 비교엔 그
없다 — 매 `Set`이 **새 에포크**라 항상 통과하고, 접히는 건 **다이아몬드에서 모드가 없다 — 매 `Set`이 **새 리비전**이라 항상 통과하고, 접히는 건
같은 에포크가 두 경로로 도착한 두 번째**뿐이다. **다이아몬드에서 같은 리비전이 두 경로로 도착한 두 번째**뿐이다.
- **emit 전파를 늦추거나 흡수할 수 있는 건 위 dedup 외엔 명시적인 게이트 - **emit 전파를 늦추거나 흡수할 수 있는 건 위 dedup 외엔 명시적인 게이트
요소뿐** — `state:Gate(setup)`(`base/gate-plan.md`)와 그 위에 얹히는 요소뿐** — `state:Gate(setup)`(`base/gate-plan.md`)와 그 위에 얹히는
`Blocker`(`base/blocker-plan.md`), `base/debounce-throttle-plan.md`의 시간 `Blocker`(`base/blocker-plan.md`), `base/debounce-throttle-plan.md`의 시간
기반 정책. **평범한 State는 그 외의 이유로 신호를 삼키지 않는다.** 기반 정책. **평범한 State는 그 외의 이유로 신호를 삼키지 않는다.**
- **[2026-08-21] 여기 있던 "emit은 *항상* 전파된다"는 무조건 서술은
역전됐다** — 원문은 `archive/always-propagate-no-dedup-superseded.md`.
- 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 — - 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 —
"필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/ "필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/
생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한 생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한
@ -250,7 +253,7 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
**⭐ [2026-08-21 역전] 중복 *통지*도 이제 접힌다.** 여기엔 원래 "quad가 추가로 **⭐ [2026-08-21 역전] 중복 *통지*도 이제 접힌다.** 여기엔 원래 "quad가 추가로
접지 않는 것은 중복 통지뿐이고, `d` 아래 `Observer`가 한 사이클에 두 번 우는 접지 않는 것은 중복 통지뿐이고, `d` 아래 `Observer`가 한 사이클에 두 번 우는
것은 의도된 동작"이라고 적혀 있었으나, `base/state-epoch-plan.md` 채택으로 것은 의도된 동작"이라고 적혀 있었으나, `base/state-epoch-plan.md` 채택으로
**두 번째 신호는 삼켜진다** — 그 신호가 나르는 에포크를 `d`가 이미 봤기 **두 번째 신호는 삼켜진다** — 그 신호가 나르는 리비전을 `d`가 이미 봤기
때문이다. 그래서 위 1단계는 "`d`가 두 번 받는다"가 아니라 **"두 번째는 때문이다. 그래서 위 1단계는 "`d`가 두 번 받는다"가 아니라 **"두 번째는
`d`에서 멈춘다"**가 된다. 역전 원문과 이게 2026-08-14 역전을 되돌린 게 아닌 `d`에서 멈춘다"**가 된다. 역전 원문과 이게 2026-08-14 역전을 되돌린 게 아닌
이유는 `archive/always-propagate-no-dedup-superseded.md`. 이유는 `archive/always-propagate-no-dedup-superseded.md`.
@ -258,7 +261,7 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
**부수로 같이 고쳐진 것 — 섞인 값(glitch).** 옛 모델에선 전파가 DFS라 **부수로 같이 고쳐진 것 — 섞인 값(glitch).** 옛 모델에선 전파가 DFS라
`b` 가지가 먼저 끝까지 내려가고, 그 아래 Observer가 `d:Get()`을 부르면 `c` `b` 가지가 먼저 끝까지 내려가고, 그 아래 Observer가 `d:Get()`을 부르면 `c`
아직 신호를 못 받아 **옛 캐시를 반환**해 `d``(b_new, c_old)`를 캐시했다. 아직 신호를 못 받아 **옛 캐시를 반환**해 `d``(b_new, c_old)`를 캐시했다.
에포크 비교는 `c`가 신호 없이도 스스로 낡음을 알아채므로 이 창이 없다 — 리비전 비교는 `c`가 신호 없이도 스스로 낡음을 알아채므로 이 창이 없다 —
상세와 재현 시나리오는 `base/state-epoch-plan.md` §1. 상세와 재현 시나리오는 `base/state-epoch-plan.md` §1.
**전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)** **전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)**
@ -929,6 +932,26 @@ retract/Destroy되면 자동으로 정리됨.
두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를 두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를
따로 안 짜도 됨). `state:Observer()`(인자 없는 "항상 관측" 유틸)도 따로 안 짜도 됨). `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은 - **값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함.** 기존 "emit은
무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(위 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(위
"전파 모델 확정" 절)이 그대로 적용됨: `fn`은 "뭔가 "전파 모델 확정" 절)이 그대로 적용됨: `fn`은 "뭔가
@ -938,18 +961,18 @@ retract/Destroy되면 자동으로 정리됨.
`:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의 `:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의
`noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()` `noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()`
호출 여부를 작성자가 직접 결정하게 열어둔 것. 호출 여부를 작성자가 직접 결정하게 열어둔 것.
- **⚠️ 이 허용이 전파 규칙에 의존한다(2026-08-14 명시, - **⚠️ 이 허용이 전파 규칙에 의존한다(2026-08-14 명시, 2026-08-21 갱신).**
[2026-08-21 갱신]).** 지금 형태로 말하면 **"`Set` 한 번은 새 에포크라 의존하는 명제는 **"`Set` 한 번은 새 리비전이라 항상 통과한다"** 하나다 —
항상 통과한다"**에 의존한다 — 에포크 dedup이 접는 건 *같은* 에포크의 dedup이 접는 건 *같은* `Epoch`*같은* 리비전이 두 번째로 도착한
두 번째 도착뿐이라 이 Observer는 매 변경마다 정확히 한 번 운다 것뿐이라, 이 Observer는 매 변경마다 정확히 한 번 운다
(`base/state-epoch-plan.md`). 아래 서술의 "항상 전파"는 그 뜻으로 읽을 것. `fn``:Get()`을 안 하면 상류 State는 계속 (`base/state-epoch-plan.md` §4). **`invalid`로 전파를 접는 최적화는
`invalid`로 남는데, 만약 "이미 `invalid`면 전파를 멈춘다"는 규칙이 지금도 금지**다 — `fn``:Get()`을 안 하면 상류 State는 계속
있으면 **이 Observer는 두 번째 변경부터 영원히 안 울림**. 실제로 `invalid`로 남으므로, 그런 규칙이 있으면 **이 Observer는 두 번째
2026-08-14 이전까지 위 "전파 모델 확정" 절에 그런 문장이 있었고, 변경부터 영원히 안 울린다**(2026-08-14 이전에 실제로 그 문장이 위
이 계약과 정면 충돌하는 상태로 방치돼 있었음 "전파 모델 확정" 절에 있었고 이 계약과 충돌한 채 방치됐었다 —
(`archive/invalidate-dedup-propagation-reversed.md`). **두 서술은 `archive/invalidate-dedup-propagation-reversed.md`). **두 서술은 같이
같이 움직여야 함** — 전파를 접는 최적화를 다시 넣고 싶어지면 움직여야 함** — 전파를 접는 최적화를 다시 넣고 싶어지면 반드시 이
반드시 이 항목부터 확인할 것. 항목부터 확인할 것.
- **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯 - **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯
번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제 번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제
`fn`을 만들어 반환하는 패턴, `Modifier``Boldify(10)` 커링(`modifier-plan.md` `fn`을 만들어 반환하는 패턴, `Modifier``Boldify(10)` 커링(`modifier-plan.md`

View file

@ -1,26 +1,23 @@
# State 재계산/전파 판정 — 소스 에포크 비교 (2026-08-21 확정) # State 재계산/전파 판정 — `Epoch` 비교와 `EpochMap` 컴포지션 (2026-08-21 확정)
**상태**: **확정.** 사용자 제안으로 시작해 같은 날 라운드에 걸쳐 다듬은 뒤 **상태**: **확정.** 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤
**채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다. **채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다.
채택하면 될것 같아요."* 구현은 **M3(Source/State)**. 채택하면 될것 같아요."* 구현은 **M3(Source/State)**.
**⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는 **⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는
게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을 게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을
얹는다.** 그 절이 원래 갖고 있던 두 서술은 이 채택으로 뒤집혔고(아래 §3), 얹는다.** 그 절이 원래 갖고 있던 두 서술은 이 채택으로 뒤집혔고, 역전 원문은
역전 원문은 `archive/always-propagate-no-dedup-superseded.md`에 있다. `archive/always-propagate-no-dedup-superseded.md`에 있다.
**⚠️ [2026-08-21] 이 문서의 구조를 바꾸는 제안이 `research/`에 대기 중이다** — **읽는 순서**: 규칙만 필요하면 **§2~§4**만 보면 된다. §1은 왜 이걸 하는가
두 맵을 `EpochMap`으로 컴포지션하고 `Source` 대신 **`Epoch` 인터페이스**로 (실재하는 glitch), §5는 emit 페이로드, §6은 State 밖의 소비자, §7은 비용,
일반화하는 안(`research/epoch-brand-composition.md`). **사실상 전량 확정됐고 §8은 구현 시 확인할 것.
승격만 남았다**(같은 날 세션이 길어져 다음 세션으로 미룸). 여기 적힌 규칙 자체는
그대로 성립하고 표현만 바뀐다 — 승격 전엔 이 문서가 정본.
**읽는 순서**: 규칙만 필요하면 **§2**만 보면 된다. §1은 왜 이걸 하는가(실재하는
glitch), §3은 무엇이 고쳐지는가, §5는 세부 계약, §7은 구현 시 확인할 것.
**히스토리**: 구현 전 QA 5라운드(`SS-2`/`SS-3`)에서 나왔고, 회신 원문은 **히스토리**: 구현 전 QA 5라운드(`SS-2`/`SS-3`)에서 나왔고, 회신 원문은
`qa-request/pre-implementation-qa-round5-response.md`, 네 라운드의 정정 경위는 `qa-request/pre-implementation-qa-round5-response.md`, 여러 라운드의 정정
`qa-request/pre-implementation-qa-round5-followup.md`의 M·N절이 소스. 경위는 `qa-request/pre-implementation-qa-round5-followup.md`의 M·N절이 소스.
`Epoch`/`EpochMap`으로 일반화한 마지막 라운드의 근거 기록은
`reference/epoch-brand-composition.md`.
## 1. 사용자가 지목한 문제 (실재함) ## 1. 사용자가 지목한 문제 (실재함)
@ -29,7 +26,8 @@ A ──> B ──┐
└──> C ──┴──> D └──> C ──┴──> D
``` ```
`A:Set()` 한 번에 대해 지금 모델(push-invalidate + pull-recompute)에서: `A:Set()` 한 번에 대해 옛 모델(push-invalidate + pull-recompute, 판정 주체가
`invalid` 플래그)에서:
1. `A`가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라 **`B` 쪽 가지가 먼저 1. `A`가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라 **`B` 쪽 가지가 먼저
끝까지 내려간다** — `D``B`를 통해 신호를 받고, 그 아래 Observer가 발화한다. 끝까지 내려간다** — `D``B`를 통해 신호를 받고, 그 아래 Observer가 발화한다.
@ -41,280 +39,400 @@ A ──> B ──┐
**(a) 한 사이클 안에서 잘못된 값이 한 번 관측되고**(그 값으로 이미 프로퍼티가 **(a) 한 사이클 안에서 잘못된 값이 한 번 관측되고**(그 값으로 이미 프로퍼티가
써지는 등 부작용이 나간다), **(b) 같은 계산이 두 번 돈다.** 리액티브 문헌에서 써지는 등 부작용이 나간다), **(b) 같은 계산이 두 번 돈다.** 리액티브 문헌에서
말하는 전형적인 **glitch**이고, 지금 quad 문서 어디에도 이 현상이 서술돼 있지 말하는 전형적인 **glitch**다.
않다. `base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절은
`base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절은
**중복 재계산이 없다**고만 말하는데, 그 논증은 *"누군가 `d:Get()`을 부르는 **중복 재계산이 없다**고만 말하는데, 그 논증은 *"누군가 `d:Get()`을 부르는
시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에 시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에
발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다. 발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다. 아래 규칙이 이걸 고친다.
## 2. 사용자 제안 (최종 정리형) — **[2026-08-21 3차 정정본]** ## 2. `Epoch` — 판정의 최소 인터페이스
각 State가 **자기에게 영향을 주는 루트 `Source`들의 에포크(count)** 를 들고 **판정에 필요한 건 둘뿐이다: identity와 "직전과 달라지는 리비전".** 그걸
있다가, 그것과 실제 Source의 현재 count를 비교해 재계산/전파 여부를 정한다. 이름 붙인 것이 `Epoch`다.
**[2026-08-21] 마지막 정정으로 테이블이 둘로 갈렸다** — 아래 "왜 둘인가" 참고.
```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 State
sourceCountMap : { [source (weak key)] : count } valueEpochMap : EpochMap -- "내 값이 이 Epoch에 대해 최신인가" (값 유효성)
-- "내 값이 이 소스에 대해 최신인가" (값 유효성) emitEpochMap : EpochMap -- "이 Epoch의 이 리비전을 하류로 이미 던졌는가" (전파 dedup)
sourceEmitMap : { [source (weak key)] : count } rawInvalid : boolean -- "재계산이 필요하다"는 확정 플래그
-- "이 소스의 이 에포크를 내가 하류로 이미 던졌는가" (전파 dedup)
rawInvalid : boolean
-- "재계산이 필요하다"는 확정 플래그
``` ```
**⭐ [2026-08-21 확정] 노드가 생길 때의 초기값은 두 맵이 서로 다르다.** **⭐ 왜 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의 상태로
하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시
던져야하는지 봐야하나' 를 보는걸 나누는거죠."* 순회가 없다면 **"값을
최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서** 맵
하나로 충분하다. 순회를 넣는 순간 그 둘이 갈라진다 — **순회는 값만 앞당기고
통지는 안 한다.**
- **`sourceEmitMap`은 비운 채로 시작한다.** `nil ~= source.count`라 어떤 emit이 **⭐ 대원칙 — 무효화를 결정하는 건 언제나 리비전 비교지 emit의 도착이 아니다.**
와도 "처음 보는 것"으로 걸린다 — 그리고 그게 맞다. 사용자: *"새로 생성된 emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도
노드에서 들어온 emit 은, 개념적으로 해당 노드가 한번도 받아본적 없는 리비전이 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2번 규칙). 사용자
emit 입니다."* 정리: *"emit 자체가 invalid 하게 만드는 직접 트리거는 아니라서, (이미 count 가
- **`sourceCountMap`은 반대로 상류에서 전부 끌어와 실제 count로 채우고, 최신이면) 캐시가 유효하다."* 아래 나머지 규칙은 전부 이 원칙의 따름정리다.
`rawInvalid = true`로 시작한다.** 이쪽은 비워두면 안 된다 — 순회가 훑을
### 노드가 생길 때의 초기값 — 두 맵이 서로 다르다
- **`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 할 순 없는게, '내가 뭘 오판한다. 사용자: *"이건 상류가 주는대로 lazy 할 순 없는게, '내가 뭘
추적하고 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제 추적하고 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제
count 로 둡니다. 그러고 `rawInvalid` 를 true 로 두세요."* count 로 둡니다. 그러고 `rawInvalid` 를 true 로 두세요."*
- 그래서 **`:With`에서 두 상류가 같은 소스에 다른 count를 들고 있을 때의 병합 - 그래서 **`:With`에서 두 상류가 같은 `Epoch`에 다른 리비전을 들고 있을 때의
규칙도 필요 없다** — 어차피 생성 시점의 라이브 count로 통일된다. 병합 규칙도 필요 없다** — 어차피 생성 시점의 라이브 리비전으로 통일된다.
**⭐ 대원칙 — 무효화를 결정하는 건 언제나 count 비교지 emit의 도착이 아니다.** ### emit을 받았을 때 — 두 맵을 각각 `Update` 하고 그 boolean으로 결정한다
emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도
count가 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2번 규칙). 사용자
정리(2026-08-21): *"emit 자체가 invalid 하게 만드는 직접 트리거는 아니라서,
(이미 count 가 최신이면) 캐시가 유효하다."* 이 문서의 나머지 규칙은 전부 이
원칙의 따름정리다.
- `Source:Set()`/`:Emit()`은 자기 count를 증가시킨다. ```lua
- **`emit`은 값을 안 싣고 count도 안 싣는다 — 싣는 건 "이 통지의 출처" 하나**다. local valueChanged = self.valueEpochMap:Update(from)
받는 쪽이 거기서 count를 **그때그때 라이브로** 읽는다. 그래서 "에포크 5의 local emitChanged = self.emitEpochMap:Update(from)
emit" 같은 건 없다. 출처로 올 수 있는 것은 **둘**이다 if valueChanged then self.rawInvalid = true end
(**[2026-08-21 확장]** — 원래는 `Source`뿐이었다): if valueChanged or emitChanged then
- **`Source`** — 그 소스 하나를 확인하라. -- 뒤로 emit (받은 from을 그대로 넘긴다)
- **게이트 배치** — 게이트가 유보를 풀며 **떼어낸 소스 집합**을 확인하라 end
(`base/gate-plan.md`의 4번). 게이트가 `blocker:Off()` 등으로 풀 때 쓴다. ```
**게이트의 살아있는 `withheld` 테이블이 아니라 그 자리에서 스왑해 떼어낸
스냅샷**이다 — 재진입이 나도 바깥 전파가 빈 집합을 순회하지 않게 하기 세 갈래로 읽으면 이렇다:
위함(같은 절).
**평범한 노드는 출처를 안 바꾼다** — 자기를 끼워넣지 않고 받은 출처를 그대로 1. **`valueEpochMap`이 달랐다** → 값이 낡았다. `rawInvalid = true`를 세우고
아래로 넘긴다. **`GateNode`만 예외로 언제나 자기 자신을 출처로 새로 낸다** **뒤로 emit** 한다(두 맵 모두 갱신됨).
(`base/gate-plan.md`의 4번). 2. **`valueEpochMap`은 같은데 `emitEpochMap`이 달랐다** → **값은 이미 최신이지만
- **emit을 받았을 때** — 출처가 `Source`면 판정은 O(1)이다, 그 항목 하나만 본다: 통지는 아직 안 나갔다**(순회가 앞질러 흡수했거나, 게이트가 붙들고 있는 동안
1. `sourceCountMap[source] ~= source.count` → 둘 다 `source.count`로 갱신하고 하류가 `Get()`으로 앞당겨 읽은 경우). `emitEpochMap`만 갱신되고 **뒤로
`rawInvalid = true`를 세운 다음 **뒤로 emit** 한다. emit** 한다 — `rawInvalid`는 안 건드린다.
2. 같은데 `sourceEmitMap[source] ~= source.count` → **값은 이미 최신이지만 3. **둘 다 같다****삼킨다.** 다이아몬드에서 같은 리비전이 두 경로로 도착한
통지는 아직 안 나갔다**(아래 순회가 앞질러 흡수했거나, 게이트가 붙들고 두 번째가 여기서 접힌다.
있는 동안 하류가 `Get()`으로 앞당겨 읽은 경우). `sourceEmitMap`
갱신하고 **뒤로 emit** 한다 — `rawInvalid`는 안 건드린다. - **⚠️ [2026-08-22 신설] `GateNode`는 이 의사코드를 그대로 쓰지 않는다.**
3. 둘 다 같으면 → **삼킨다.** 위 코드는 `emitEpochMap`**수신 시점에** 갱신하는데, 게이트는
- **다른 소스 항목은 건드리지 않는다** — 그 소스들은 자기가 직접 emit 하므로. `base/gate-plan.md` 4번이 **전파할 때 `:Sync(batch)`로** 갱신하는 것으로
- **게이트는 배치가 비어 있으면 애초에 통지하지 않는다**(`base/gate-plan.md`의 확정돼 있다(그래야 "내가 하류로 던진 리비전"이라는 맵의 뜻이 게이트에서도
8번) — 빈 배치를 흘리는 건 "쌓인 게 없는데 뒤로 넘기는" 꼴이라 State 층에 참이 된다 — 유보 중엔 아직 안 던졌으니까). 그래서 게이트에서는:
`Source:Emit`을 추가하는 것과 같아진다. 그래서 아래 규칙이 빈 집합을 받는 - 판정(규칙 1~3)은 **똑같이** 먼저 돈다. 규칙 3으로 삼켜지면 정책도 안 돌고
경우는 없다. 흡수 집합에도 안 들어간다(`gate-plan.md` 4번).
- **출처가 게이트 배치면** 그 집합을 순회하며 **각 소스에 위 1~3을 - 다만 `emitEpochMap:Update`를 수신 시점에 부르지 않으므로, **유보 중에
그대로 적용**하고, 하나라도 1번이나 2번에 걸렸으면 **받은 출처(그 게이트)를 같은 리비전이 다른 경로로 또 오면 규칙 2로 걸려 정책이 한 번 더 돈다.**
그대로** 뒤로 넘긴다. 전부 3번이면 삼킨다 — 다이아몬드에서 같은 해제 통지가 이미 흡수 집합에 있어 무해하다(같은 절). 정책이 실제로 emit한 뒤에는
두 번 도착해도 두 번째가 접히는 건 소스 emit과 똑같다. `:Sync(batch)`가 돌아 있으므로 그 다음 도착은 정상적으로 규칙 3에 걸린다.
- **⚠️ 단, 받는 쪽이 `GateNode`면 배치를 그대로 넘기지 않고 풀어서 자기 - **이 예외가 여기 기록돼 있지 않았다**(2026-08-21 커밋 전 `/code-review high`
`withheld`에 합친다** — 배치는 상류 게이트가 이번 전파에만 쓰는 일회성 발견) — §4대로 구현하면 `gate-plan.md` 4번의 계약이 조용히 깨진다.
스냅샷이라, 참조만 들고 있다가 나중에 풀면 그 배치가 이미 지나간 것이 - **`from`이 하나면 판정은 O(1)이다** — 그 항목 하나만 본다. **다른 항목은
된다(`base/gate-plan.md`의 4번). 건드리지 않는다**(그 `Epoch`들은 자기가 직접 emit 하므로).
- **재계산 판정**: - **`from`이 집합이면**(게이트 배치, §5) `Update`가 알아서 순회하고, 하나라도
- `rawInvalid == true` → 그냥 재계산한다. 순회할 이유가 없다(이미 확정). 달랐으면 `true`를 준다 — 규칙이 그대로 성립한다.
- `rawInvalid == false`**그때만 `sourceCountMap`을 훑는다.** 목적은 하나뿐 —
**못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에 막혀 있었다거나 ### 재계산 판정
전파 파동이 아직 이 가지에 안 닿았다거나). 다른 항목이 발견되면
**`sourceCountMap`만** 갱신하고 `rawInvalid = true`로 만든다. - **`rawInvalid == true`** → 그냥 재계산한다. 순회할 이유가 없다(이미 확정).
- **⭐ 순회는 `sourceEmitMap`을 건드리지 않고, 뒤로 emit 하지도 않는다.** - **`rawInvalid == false`** → **그때만 `valueEpochMap:Refresh()`를 부른다.**
목적은 하나뿐 — **못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에
막혀 있었다거나 전파 파동이 아직 이 가지에 안 닿았다거나). `true`가 나오면
`rawInvalid = true`로 만들고 재계산한다.
- **⭐ 순회는 `emitEpochMap`을 건드리지 않고, 뒤로 emit 하지도 않는다.**
그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 **하류까지 정상적으로 그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 **하류까지 정상적으로
전파된다**(사용자: *"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*). 전파된다**(사용자: *"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*).
- **재계산이 끝나면 `rawInvalid = false`, 그리고 `sourceCountMap`은 자기가 읽은 이게 없으면 **값은 맞는데 통지가 죽는** 실패 모드가 생긴다 — 2026-08-14에
상류 전부에 대해 갱신한다**(**[2026-08-21 확정]** — 발행 소스 항목만 갱신하는 폐기된 옛 dedup의 "영구 침묵"과 같은 계열이다
게 아니다). 맵의 뜻이 "내 값이 이 소스에 대해 최신인가"이므로, 방금 계산한 (`archive/invalidate-dedup-propagation-reversed.md`).
값은 정의상 **모든** 상류에 대해 최신이다. `sourceEmitMap`**안 건드린다**
계산은 통지가 아니다.
- 이게 실제로 갈리는 자리는 **게이트가 붙들고 있는 동안 하류가 `Get()`으로
앞당겨 읽는 경우**다. 그때 `sourceCountMap`이 앞서 있으므로, 나중에 게이트가
풀며 보내는 통지는 위 2번(통지만)으로 떨어져 **같은 값을 다시 계산하지
않는다.**
- count가 싫다면 `[source] -> {}` 처럼 **유니크 테이블 identity**로 같은 판정이
가능하다(사용자 대안).
**⭐ 왜 테이블이 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의 ### 재계산이 끝나면
상태로 하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시
던져야하는지 봐야하나' 를 보는걸 나누는거죠."* 순회가 없다면 **"값을
최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서** 카운트
하나로 충분하다 — 실제로 2026-08-21 2차 정정에서 그렇게 정리했었다. 순회를
넣는 순간 그 둘이 갈라진다(순회는 값만 앞당기고 통지는 안 한다). 그래서 이
분리는 **한 번 철회됐던 `seen`/`computedAt` 분리가, 다른 이유로 되살아난
것**이다 — 옛 근거("전파 시점에 갱신된 count가 캐시를 신선한 것으로 오인시킨다")는
여전히 틀렸고, 지금 근거는 **순회가 값과 통지를 비대칭으로 앞당긴다**는 것이다.
**⚠️ [2026-08-21 `/code-review high`] 아래 희소 구현 메모는 "둘 다 상류에서 - **`rawInvalid = false`**, 그리고 **`valueEpochMap`은 자기가 읽은 상류
복사"와 그대로는 안 맞는다** — 아래 §5의 7번이 소스. 두 맵을 명시적으로 다 전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이
들고 시작하는 게 안전하고, 희소화는 그 규칙이 정해진 뒤 얹을 것. `Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해
최신이다. 사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 전부
업데이트 하는건 맞아보입니다."*
- **`emitEpochMap`은 안 건드린다 — 계산은 통지가 아니다.**
- 이 "전부 갱신"이 실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가
`Get()`으로 앞당겨 읽는 경우**다. 그때 `valueEpochMap`이 앞서 있으므로,
나중에 게이트가 풀며 보내는 통지는 위 2번(통지만)으로 떨어져 **같은 값을
다시 계산하지 않는다.**
**구현 메모 — `sourceEmitMap`은 희소 테이블로 두면 된다.** emit이 count를 안 ## 5. emit 페이로드 — `Epoch | EpochSet`, 게이트는 안 싣는다
싣고 받는 쪽이 라이브로 읽으므로, 이 테이블에 실제로 필요한 정보는 **"순회가
앞질러 흡수해서 아직 안 던진 소스가 무엇인가"** 뿐이다. 평상시엔 비어 있고
순회가 발견했을 때만 채워지는 `pending` 집합으로 구현해도 위 세 규칙이 그대로
성립한다(1번에서 지우고, 2번에서 있으면 던지고 지운다).
## 3. 에이전트 분석 — 이게 실제로 무엇을 고치는가 - **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번).
- **⚠️ 받는 쪽이 `GateNode`면 배치를 그대로 넘기지 않고 풀어서 자기
`withheld`에 합친다** — 배치는 상류 게이트가 이번 전파에만 쓰는 일회성
스냅샷이라, 참조만 들고 있다가 나중에 풀면 그 배치가 이미 지나간 것이
된다(같은 절).
- **게이트는 배치가 비어 있으면 애초에 통지하지 않는다**(`base/gate-plan.md`의
8번) — 그래서 위 규칙이 빈 집합을 받는 경우는 없다.
**결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라 ## 6. State 밖의 소비자도 같은 판정을 쓴다 — `Effect`
`Get()`의 의미론을 바꾸는 결정이다.**
- **고쳐진다 — 섞인 값.** 위 3단계에서 `D``C:Get()`을 부르면, `C`도 자기 `EpochMap`을 떼어낸 실익이 여기서 나온다. **`Effect`가 자기 `EpochMap`을 하나
`sourceCountMap`에서 `A`의 count가 자기가 기록한 것보다 앞선 걸 보고 **신호가 들고** 각 의존성의 내부 Observer가 그걸 `Update`하면, 한 파동에 여러 dep가
아직 안 왔어도 스스로 재계산**한다. 그래서 `D`는 항상 `(B_new, C_new)` 깨워도 **첫 번째만 `true`**`fn`이 한 번만 돈다.
얻는다. **`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. 비용 이건 `A → b`, `A → c`, `Effect(fn, b, c)`에서 **접어줄 공통 하류가 없어**
State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그 지점이 곧
공통 하류가 된다. 계약 전량은 `base/effect-plan.md`의 "`Effect(fn, ...deps)`"
절이 소스.
**그래서 `Observer` 클로저는 출처를 인자로 받는다** —
`fn(self, from: (Epoch | EpochSet)?)`. `:Compute``fn(self, ...)`와 같은
모양이고(설치 발화에는 출처가 없어 `nil`이다),
값이 아니라 **핸들과 메타데이터**만 넘기므로 "값을 안 실어주는 구독" 계약은
안 깨진다. `base/source-state-plan.md`의 "`state:Observer(fn)`" 절이 소스.
## 7. 비용
사용자 추산(*"해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해 사용자 추산(*"해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해
인덱싱 하는 정도"*)에 동의한다. 덧붙일 것 둘: 인덱싱 하는 정도"*)에 동의한다. 덧붙일 것 둘:
- 두 맵의 크기는 **그 노드 상류에 있는 서로 다른 루트 Source의 수**다. - 두 맵의 크기는 **그 노드 상류에 있는 서로 다른 루트 `Epoch`의 수**다.
체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가 체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가
큰 경우는 드물다. 큰 경우는 드물다.
- **[2026-08-21 정정]** 순회 조건이 뒤집혔으므로 비용 구도도 뒤집힌다 — - **훑는 쪽(`rawInvalid == false`, 즉 "안 바뀐 것 같다")이 흔한 경로**다.
**훑는 쪽이 흔한 경로**(`rawInvalid == false`, 즉 "안 바뀐 것 같다")다. 그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 **재계산은
그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 안 도는** 경로다. 반대로 `rawInvalid == true`면 순회를 아예 건너뛰고 바로
**재계산은 안 도는** 경로다. 반대로 `rawInvalid == true`면 순회를 아예 재계산한다.
건너뛰고 바로 재계산한다. - 맵 하나당 객체 하나가 늘지만(State당 둘), 옛 모양도 테이블 둘이었으므로
컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다.
## 5. 열린 질문 (채택 전에 답이 필요) ## 8. 구현 시 확인할 것
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<T> 로 변환해주는 등"*. 즉 "항상 최신 값을 필드로 들고 있는 박스"를 만드는
순수 슈가. **이건 이 문서의 결정과 독립**이고(에포크를 채택하든 안 하든 쓸 수
있다), `research/operator-sugar-plan.md`의 콤비네이터 계열에 더 가깝다 —
채택되면 그쪽으로 옮길 것.
## 7. 구현 시 확인할 것 (채택 확정 후 남은 실무 항목)
- **역전된 두 서술은 `archive/always-propagate-no-dedup-superseded.md`에 있다** - **역전된 두 서술은 `archive/always-propagate-no-dedup-superseded.md`에 있다**
"emit은 자기 `invalid`와 무관하게 **항상** 전파된다"와 "quad가 접지 않는 것은 "emit은 자기 `invalid`와 무관하게 **항상** 전파된다"와 "quad가 접지 않는 것은
중복 *통지*뿐이다". 지금 계약은 **"`invalid`로는 절대 안 접고, 같은 소스의 같은 중복 *통지*뿐이다". 지금 계약은 **"`invalid`로는 절대 안 접고, 같은 `Epoch`
에포크가 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면 2026-08-14에 같은 리비전이 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면
폐기된 "영구 침묵" 버그로 되돌아간다(§3). 2026-08-14에 폐기된 "영구 침묵" 버그로 되돌아간다.
- **선언 안 된 의존성에 대한 UB 조항은 안 만든다** — §5의 1번(사용자 기각). - **선언 안 된 의존성에 대한 UB 조항은 안 만든다.** 에이전트가 "새 모델은 더
- **`luau-test` 스파이크 하나**: §1의 다이아몬드를 그대로 짜서 (a) 에포크 없이는 강한 약속을 하니 예외를 UB로 못 박아야 한다"고 제안했으나 **사용자가 기각**:
섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 사라지는지 대조. *"그건 아니다. 이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을
- **`sourceEmitMap`은 희소 `pending` 구현으로 시작해도 된다** — §2의 구현 메모. 던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고
- 곁가지였던 폴링 슈가는 §6 그대로 — 이 결정과 독립이고 `operator-sugar-plan.md` 생각한다."* — 선언 안 한 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<T>` 로 변환해주는 등"*. **이 문서의 결정과
독립**이고 `research/operator-sugar-plan.md`의 콤비네이터 계열에 속한다.

View file

@ -185,7 +185,7 @@ State<T | Tween<T>>`가 나옴 — Modifier/State/Source/StoreBind 코드엔
**핸들러 계층 UB 체크와도 안 부딪힘** — `Tween<T>``Ref`/`Observer`/ **핸들러 계층 UB 체크와도 안 부딪힘** — `Tween<T>``Ref`/`Observer`/
`Slot`류처럼 dispatch 참가자(`process`를 가진 Handler에 매칭되는 값)가 아니라 `None`/ `Slot`류처럼 dispatch 참가자(`process`를 가진 Handler에 매칭되는 값)가 아니라 `None`/
`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/ `Tag`처럼 순수 raw 데이터 값(별도 `TweenBrand`)이라, Modifier 필드/
`State<Modifier>`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에 `State<Modifier>`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에
안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch 안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch
참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래 참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래
@ -373,7 +373,7 @@ quad-roblox 레벨 편의 함수라 base 계약에 영향 없음.
## 패키지 경계 — `Tag`가 이미 밟은 것과 같은 분리 (2026-08-10 세션 확정) ## 패키지 경계 — `Tag`가 이미 밟은 것과 같은 분리 (2026-08-10 세션 확정)
- **quad-base**: `Tween.luau` — 값 타입(`Tween(opts)` 팩토리, `isTween` - **quad-base**: `Tween.luau` — 값 타입(`Tween(opts)` 팩토리, `isTween`
predicate/`TweenTag` Brand)만. 엔진 무관. predicate/`TweenBrand` Brand)만. 엔진 무관.
- **quad-roblox**: `Handlers/Property.luau`(기존 프로퍼티 세팅 로직에 - **quad-roblox**: `Handlers/Property.luau`(기존 프로퍼티 세팅 로직에
`isTween` 분기 + 3-상태 릴레이션 저장 + override 정책 추가) + `isTween` 분기 + 3-상태 릴레이션 저장 + override 정책 추가) +
`Animate.luau`(편의 콤비네이터, 신규). `Animate.luau`(편의 콤비네이터, 신규).

View file

@ -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 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 | | **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 | | **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 기능을 **16은 특히 `type function`이라는 비교적 최근/계속 진화 중인 Luau 기능을
쓰므로, luau-analyze 버전이 오래되면 아예 문법 자체를 못 알아볼 수 있음** 쓰므로, luau-analyze 버전이 오래되면 아예 문법 자체를 못 알아볼 수 있음**
— 그 경우는 실패가 아니라 "이 Luau 버전에서 type function 자체가 아직 — 그 경우는 실패가 아니라 "이 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` "요소 소유권" | | `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 여섯 번째 세션) | | `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번 | | `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<T, Pattern>`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<<A,B>>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 | | `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad<T, Pattern>`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<<A,B>>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 |
## 공통 유틸리티 ## 공통 유틸리티

View file

@ -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`**로 정정되면서, 두 루프 버전을 검증하던 > props 순회가 **단일 일반화 `for`**로 정정되면서, 두 루프 버전을 검증하던
> `01`이 낡아 `done/``rewrite-required/` 이동(검증 대상인 순서 계약 > `01`이 낡아 `done/``rewrite-required/` 이동(검증 대상인 순서 계약
> 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이 > 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이
@ -40,9 +45,9 @@
| 폴더 | 뜻 | 개수 | 누가 처리 | | 폴더 | 뜻 | 개수 | 누가 처리 |
|---|---|---|---| |---|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 | | `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 7 | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 17 | — | | `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — |
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
@ -67,7 +72,10 @@
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
아님(계약 자체는 위에서 이미 확정됨). 아님(계약 자체는 위에서 이미 확정됨).
## 🟠 `rewrite-required/` — 스파이크가 낡음 (5건) ## 🟠 `rewrite-required/` — 스파이크가 낡음
(개수는 위 표와 폴더가 소스 — 여기서 다시 세지 않는다. 예전엔 이 제목이
개수를 들고 있다가 실제와 어긋난 적이 있다.)
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 **[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
@ -86,6 +94,15 @@
통과 상태로 `done/`에 두면 `01`은 구현이 안 하는 두 루프 순회를, `05` 통과 상태로 `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`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 | | `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` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | | `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는 손댈 것 없음 | | `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`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `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 섹션은 손댈 것 없음 | | `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/` — 이 환경에서 못 돌림 ## ⚪ `not-run/` — 이 환경에서 못 돌림
@ -104,10 +122,15 @@
|---|---| |---|---|
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | | `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개"라고 적혀 있었는데, 그 산술이 이미 **[2026-08-21 정정]** 여기 "런타임 12개"라고 적혀 있었는데, 그 산술이 이미
`rewrite-required/`로 나간 `04`/`10`/`19`까지 포함한 옛 총계에서 이어져 온 `rewrite-required/`로 나간 `04`/`10`/`19`까지 포함한 옛 총계에서 이어져 온
것이라 실제와 안 맞았다(두 번째 `/code-review high` 발견). — **[열네 번째 세션] `04`/`19`는 것이라 실제와 안 맞았다(두 번째 `/code-review high` 발견). — **[열네 번째 세션] `04`/`19`는
@ -126,7 +149,6 @@
| `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 | | `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 |
| `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) | | `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) |
| `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 | | `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 셋을 정확히 갈라냄 |
**타입 스파이크 중 판정이 끝나 더 할 일 없는 것**: **타입 스파이크 중 판정이 끝나 더 할 일 없는 것**:

View file

@ -41,6 +41,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
- `.claude/reference/`**[2026-08-07 신설]** base처럼 확정된 건 아니지만 - `.claude/reference/`**[2026-08-07 신설]** base처럼 확정된 건 아니지만
base 문서가 근거로 인용하는 온디맨드 참고 자료(v1 내부 동작 스냅샷, base 문서가 근거로 인용하는 온디맨드 참고 자료(v1 내부 동작 스냅샷,
Fusion/Vide 비교 리서치) — 항상 읽을 필요는 없고 인용될 때만 열어볼 것. Fusion/Vide 비교 리서치) — 항상 읽을 필요는 없고 인용될 때만 열어볼 것.
**[2026-08-21 확장]** 확정된 결정의 **근거 기록**(그 결정이 왜 그렇게
났는지)도 여기 둠 — `research/`를 떠났지만 `archive/` 대상은 아닌 것들.
어떤 문서가 있는지는 `.claude/README.md`가 소스.
- `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. 전부 - `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. 전부
후순위. **어떤 문서가 있는지·우선순위가 뭔지는 여기서 세지도 나열하지도 후순위. **어떤 문서가 있는지·우선순위가 뭔지는 여기서 세지도 나열하지도
않고 `.claude/README.md``research/` 표로 미룸**(개수뿐 아니라 파일명 않고 `.claude/README.md``research/` 표로 미룸**(개수뿐 아니라 파일명

View file

@ -9,7 +9,7 @@
않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록. 않는다(사용자 지시). 아래 A~G절은 거기까지 온 처리 과정의 기록.
**[2026-08-21] 3차 처리 — 아래 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절에서 닫힘). 그 시점에 남았던 건 `F-3`의 (1)~(3)과 "+"였다(요약은 `G-3`, H절에서 닫힘).
**[2026-08-21] 2차 처리 — 아래 F절.** **[2026-08-21] 2차 처리 — 아래 F절.**
@ -1030,7 +1030,7 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" =
맞을지도. attachSlot 의 기능이 너무 다양해진게 문제같음. 이 부분에 있어서는 맞을지도. attachSlot 의 기능이 너무 다양해진게 문제같음. 이 부분에 있어서는
확장 논의를 하게 준비해두자."* 확장 논의를 하게 준비해두자."*
**`research/slot-attach-decomposition.md` 신설.** `setLength`를 어느 줄에 **`reference/slot-attach-decomposition.md` 신설.** `setLength`를 어느 줄에
둘지 고르는 문제가 아니라 분해 문제라는 진단에 동의하고, 논의가 바로 시작될 수 둘지 고르는 문제가 아니라 분해 문제라는 진단에 동의하고, 논의가 바로 시작될 수
있게 재료만 모아뒀다(**아무것도 확정 안 함**, `base/slot-plan.md`가 여전히 정본): 있게 재료만 모아뒀다(**아무것도 확정 안 함**, `base/slot-plan.md`가 여전히 정본):
@ -1141,7 +1141,7 @@ if input[key] ~= nil then continue end -- "이미 있으면 건너뛴다" =
## H-4. `attachSlot` 분해 (확정·반영) ## H-4. `attachSlot` 분해 (확정·반영)
`research/slot-attach-decomposition.md`를 **확정**으로 승격하고 의사코드를 `reference/slot-attach-decomposition.md`를 **확정**으로 승격하고 의사코드를
`base/slot-plan.md`에 반영했다. 결론은 후보 **(B)**: `base/slot-plan.md`에 반영했다. 결론은 후보 **(B)**:
- **`materializeSlotTree(slot, physicalTarget, ownerKey, position)`** — 부기만. - **`materializeSlotTree(slot, physicalTarget, ownerKey, position)`** — 부기만.

View file

@ -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`로 합칠 수 없어 애초에 의존성이 될 방법이 없었다 / 구독을 따로 걸면 합치는 노드 자체가 안 생겨 "감출 비용"이 없다) | | 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`와의 차이 명시 | | 3 | **`indexOfRaw`가 어디에도 정의돼 있지 않음** | 신설 `rawReplace`가 쓰는데 문서에 없었다. 구현자가 공개 `IndexOf`를 그대로 쓰면 **래핑된 자리에서 어긋난다**(그건 언래핑 기준 비교) | `indexOfRaw` 한 줄 정의 + `IndexOf`와의 차이 명시 |
| 4 | **index/element 혼용 캐비엇 목록이 안 늘어남** | 기존 캐비엇이 `rawRemove`/`rawUnmount`만 나열하는데, 이번에 같은 불일치를 물려받은 `rawDetach`/`releaseElement`/`rawReplace`가 빠져 있었다 | 캐비엇에 셋 추가 | | 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`를 넘긴다를 산문에 명시 | | 6 | **`Replace``destroyOld`가 base 본문에 없었다** | `rawReplace`에 인자가 있는데 CRUD 표/산문이 그 존재를 설명 안 함. followup은 처리 기록이지 소스가 아니다(이번 라운드 `CR-1`의 교훈 그대로) | 공개 `Replace`는 항상 파괴 / `:List``_owned`를 넘긴다를 산문에 명시 |
부수로 인덱스도 같이 정리했다 — **`Gate` 이름이 `question.md` 1번(용어 부수로 인덱스도 같이 정리했다 — **`Gate` 이름이 `question.md` 1번(용어

View file

@ -45,7 +45,7 @@
| `QT` | quad-types / 버전 체크 (4라운드 문항 없음) | `base/quad-types-plan.md` | | `QT` | quad-types / 버전 체크 (4라운드 문항 없음) | `base/quad-types-plan.md` |
| `IM` | **실제 커밋된 M1 코드** (문서 아님) | `quad-base/src`, `quad-types/src`, `type-version-check/src` | | `IM` | **실제 커밋된 M1 코드** (문서 아님) | `quad-base/src`, `quad-types/src`, `type-version-check/src` |
| `DE` | `Detach`/`_detached`/`KeyGone`/`Owned` (신규 확정) | `base/slot-plan.md` | | `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` | | `DC` | 디스패치 코어 심화 | `base/dispatch-core-plan.md` |
| `SS` | Source/State 심화 | `base/source-state-plan.md` | | `SS` | Source/State 심화 | `base/source-state-plan.md` |
| `LC` | 생명주기 배관 심화 | `base/lifecycle-pattern.md`, `base/relate-plan.md` | | `LC` | 생명주기 배관 심화 | `base/lifecycle-pattern.md`, `base/relate-plan.md` |

View file

@ -40,40 +40,6 @@
`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절,
`archive/canexecute-inst-arg-reversed.md` 하단 addendum 참고.) `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"(콘텐츠 주입 지점)과 이름은 같지만 의미가 - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가
다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이
헷갈릴 수 있음. 헷갈릴 수 있음.
@ -109,13 +75,18 @@
안 바꾸고 대기열에만 올림(의사코드는 새로 쓰는 자리부터 `nextValue` 안 바꾸고 대기열에만 올림(의사코드는 새로 쓰는 자리부터 `nextValue`
쓰기 시작했음). 쓰기 시작했음).
- **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임 - **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임
nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState` nominal 타입 판별 통합 메커니즘 — `base/brand-plan.md`에서 동작/구현
branded 타입 전부로 일반화) — `brand-plan.md``Brand` 방식은 확정, 이름만 열린 질문(사용자가 직접 제기). `Tag`는 이미
절에서 동작/구현 방식은 확정, "OOP 인스턴스의 클래스명을 얻는 느낌"을 quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로
전달할 더 나은 이름이 있는지가 열린 질문(사용자가 직접 제기) — `Tag`
이미 quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로
"type namespace"류를 사용자가 검토했으나 미확정. **(2026-08-08 재확인)** "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 세 번째 - **`Tag`/`Added`/`Removed`/`Merged`(3순위, 사소함, 2026-08-08 세 번째
세션 array-part 값 객체 재설계 때 확정된 API 표면)**: `base/tag-plan.md` 세션 array-part 값 객체 재설계 때 확정된 API 표면)**: `base/tag-plan.md`
"열린 질문 없음, 값 모양/메커니즘/retract/패키지 배치 전부 확정, 이름 "열린 질문 없음, 값 모양/메커니즘/retract/패키지 배치 전부 확정, 이름
@ -142,164 +113,6 @@
## 3. 낮은 우선순위 — 열려 있지만 급하지 않음 ## 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 신설, - **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설,
같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등 같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등
`:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level `:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level
@ -334,36 +147,6 @@
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자 `getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전 탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전
필요**, `base/store-plan.md`의 "타입 추론 문제" 절. 필요**, `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 → - **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가 없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
@ -376,10 +159,6 @@
문서화만** 했는데(`base/attribute-plan.md` "메커니즘" 절), 원자적 문서화만** 했는데(`base/attribute-plan.md` "메커니즘" 절), 원자적
롤백(그룹 `process`에만 국소적인 unwind)을 넣을지는 열어둠. 지금 결정 롤백(그룹 `process`에만 국소적인 unwind)을 넣을지는 열어둠. 지금 결정
불필요 — M10 구현 시점에 판단. 불필요 — 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` 참고. - **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고.
채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를 채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를
넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은 넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은

View file

@ -1,14 +1,30 @@
# `Epoch` 인터페이스 + `EpochMap` 컴포지션, 그리고 `Brand` 인스턴스화 (2026-08-21 신설) # `Epoch` 인터페이스 + `EpochMap` 컴포지션, 그리고 `Brand` 인스턴스화 — 결정 근거 기록 (2026-08-21)
**상태**: research — **사용자 제안, 두 차례 회신으로 §4가 사실상 전부 닫혔다** **상태**: **[2026-08-21] 전량 확정·승격 완료.** 이 문서는 이제 "왜 그렇게
(남은 미정은 리비전 증가를 `bit32` 랩으로 할지 평이한 `+1`로 할지 하나뿐이고, 정했나"의 근거 기록이고, **지금 유효한 설계는 `base/`가 소스**다 —
그건 어느 쪽이든 실질 위험이 없다). **다만 아직 `base/`로 승격하지 않았다** `base/state-epoch-plan.md`(`Epoch`/`EpochMap`/State의 두 맵),
승격하면 `state-epoch-plan.md`/`source-state-plan.md`/`effect-plan.md`/ `base/brand-plan.md`(인스턴스 브랜드), `base/source-state-plan.md`(`Source`가
`brand-plan.md` 넷을 같이 고쳐야 하므로 사용자 확인 후에 한다. `Epoch`를 구조적으로 만족 + `Observer` 클로저 시그니처),
`base/state-epoch-plan.md`/`base/gate-plan.md`/`base/brand-plan.md`가 여전히 `base/effect-plan.md`(`Effect`가 자기 `EpochMap`을 듦).
정본이다. 발단은 "다중 의존성 `Effect`에서 한 파동에 `fn`이 두 번 도는" 갭 **[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 대화에서 에이전트가 제기). (`base/effect-plan.md`의 다중 deps 절, 2026-08-21 대화에서 에이전트가 제기).
아래는 그 결론에 이른 제안·평가·회신을 그대로 보존한 것 — 원래 서술은
"승격 대기 중인 제안"이었다.
## 1. 제안 (사용자 원문 요지) ## 1. 제안 (사용자 원문 요지)
1. **에포크 부기를 State에서 떼어내 컴포지션 가능한 객체로.** 가칭 1. **에포크 부기를 State에서 떼어내 컴포지션 가능한 객체로.** 가칭
@ -36,7 +52,8 @@
- **`EpochMap` 분리는 이미 코퍼스가 발견한 구분을 형식화한다.** 같은 날 - **`EpochMap` 분리는 이미 코퍼스가 발견한 구분을 형식화한다.** 같은 날
`state-epoch-plan.md`가 맵을 둘로 가른 이유가 정확히 "값 유효성"과 "전파 `state-epoch-plan.md`가 맵을 둘로 가른 이유가 정확히 "값 유효성"과 "전파
dedup"이 **비대칭으로 움직인다**는 것이었다(§2의 "왜 테이블이 둘인가"). dedup"이 **비대칭으로 움직인다**는 것이었다(승격 후 정본은
`base/state-epoch-plan.md` §4의 "왜 둘인가 — 순회 때문이다" 문단).
후자만 떼어내 재사용 가능한 객체로 만들면, **노드가 아닌 소비자(leaf)도 후자만 떼어내 재사용 가능한 객체로 만들면, **노드가 아닌 소비자(leaf)도
같은 판정을 쓸 수 있다** — 지금 State에만 있어서 못 쓰던 것. 같은 판정을 쓸 수 있다** — 지금 State에만 있어서 못 쓰던 것.
- **다중 dep `Effect` 갭이 이걸로 정확히 닫힌다.** `A → b`, `A → c`, - **다중 dep `Effect` 갭이 이걸로 정확히 닫힌다.** `A → b`, `A → c`,
@ -46,8 +63,8 @@
노드로 수렴시키기"보다 낫다 — 노드를 더 안 만들고, `effect-plan.md` 노드로 수렴시키기"보다 낫다 — 노드를 더 안 만들고, `effect-plan.md`
확정한 "의존성 N개면 내부 Observer도 N개" 구조를 안 건드린다. 확정한 "의존성 N개면 내부 Observer도 N개" 구조를 안 건드린다.
- **게이트를 페이로드에서 빼는 것도 맞다.** 하류는 게이트 identity를 **한 - **게이트를 페이로드에서 빼는 것도 맞다.** 하류는 게이트 identity를 **한
번도 안 쓴다**(에포크 경계로 만드는 안은 `state-epoch-plan.md` §5-3에서 번도 안 쓴다**(에포크 경계로 만드는 안은 `base/state-epoch-plan.md` §8의
이미 기각됨). 배치가 "그 전파에만 쓰는 일회성 스냅샷"이라는 성질도 "기각된 대안 — 게이트를 에포크 경계로" 항목에서 기각됨). 배치가 "그 전파에만 쓰는 일회성 스냅샷"이라는 성질도
그대로 유지된다. 그대로 유지된다.
- **`Epoch`로의 일반화가 실제로 계약을 정확하게 만든다.** 맵이 `Source`에서 - **`Epoch`로의 일반화가 실제로 계약을 정확하게 만든다.** 맵이 `Source`에서
요구하는 건 **identity + 단조 증가 카운터** 둘뿐이다. 그걸 이름 붙이면 요구하는 건 **identity + 단조 증가 카운터** 둘뿐이다. 그걸 이름 붙이면
@ -112,14 +129,19 @@
- **숫자**: 할당 0, 비교도 더 쌈. 위험은 도달 불가능한 `2^53`뿐. - **숫자**: 할당 0, 비교도 더 쌈. 위험은 도달 불가능한 `2^53`뿐.
- **에이전트 권고도 숫자(`Revision`)** — 트윈처럼 매 프레임 `Set`하는 - **에이전트 권고도 숫자(`Revision`)** — 트윈처럼 매 프레임 `Set`하는
소스가 여럿이면 테이블안은 GC 압력을 만든다. 소스가 여럿이면 테이블안은 GC 압력을 만든다.
- **증가 방식 — `bit32` 랩 vs 평이한 `+1`, 아직 안 정함.** 사용자는 - **[해소, 2026-08-21] `bit32.bnot(-rev)`로 확정.** 사용자가 인용한
`bit32`가 uint32 안에서 **랩어라운드**한다는 걸 근거로 그 구조를 `bit32.bnot(-1) == 0`, `bit32.bnot(-0) == 4294967295`,
염두에 뒀다(`bit32.bnot(-1) == 0`, `bit32.bnot(0) == 4294967295`). `bit32.bnot(-4294967295) == 4294967294`가 **예시가 아니라 연산 자체**
맞다 — 랩이면 double 포화(`n + 1 == n`)가 아예 안 생긴다. **다만 충돌 였다 — `bit32.bnot(-a)``a > 0`이면 `a - 1`, `0`이면 `4294967295`
거리는 오히려 짧아진다**: 평이한 `+1``2^53`에서 포화하고, `bit32` **랩어라운드 감소**이고, 갱신과 랩이 **FASTCALL 하나**로 끝난다.
랩은 `2^32`마다 한 바퀴라 "정확히 `2^32`만큼 벌어진 두 리비전"이 같아 랩이라 double 포화(`n + 1 == n`)가 안 생긴다. 충돌 거리 자체는
보인다. 둘 다 도달 불가능이라 **어느 쪽이든 실질 위험은 없고**, 안전 오히려 짧아지지만(`2^53` vs `2^32`) **둘 다 도달 불가능이라 정확성은
마진만 보면 평이한 `+1`이 넓다. 동률**이고, 사용자가 hot path 비용을 근거로 골랐다: *"매번 도는
코드인지라, 값 싸게 native call + num 연산으로 가볍게 가고 싶어요."*
- **⚠️ 에이전트가 한 번 잘못 옮겼다** — 형태를 `band(rev + 1, mask)`
적고 "덧셈 위에 fastcall이 하나 더 얹힌다"고 단서까지 달았는데,
사용자 정정(*"제가 말한건, bit32.bnot(-a) 입니다"*)으로 둘 다 틀린
게 확인됐다. 실측·정본은 `base/state-epoch-plan.md` §2.
5. **[해소] State는 `EpochMap`을 둘 컴포지션하고, `:Sync`는 필수 연산이 5. **[해소] State는 `EpochMap`을 둘 컴포지션하고, `:Sync`는 필수 연산이
아니다 — 에이전트 착오 정정.** 에이전트가 "재계산 후 전부 최신으로 아니다 — 에이전트 착오 정정.** 에이전트가 "재계산 후 전부 최신으로
맞추려면 `Update` 외의 연산이 필요하다"고 적었으나, **`Update` 맞추려면 `Update` 외의 연산이 필요하다"고 적었으나, **`Update`
@ -129,7 +151,8 @@
본 탓이다. 본 탓이다.
- **초기화도 같은 연산 하나로 끝난다**`:With`/`:Compute`의 deps를 - **초기화도 같은 연산 하나로 끝난다**`:With`/`:Compute`의 deps를
전부 `Update`하고 **반환값은 안 보고** `rawInvalid = true`로 둔다. 전부 `Update`하고 **반환값은 안 보고** `rawInvalid = true`로 둔다.
이미 확정된 노드 생성 규칙(`base/state-epoch-plan.md` §2)과 정확히 이미 확정된 노드 생성 규칙(`base/state-epoch-plan.md` §4의 "노드가
생길 때의 초기값" 절)과 정확히
같은 동작이다. 같은 동작이다.
- **내부 최적화(사용자 제안)**: `Update`가 목록을 돌 때 diff 때문에 - **내부 최적화(사용자 제안)**: `Update`가 목록을 돌 때 diff 때문에
읽기가 들어가는데, **한 번 다름을 찾으면 반환값이 이미 `true` 읽기가 들어가는데, **한 번 다름을 찾으면 반환값이 이미 `true`
@ -152,10 +175,13 @@
`Debug`뿐, 2026-08-21 확인) — 전환 비용은 **문서뿐**이다. `Brand`라는 이름 `Debug`뿐, 2026-08-21 확인) — 전환 비용은 **문서뿐**이다. `Brand`라는 이름
자체는 여전히 용어 정리 대기(`question.md` 1번). 자체는 여전히 용어 정리 대기(`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/gate-plan.md` — 배치 페이로드(4번), 빈 배치 무통지(8번).
- `base/brand-plan.md` — 현행 단일 레지스트리 설계와 서브타입 OR 합성.
- `base/effect-plan.md` — 다중 deps 절(이 제안이 닫으려는 갭).
- `base/source-state-plan.md``state:Observer(fn)` 계약.

View file

@ -4,6 +4,8 @@
반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한 반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한
설계는 `base/slot-plan.md`의 "재귀 메커니즘" 절**(`materializeSlotTree` / 설계는 `base/slot-plan.md`의 "재귀 메커니즘" 절**(`materializeSlotTree` /
`mountSlotTree` / 얇은 `attachSlot`)이 소스다. `mountSlotTree` / 얇은 `attachSlot`)이 소스다.
**[2026-08-21 `research/``reference/` 이동]** 확정된 뒤엔 "상의가 더 필요한
설계"가 아니라 "다른 문서가 근거로 인용하는 온디맨드 자료"이므로.
**사용자 확정 근거**(2026-08-21): *"함수 분해는 확정해도 좋을것 같음. 이게 **사용자 확정 근거**(2026-08-21): *"함수 분해는 확정해도 좋을것 같음. 이게
하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던 부분이고, 지금 하나의 큰 복잡한 복합 함수라 여러 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` **왜 생겼나**: 2026-08-21 구현 전 QA 4라운드 `F-4-3`에서 `Dispatch.setLength`
flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가 flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가
아니라고 짚었다 — *"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 아니라고 짚었다 — *"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고

View file

@ -1725,4 +1725,67 @@ State의 재계산/전파 판정은 **소스 에포크 비교 채택**으로 닫
한 파동에 `fn`이 두 번 도는 갭**(공통 하류가 없어 에포크 dedup이 못 접는다)이고, 한 파동에 `fn`이 두 번 도는 갭**(공통 하류가 없어 에포크 dedup이 못 접는다)이고,
`Effect`가 자기 `EpochMap`을 들면 그게 공통 하류가 되어 닫힌다. **사실상 전량 `Effect`가 자기 `EpochMap`을 들면 그게 공통 하류가 되어 닫힌다. **사실상 전량
확정됐으나 세션 길이 때문에 `base/` 승격은 다음 세션으로 미뤄졌다** — 확정됐으나 세션 길이 때문에 `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 착수 전 필요" 목록도 절반 넘게 해소 항목이라 실제로 열린 둘만 남겼다.
이관한 히스토리의 원문은 소급해 고치지 않고 **머리에 "그 뒤 이름이 바뀌었다"
경고만** 달았다.

View file

@ -7,7 +7,7 @@
**소스 관계**: 지금 유효한 설계는 항상 `base/slot-plan.md`가 소스이고, **소스 관계**: 지금 유효한 설계는 항상 `base/slot-plan.md`가 소스이고,
처리 경과 요약은 `qa-request/pre-implementation-qa-round4-followup.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`이 이미 최종값이라는 `ChildAdded` 핸들러가 볼 때 서브트리의 `Length`/`Offset`이 이미 최종값이라는
점뿐이고, 옛 코드는 거기서 **미완성 스냅샷**을 보여줬다. 즉 이 변화는 점뿐이고, 옛 코드는 거기서 **미완성 스냅샷**을 보여줬다. 즉 이 변화는
동치성 손실이 아니라 **엄밀히 더 정확해지는 방향**이다. 분석 원문은 동치성 손실이 아니라 **엄밀히 더 정확해지는 방향**이다. 분석 원문은
`research/slot-attach-decomposition.md` 7절. `reference/slot-attach-decomposition.md` 7절.
## 6. 이 세션이 안 한 것 ## 6. 이 세션이 안 한 것

View file

@ -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`에 안 올린다** — 같은 자리에서 확정됐으므로 열린
항목이 아니다.

View file

@ -5,38 +5,28 @@
(`.claude/question.md`, `luau-test/STATUS.md` 등). (`.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/` 00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~5라운드 전부 `base/`
반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서 반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서
**M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate` **M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`
`state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의 `state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의
재계산/전파 판정은 **소스 에포크 비교** 채택으로(`base/state-epoch-plan.md`, 재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md`,
구현은 M3) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다. 구현은 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`가 "게이트가 유보했다 **[2026-08-21 경위]** 같은 날 `/code-review high`가 "게이트가 유보했다
내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로 내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로
되돌아갔으나, **사용자가 그 자리에서 `emit(self)` + 흡수 집합으로 되돌아갔으나, **사용자가 그 자리에서 `emit(self)` + 흡수 집합으로
확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번). 같이 확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번). 같이
제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 count 전부 갱신 / 제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 리비전 전부 갱신 /
새 노드는 `sourceEmitMap`은 비우고 `sourceCountMap`은 실제 count로 채운 뒤 새 노드는 `emitEpochMap`은 비우고 `valueEpochMap`은 실제 리비전으로 채운 뒤
`rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) — `rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) —
`state-epoch-plan.md` §5 7번. **[같은 날 두 번째 `/code-review high`]** `base/state-epoch-plan.md` §4. **[같은 날 두 번째 `/code-review high`]**
7건이 더 나왔고 전부 유효했는데(재진입 시 빈 배치가 새어 변경이 증발하던 7건이 더 나왔고 전부 유효했는데(재진입 시 빈 배치가 새어 변경이 증발하던
것, `OffWithoutEmit`이 흡수 집합을 안 비우던 것 등), 그중 사용자 판단으로 것, `OffWithoutEmit`이 흡수 집합을 안 비우던 것 등), 그중 사용자 판단으로
올라갔던 둘도 같은 날 닫혔다 — "재진입 계약"은 애초에 **잘못 옮긴 올라갔던 둘도 같은 날 닫혔다 — "재진입 계약"은 애초에 **잘못 옮긴
@ -54,7 +44,7 @@
**`Detach` 보존 주체(`userdata` → `slot._detached`)**, **`KeyGone` 센티널**, **`Detach` 보존 주체(`userdata` → `slot._detached`)**, **`KeyGone` 센티널**,
**`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해** **`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해**
(`materializeSlotTree` + `mountSlotTree`, 근거는 (`materializeSlotTree` + `mountSlotTree`, 근거는
`research/slot-attach-decomposition.md`)가 전부 확정·반영됐다. `reference/slot-attach-decomposition.md`)가 전부 확정·반영됐다.
**[2026-08-21 정정] 5라운드 문항지를 만들었다** — 4라운드 처리 때는 사용자 **[2026-08-21 정정] 5라운드 문항지를 만들었다** — 4라운드 처리 때는 사용자
지시("이후 stale 만 잡는것으로 끝낼 수 있어보임")로 안 만들기로 했으나, 같은 지시("이후 stale 만 잡는것으로 끝낼 수 있어보임")로 안 만들기로 했으나, 같은
날 사용자가 5라운드를 요청("4차에서 예로 넘어갔던건 스킵하고, 새로운 날 사용자가 5라운드를 요청("4차에서 예로 넘어갔던건 스킵하고, 새로운
@ -121,45 +111,19 @@
재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md 재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md
마일스톤 정합성" 절 참고). 마일스톤 정합성" 절 참고).
**아래는 M3 착수 전에 결론이 필요한 항목 목록**(M0/M2는 여전히 막혀 **아래는 M3 착수 전에 결론이 필요한 항목 목록** — M0/M2는 막혀 있지
있지 않음, 0번 항목 참고 — **단, M2가 M3의 `Blocker.luau`를 선당겨야 않다(0번 항목 참고). 둘 다 `question.md` 3번에도 있고 각 `base/` 문서에도
하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md` ⚠️로 표시돼 있다. **[2026-08-21 정리]** 여기 쌓여 있던 `[해소]` 항목들
3번에도 올라가 (`Blocker.luau` 마일스톤 순서, 그룹 `Attribute` 위치 claim 키,
있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한 `SetAndDispose`, `PopOnly`→`Detach`, `KeyGone` 처분, `Store` 미선언 키
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인은 판단이 아니라 구현 시 타입 에러, dedup 경로 대칭)은 **전부 `archive/question-resolved.md`
검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/` `base/` 문서로 옮겼다** — 목록이 절반 넘게 해소 항목으로 차 있어
문서에도 ⚠️로 표시돼 있다: "지금 할 일"로 읽히지 않던 것을 걷어낸 것.
- **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md`
M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau`
(또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수
전 필요**. `qa-request/pre-implementation-qa-round3.md`
"ROADMAP.md 마일스톤 정합성" 절.
- **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong / - **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong /
하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** 하류 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`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. 됨(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 열한 번째 세션 기준).** 0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).**
`question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy `question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy

View file

@ -42,13 +42,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
것은 "이미 invalid면 전파 중단되는지"가 **아니라** 그 반대: 것은 "이미 invalid면 전파 중단되는지"가 **아니라** 그 반대:
**emit은 자기 invalid 상태와 무관하게 전파되고**, 중복 재계산은 **emit은 자기 invalid 상태와 무관하게 전파되고**, 중복 재계산은
`:Get()` 시점 캐시로만 막히는지(**[2026-08-21]** 그 뒤 "항상"에서 `:Get()` 시점 캐시로만 막히는지(**[2026-08-21]** 그 뒤 "항상"에서
"같은 에포크의 두 번째만 접힘"으로 좁혀졌다 — 아래 참고). 특히 `:Get()`을 안 부르는 "같은 `Epoch`의 같은 리비전이 두 번째로 도착했을 때만 접힘"으로 좁혀졌다 — 아래 참고). 특히 `:Get()`을 안 부르는
`Observer`가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터 `Observer`가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터
침묵했음(`archive/invalidate-dedup-propagation-reversed.md`). 침묵했음(`archive/invalidate-dedup-propagation-reversed.md`).
스파이크 `05-store-state-diamond-propagation.luau`**[2026-08-19 스파이크 `05-store-state-diamond-propagation.luau`**[2026-08-19
재작성 완료]** 그 모델("emit은 항상 전파 + `:Get()` 시점 캐시로만 재작성 완료]** 그 모델("emit은 항상 전파 + `:Get()` 시점 캐시로만
dedup")로 재검증 통과 — **[2026-08-21] 그 모델이 다시 바뀌어 dedup")로 재검증 통과 — **[2026-08-21] 그 모델이 다시 바뀌어
`rewrite-required/`로 되돌아갔다**(소스 에포크 채택으로 다이아몬드 `rewrite-required/`로 되돌아갔다**(`Epoch` 리비전 비교 채택으로 다이아몬드
Observer가 이제 변경당 **1회**만 울어야 함, `base/state-epoch-plan.md`)) Observer가 이제 변경당 **1회**만 울어야 함, `base/state-epoch-plan.md`))
- [x] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self: - [x] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self:
Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이 Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이
@ -152,8 +152,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`process(inst,k,v,index) -> (hintValue)->()` **3종**`isHandlable` `process(inst,k,v,index) -> (hintValue)->()` **3종**`isHandlable`
`inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는 `inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는
`process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션)) `process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션))
- [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/ - [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`
`Brand.get(x)``isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ 브랜드마다 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`/ `isAttributeKey`/`isAttribute`/`isTween`/`isBlocker`/`isSource`/
`isStore`/`isSlot`/`isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 `isStore`/`isSlot`/`isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째
세션 추가 — 원래 태그 목록에서 빠져있었음. **[정정, 2026-08-09 세션 추가 — 원래 태그 목록에서 빠져있었음. **[정정, 2026-08-09
@ -256,6 +262,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
형태까지는 M2에 필요하다. 경위는 형태까지는 M2에 필요하다. 경위는
`qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤 `qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤
정합성" 절과 `pre-implementation-qa-round5-followup.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 클로저를 **반환하지 않는** - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
@ -349,14 +358,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
**공용 게이트 노드는 M2에서 이미 만들어져 있다**("게이팅 먼저" 결정, **공용 게이트 노드는 M2에서 이미 만들어져 있다**("게이팅 먼저" 결정,
위 M2 각주 + `base/gate-plan.md`). M3에서 `Blocker`를 짤 때는 위 M2 각주 + `base/gate-plan.md`). M3에서 `Blocker`를 짤 때는
그 노드를 **다시 만들지 말고 그 위의 정책으로** 얹을 것. 그 노드를 **다시 만들지 말고 그 위의 정책으로** 얹을 것.
- [ ] **[2026-08-21 5라운드 — 채택 확정]** State의 재계산/전파 판정은 - [ ] **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의
**소스 에포크 비교**다(`base/state-epoch-plan.md`) — `invalid` 플래그가 재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`)
아니다. 아래 `Source.luau`/`State.luau`가 이걸 전제로 짜여야 한다: `invalid` 플래그가 아니다. 아래 `Source.luau`/`State.luau`가 이걸
노드가 `sourceCountMap`(값 유효성)/`sourceEmitMap`(전파 dedup) 두 전제로 짜여야 한다: `Source``type Epoch = { Revision: number }`
테이블을 들고, emit은 발행 source만 싣고, 순회는 `rawInvalid == false` 구조적으로 만족하고(`EpochBrand`에도 등록), 부기는 재사용 가능한
일 때만 돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`, `:Refresh`, `:Sync`,
중복 통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다 `:TrackFrom``EpochSet = {[Epoch]: true}`, **배열 아님**)
으로 떼어내며, State가 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값
유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고
**출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만
돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복
통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다
(`luau-test/STATUS.md`). (`luau-test/STATUS.md`).
- [ ] `EpochMap.luau` — 위 부기 객체. `Effect`(M3)와 `GateNode`(M2)도 같은
것을 쓰므로 `State.luau`에 묻지 말고 별도 모듈로 낼 것.
**`GateNode`가 M2로 앞당겨졌으므로 이 모듈도 실제로는 M2에 필요하다**
(위 M2 게이팅 각주).
- [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 - [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를
한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, 한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive,
State와 밀접히 연관돼 있어 같은 마일스톤에서 개발) State와 밀접히 연관돼 있어 같은 마일스톤에서 개발)
@ -370,10 +388,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v 경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v
is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14 is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록 열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
- [ ] `Effect(fn, state?)`(`base/effect-plan.md`) — `state` 생략 시 설치 - [ ] `Effect(fn, ...deps)`(`base/effect-plan.md`, **[2026-08-21 5라운드
1회+leaf 사망 시 확정 정리, `state` 지정 시 내부적으로 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치
`state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React 1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독**
`useEffect` 동형). Observer 구현 이후에 착수(의존 관계). (State/Source는 `Observer`, `Ref``:Callback`)을 걸어
재실행+cleanup 체이닝(React `useEffect` 동형). **`EffectHandle`
`EpochMap`을 하나 들어** 공통 상류로 인한 중복 발화를 접고, 설치 구간
억제 플래그가 그 `Update`보다 먼저 와야 함
(`base/state-epoch-plan.md`). Observer 구현 이후에 착수(의존 관계).
`EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는
모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션). 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션).
@ -663,7 +685,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수 `recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수
경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서" 경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서"
나던 버그 클래스가 구조적으로 사라짐. 근거 기록은 나던 버그 클래스가 구조적으로 사라짐. 근거 기록은
`research/slot-attach-decomposition.md`. `reference/slot-attach-decomposition.md`.
**관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가 **관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가
"부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의 "부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의
`Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음). `Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음).
@ -736,9 +758,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키
`Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인)
- [ ] `:Peek<<T>>(key): T|State<T>|nil` 필드 읽기 접근자 + - [ ] `:Peek<<T>>(key): T|State<T>|nil` 필드 읽기 접근자 +
`isState(x)`/`isSource(x): boolean`(`Brand` 공유 레지스트리 기반 — `isState(x)`/`isSource(x): boolean`(**[2026-08-21 갱신]** 인스턴스
`modifier-plan.md` 9번, `brand-plan.md``Brand` 절, M2의 브랜드 멤버십 기반 — `isSource(x)``SourceBrand:is(x)`, `isState`
`Brand.luau`에 이미 구현돼 있어야 함) 그 위에 `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` 센티널 - [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None` 센티널
(이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + (이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) +
이를 `nil`로 재디스패치하는 base 내장 `NoneHandler` 이를 `nil`로 재디스패치하는 base 내장 `NoneHandler`
@ -970,7 +996,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`. 재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`.
- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/ - [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/
`TweenTag` Brand, `Value: T` plain만 받고 State 재귀 없음) `TweenBrand`, `Value: T` plain만 받고 State 재귀 없음)
- [ ] `Handlers/Property.luau``isTween(realv)` 분기 추가(기존 - [ ] `Handlers/Property.luau``isTween(realv)` 분기 추가(기존
`Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯 `Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯
(`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈 (`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈