design: State 에포크 안 3차 정정 — rawInvalid 기제 반전, seen/computedAt 분리 철회

사용자가 research/state-epoch-validation.md를 직접 읽고 기제 서술 세 건을
정정. 채택 여부 자체는 여전히 미정(M3 전 결론 필요).

- sourceList 순회 조건이 반대였다: rawInvalid == false일 때만 돈다.
  true면 재계산이 이미 확정이라 훑을 이유가 없고, 순회의 목적은 오직
  "못 받은 emit(게이트에 막혔던 것)을 여기서 먼저 받는 것".
- emit은 (source, count)가 아니라 발행 source만 싣는다 — 받는 쪽이
  source의 count 필드를 그냥 읽으면 된다.
- 에이전트가 요구했던 seen/computedAt 두 카운트 분리는 철회. count 갱신과
  rawInvalid = true가 같은 스텝이라 캐시 오인 경로가 없다.
- emit 수신 규약 확정형: 같으면 삼킴 / 다르면 count 먼저 갱신 →
  rawInvalid = true → 그 다음 뒤로 emit. 다른 소스 항목은 안 건드림.
- 부수로 열린 것: 순회가 발견한 변경을 뒤로 emit 할 것인가. 다이아몬드
  쪽은 사용자가 스스로 안전으로 정정(D도 count를 갱신해 중복을 삼킴),
  게이트 쪽만 "해제 emit이 source = nil을 싣고 받는 쪽이 전체 확인"
  규약으로 남음 — 게이트는 보통 최종단이라 채택을 막지 않는다는 판단.
- 비용 서술도 뒤집었고(훑는 쪽이 흔한 경로), question.md에 남아 있던
  이미 뒤집힌 옛 결론("중복 통지는 안 고쳐짐 / UB 명문화 필요")도 정정.

처리 기록은 round5-followup.md M절. 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-21 20:15:28 +09:00
parent a4b517aee2
commit 4c395cd383
Signed by: qwreey
GPG key ID: D28DB79297A214BD
6 changed files with 135 additions and 32 deletions

View file

@ -93,7 +93,7 @@
| `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] 결론 확정·반영 완료**, 이제 근거 기록용 | | `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가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 |
| `gate-primitive.md` | **[2026-08-21 신설]** `Blocker`가 쓰는 게이티드 State 노드를 공용 `Gate`로 일반화 — 상류 emit을 가로채 내려보낼지 정책이 정하는 노드. `Blocker`/`Debounce`/`Throttle`이 그 위의 정책이 됨. **방향은 사용자 확정(게이팅을 M2로 앞당김), 이름(`Gate` vs `Gater`)·표면·`Blocker`와의 결합 방식이 미정** | **상 — M2 착수 전 필요**(`Dispatch.drive`의 배치 등록이 이 게이팅을 전제) | | `gate-primitive.md` | **[2026-08-21 신설]** `Blocker`가 쓰는 게이티드 State 노드를 공용 `Gate`로 일반화 — 상류 emit을 가로채 내려보낼지 정책이 정하는 노드. `Blocker`/`Debounce`/`Throttle`이 그 위의 정책이 됨. **방향은 사용자 확정(게이팅을 M2로 앞당김), 이름(`Gate` vs `Gater`)·표면·`Blocker`와의 결합 방식이 미정** | **상 — M2 착수 전 필요**(`Dispatch.drive`의 배치 등록이 이 게이팅을 전제) |
| `state-epoch-validation.md` | **[2026-08-21 신설]** State 재계산 판정을 `invalid` 플래그에서 **루트 Source 에포크 비교**로 바꾸는 안(사용자 제안) — DFS 전파 도중 `Get()`이 섞인 값을 캐시하는 glitch를 없앰. 성능이 아니라 **정확성** 결정이고 State 내부 표현을 바꾸므로 M3 전에 결론 필요. 에이전트 분석: 진단·방향 모두 타당. **[같은 날 후속 확정] 중복 *통지*도 같은 장치로 접고**(emit이 `(source, count)`를 실어오므로 판정 O(1)), "선언 안 된 의존성 UB 명문화"는 사용자 기각으로 빠짐 — 대신 노드가 `seen`(전파 dedup)/`computedAt`(캐시 검증) 두 카운트를 따로 들어야 한다는 구현 요구가 생김 | **상 — M3(State 구현) 착수 전 필요** | | `state-epoch-validation.md` | **[2026-08-21 신설]** State 재계산 판정을 `invalid` 플래그에서 **루트 Source 에포크 비교**로 바꾸는 안(사용자 제안) — DFS 전파 도중 `Get()`이 섞인 값을 캐시하는 glitch를 없앰. 성능이 아니라 **정확성** 결정이고 State 내부 표현을 바꾸므로 M3 전에 결론 필요. 에이전트 분석: 진단·방향 모두 타당. **[같은 날 후속 확정] 중복 *통지*도 같은 장치로 접고**(emit이 발행 source를 실어오므로 판정 O(1)), "선언 안 된 의존성 UB 명문화"는 사용자 기각으로 빠짐. **[같은 날 3차 정정]** `rawInvalid` 기제가 뒤집혔다 — `sourceList` 순회는 `rawInvalid`**false**일 때만 돌고(목적은 "못 받은 emit 받기"), emit은 count 없이 source만 싣는다. 그래서 `seen`/`computedAt` 두 카운트 분리 요구는 **철회**되고 count 하나면 충분. 남은 세부는 게이트 해제 emit의 `source = nil` 규약 | **상 — M3(State 구현) 착수 전 필요** |
| `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 코어 구현 시점까지 미결 |
| `doc-include-plan.md` | **[2026-08-14 신설]** 문서 stale 감소용 include 도구 `doc-include.py`(가칭) — 원본 파일에 `<!--#summary-->` 류 마커로 요약 구간을 표시해두면 인용하는 문서가 그 구간을 기계적으로 추출해 붙여넣게 하는 도구. `doc-check.py`(사후 탐지)와 짝을 이루는 사전 차단 장치. AsciiDoc tagged include/markdown-magic이 선례, build vs buy 검토 후 Python 표준 라이브러리로 직접 제작(~100줄) 채택. 파일럿은 `.claude/session-summary.md``.claude/session/*.md` 요약 마커부터(CLAUDE.md 분할로 목적지가 "통째로 생성되는 파일"이 돼 단방향 생성으로 단순화됨) | 하 — M0/설계 게이트와 무관한 메타 도구. **[2026-08-16 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) | | `doc-include-plan.md` | **[2026-08-14 신설]** 문서 stale 감소용 include 도구 `doc-include.py`(가칭) — 원본 파일에 `<!--#summary-->` 류 마커로 요약 구간을 표시해두면 인용하는 문서가 그 구간을 기계적으로 추출해 붙여넣게 하는 도구. `doc-check.py`(사후 탐지)와 짝을 이루는 사전 차단 장치. AsciiDoc tagged include/markdown-magic이 선례, build vs buy 검토 후 Python 표준 라이브러리로 직접 제작(~100줄) 채택. 파일럿은 `.claude/session-summary.md``.claude/session/*.md` 요약 마커부터(CLAUDE.md 분할로 목적지가 "통째로 생성되는 파일"이 돼 단방향 생성으로 단순화됨) | 하 — M0/설계 게이트와 무관한 메타 도구. **[2026-08-16 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) |

View file

@ -876,3 +876,32 @@ nativeDispose(element) -- 트리 밖
`rawDetach`/`mountSlotTree`/`unmountSlotTree` 의사코드), `dispatch-core-plan.md` `rawDetach`/`mountSlotTree`/`unmountSlotTree` 의사코드), `dispatch-core-plan.md`
(C-7 재정의 + 주입 op 목록), `architecture.md`(`EngineOps.luau`), `ROADMAP.md`(M5 (C-7 재정의 + 주입 op 목록), `architecture.md`(`EngineOps.luau`), `ROADMAP.md`(M5
주입 표면), `archive/`(역전 원문), `README.md` 색인. 주입 표면), `archive/`(역전 원문), `README.md` 색인.
---
# M절 — State 에포크 안: 사용자 3차 정정 (2026-08-21)
`research/state-epoch-validation.md`를 사용자가 직접 읽고 **기제 서술의
오류 세 건**을 정정했다. **채택 여부 자체는 여전히 미정**이지만, 채택하면
어떤 모양이 되는지는 이제 거의 확정형이다. 반영은 그 문서(§2가 소스) +
`README.md`/`question.md`/`ROADMAP.md` 인덱스.
| # | 문서가 적고 있던 것 | 사용자 정정 |
|---|---|---|
| M-1 | `sourceList` 순회는 **`rawInvalid == true`일 때만** 돈다 | **반대.** `rawInvalid == false`일 때만 돈다. `true`면 이미 재계산이 확정이라 훑을 이유가 없고, 순회의 목적은 오직 **"못 받은 emit을 여기서 먼저 받는 것"**이다 |
| M-2 | `emit``(source, count)`를 실어 보낸다 | **source만** 보내면 된다 — 받는 쪽이 `source`의 count 필드를 그냥 읽는다 |
| M-3 | 노드가 `seen`(전파 dedup)/`computedAt`(캐시 검증) **두 카운트**를 따로 들어야 한다 | **철회.** count 갱신과 `rawInvalid = true`가 **같은 스텝**에서 일어나므로 "전파 때 갱신된 count가 캐시를 신선한 것으로 오인시키는" 실패 모드가 없다. 캐시 유효성은 `rawInvalid`가 들고 count는 "이 에포크를 봤는가"만 답한다 — *"그냥 지금 순수 count 와 source 를 ref해두는 구현은 문제가 없어보인다"* |
**emit 수신 규약(확정형)**: `sourceList[source] == source.count`면 삼키고,
다르면 **count를 먼저 갱신**`rawInvalid = true`**그 다음** 뒤로 emit.
다른 소스 항목은 건드리지 않는다(그 소스는 자기가 직접 emit 하므로).
**부수로 열린 채 남은 것 — 순회로 발견한 변경을 뒤로 emit 할 것인가.**
사용자가 `A → {B, C} → D` 다이아몬드로 (b) 즉시 emit의 위험을 의심했다가
**스스로 안전하다고 정정**했다(*"D조차도 count를 업데이트 하기 때문에 중복을
무시한다"*). **[2026-08-21 기준]** 그래서 걸리는 곳은 게이트 하나 — 앞에서 막아둔 emit이 뒤의 `Get()`이 촉발한
순회로 새어나갈 수 있으므로 **block이 풀린 뒤의 emit까지 기다려야** 하고,
게이트 해제 emit은 여러 소스가 섞여 있어 **`source = nil`을 싣고 받는 쪽이
전체를 확인**하는 규약이 필요하다. 다만 사용자 판단은 *"중간에 blocker 낀건
이전에도 있던 문제다. 이미 최종장에 쓰는게 일반적이므로 의미 없어보인다"* —
**채택을 막는 요소가 아니고**, `nil` 규약만 `Gate` 설계와 같이 확정하면 된다.

View file

@ -221,10 +221,18 @@
`Source`들의 카운트를 들고 있다가 `Get()` 때 비교해 재계산 여부를 정한다. `Source`들의 카운트를 들고 있다가 `Get()` 때 비교해 재계산 여부를 정한다.
동기는 성능이 아니라 **정확성** — DFS 전파 도중 Observer가 `Get()`을 부르면 동기는 성능이 아니라 **정확성** — DFS 전파 도중 Observer가 `Get()`을 부르면
아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어가는 glitch가 지금 모델에 아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어가는 glitch가 지금 모델에
실재한다. 에이전트 분석 결과 진단·방향 모두 타당하고 선례도 있으나 실재한다. 에이전트 분석 결과 진단·방향 모두 타당하고 선례도 있다
(MobX/Adapton류 버전 검증), **중복 *통지*는 안 고쳐지고 "선언 안 된 의존성"을 (MobX/Adapton류 버전 검증). **[2026-08-21 갱신 — 여기 있던 "중복 통지는 안
UB로 명문화해야 한다.** State 내부 표현을 바꾸는 결정이라 M3 뒤로 미루면 고쳐지고 선언 안 된 의존성을 UB로 명문화해야 한다"는 서술은 같은 날 둘 다
되돌리는 비용이 크다. 상세는 `research/state-epoch-validation.md`. 뒤집혔다]**: 중복 통지도 **같은 장치로 접고**, UB 조항은 사용자 기각으로
빠졌다. 이어진 3차 정정으로 기제도 확정형에 가까워졌다 — `sourceList` 순회는
`rawInvalid`가 **false**일 때만 돌고(목적은 "못 받은 emit 받기"), emit은
count 없이 **발행 source만** 싣고, 카운트는 **하나면 충분**하다. 사용자
정리: *"그냥 지금 순수 count 와 source 를 ref해두는 구현은 문제가
없어보인다."* **남은 판단은 (a) 채택 여부 자체와 (b) 게이트 해제 emit이
`source = nil`을 실어 "전체 확인"을 시키는 규약**이고, State 내부 표현을
바꾸는 결정이라 M3 뒤로 미루면 되돌리는 비용이 크다. 상세는
`research/state-epoch-validation.md`.
- **[해소, 2026-08-21 구현 전 QA 5라운드 `C-4`] `Dispatch.setLength`의 Observer - **[해소, 2026-08-21 구현 전 QA 5라운드 `C-4`] `Dispatch.setLength`의 Observer
앵커 — 물리 target으로 확정(4라운드 `D-56` 역전).** `setLength` 앵커 — 물리 target으로 확정(4라운드 `D-56` 역전).** `setLength`
`(ownerKey, i, len, anchor)`로 4번째 인자를 받아 **부기 키와 생명주기 앵커를 `(ownerKey, i, len, anchor)`로 4번째 인자를 받아 **부기 키와 생명주기 앵커를

View file

@ -1,6 +1,6 @@
# State 재계산 판정을 "소스 에포크" 비교로 바꾸는 안 (2026-08-21 신설) # State 재계산 판정을 "소스 에포크" 비교로 바꾸는 안 (2026-08-21 신설)
**상태**: research — **사용자 제안, 에이전트 분석 완료, 세부 두 건은 2026-08-21에 추가 확정(중복 통지도 접음 / 선언 안 된 의존성 조항 기각). 채택 자체는 여전히 미정.** **상태**: research — **사용자 제안, 에이전트 분석 완료. 2026-08-21에 세부가 세 차례 갱신됐다(중복 통지도 접음 / 선언 안 된 의존성 조항 기각 / `rawInvalid` 기제와 `emit` 인자 정정 + `seen`·`computedAt` 분리 철회). 채택 자체는 여전히 미정.**
구현 전 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`.
@ -34,25 +34,52 @@ A ──> B ──┐
시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에 시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에
발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다. 발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다.
## 2. 사용자 제안 (최종 정리형) ## 2. 사용자 제안 (최종 정리형)**[2026-08-21 정정본]**
각 State가 **자기에게 영향을 주는 루트 `Source`들의 에포크**를 들고 있다가, 각 State가 **자기에게 영향을 주는 루트 `Source`들의 에포크(count)** 를 들고
`Get()` 때 그것과 실제 Source의 현재 에포크를 비교해 재계산 여부를 정한다. 있다가, 그것과 실제 Source의 현재 count를 비교해 재계산/전파 여부를 정한다.
``` ```
State State
sourceList : { [source (weak key)] : count } -- 상류에서 복사, :With에서 합침 sourceList : { [source (weak key)] : count }
rawInvalid : boolean -- emit이 오면 그냥 true(값싼 캐시) -- "이 소스의 이 에포크까지는 이미 봤다". 상류에서 복사, :With에서 합침
invalid : rawInvalid가 true일 때만 sourceList를 훑어 "정말 계산이 필요한가" 판정 rawInvalid : boolean
-- "재계산이 필요하다"는 확정 플래그. emit이 실제로 통과하면 켜진다
``` ```
- `emit`은 **발행한 source와 그 count를 같이 전달**한다(값은 여전히 안 싣는다).
- `Source:Set()`/`:Emit()`은 자기 count를 증가시킨다. - `Source:Set()`/`:Emit()`은 자기 count를 증가시킨다.
- 캐시를 채울 때 그 시점의 각 source count를 같이 기록해두고, `Get()` - **`emit`은 발행한 source만 전달한다**(값도, count도 안 싣는다) — 받는 쪽이
하나라도 다르면 재계산한다. 그 source의 count 필드를 그냥 읽으면 된다. **[2026-08-21 사용자 정정]**
앞선 정리는 `(source, count)` 쌍을 실어 보낸다고 적었는데 불필요하다.
- **emit을 받았을 때** — 판정은 O(1)이다, 그 소스 항목 하나만 본다:
1. `sourceList[source] == source.count`**삼킨다.** 이 에포크는 이미 다른
경로로 봤다는 뜻이므로 뒤로 안 내려보낸다.
2. 다르면 → **`sourceList[source]`를 먼저 갱신**하고 `rawInvalid = true`
둔 다음, **그러고 나서** 뒤로 emit 한다.
- **다른 소스 항목은 건드리지 않는다** — 그 소스들은 자기가 직접 emit 하므로.
- **재계산 판정**:
- `rawInvalid == true` → 그냥 재계산한다. `sourceList`를 훑을 이유가 없다
(이미 확정이라 더 알아낼 게 없다).
- `rawInvalid == false`**그때만 `sourceList`를 훑는다.** 목적은 하나뿐 —
**못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에 막혀 있었다거나).
다른 항목이 발견되면 count를 갱신하고 `rawInvalid = true`로 만든다.
- **[2026-08-21 사용자 정정]** 앞선 정리는 이 순회 조건을 `rawInvalid == true`
일 때로 적었는데 **반대**다.
- 재계산이 끝나면 `rawInvalid = false`. count는 emit을 통과시킬 때 이미
갱신돼 있으므로 따로 손댈 게 없다.
- count가 싫다면 `[source] -> {}` 처럼 **유니크 테이블 identity**로 같은 판정이 - count가 싫다면 `[source] -> {}` 처럼 **유니크 테이블 identity**로 같은 판정이
가능하다(사용자 대안). 가능하다(사용자 대안).
**⭐ [2026-08-21] 그래서 카운트는 하나면 된다 — `seen`/`computedAt` 분리는
철회.** 앞선 정리는 노드가 전파 dedup용 `seen`과 캐시 검증용 `computedAt`
따로 들어야 한다고 적었으나, 사용자 정정으로 **불필요**하다: count 갱신과
`rawInvalid = true`**같은 스텝에서 같이 일어나기** 때문에 "전파 시점에
갱신된 count가 캐시를 신선한 것으로 오인하게 만드는" 실패 모드 자체가
생기지 않는다. 캐시 유효성은 count가 아니라 `rawInvalid`가 들고, count는
순수하게 **"이 에포크를 봤는가"** 만 답한다. 사용자 정리: *"그냥 지금 순수
count 와 source 를 ref해두는 구현은 문제가 없어보인다. 단순 rawInvalid 가
약간 이상하게 사용/기제된 부분만 보일 뿐임."*
## 3. 에이전트 분석 — 이게 실제로 무엇을 고치는가 ## 3. 에이전트 분석 — 이게 실제로 무엇을 고치는가
**결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라 **결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라
@ -71,19 +98,19 @@ State
이미 모든 소스가 최신이면 무시하는게 맞아보인다. 특히 emit 은 자신 소스를 이미 모든 소스가 최신이면 무시하는게 맞아보인다. 특히 emit 은 자신 소스를
주게 되므로, 자신 소스 카운트만 빠르게 비교가 쉽다. 우리에게 중요한 점은, 주게 되므로, 자신 소스 카운트만 빠르게 비교가 쉽다. 우리에게 중요한 점은,
앞단의 변경이 뒤로 흘렀냐일 뿐이라서 해당 방식을 택하는데 있어 문제가 없다."* 앞단의 변경이 뒤로 흘렀냐일 뿐이라서 해당 방식을 택하는데 있어 문제가 없다."*
- **판정은 O(1)이다** — emit이 `(source, count)`를 실어 오므로 **그 소스 - **판정은 O(1)이다** — emit이 **어느 source가 발행했는지**를 실어 오므로
하나만** 비교하면 된다(전체 목록 순회가 아님). **그 소스 하나만** 비교하면 된다(전체 목록 순회가 아님).
- **⚠️ 이건 2026-08-14에 뒤집힌 옛 dedup과 다른 장치다.** 그때 폐기된 건 - **⚠️ 이건 2026-08-14에 뒤집힌 옛 dedup과 다른 장치다.** 그때 폐기된 건
*"이미 `invalid`면 더 안 내려보낸다"*로, `:Get()`을 안 부르는 Observer가 *"이미 `invalid`면 더 안 내려보낸다"*로, `:Get()`을 안 부르는 Observer가
**한 번 울고 영구히 침묵**하는 실패 모드가 있었다 **한 번 울고 영구히 침묵**하는 실패 모드가 있었다
(`archive/invalidate-dedup-propagation-reversed.md`). 에포크 비교는 그 (`archive/invalidate-dedup-propagation-reversed.md`). 에포크 비교는 그
모드가 없다 — 매 `Set`마다 카운트가 **새 값**이라 항상 통과하고, 접히는 건 모드가 없다 — 매 `Set`마다 카운트가 **새 값**이라 항상 통과하고, 접히는 건
**같은 에포크가 두 경로로 도착한 두 번째**뿐이다. **같은 에포크가 두 경로로 도착한 두 번째**뿐이다.
- **그래서 노드가 기록해야 하는 카운트가 둘이다**: 전파 dedup용 - **⚠️ [2026-08-21 철회] 여기 있던 "카운트를 `seen`/`computedAt` 둘로
"이 소스의 이 에포크를 이미 내려보냈다"(`seen`)와, 캐시 검증용 "이 값을 분리하라"는 요구는 사용자 정정으로 없어졌다** — §2 마지막 문단이 소스.
계산할 때의 에포크"(`computedAt`). 하나로 합치면 전파 시점에 갱신되는 캐시 유효성을 `rawInvalid`가 들고 있고 count 갱신이 `rawInvalid = true`
쪽이 캐시를 신선한 것으로 오인하게 만든다 — **구현 시 반드시 분리할 것.** 같은 스텝이라, 하나로 합쳐도 캐시를 신선한 것으로 오인하는 경로가 없다.
- 비교는 `incoming <= seen[source]`면 삼킨다(단조 증가라 안전). - 비교는 `sourceList[source] == source.count`면 삼킨다(단조 증가라 안전).
- **선례가 있다** — 값 자체를 비교하는 게 아니라 **버전/에포크를 비교해 - **선례가 있다** — 값 자체를 비교하는 게 아니라 **버전/에포크를 비교해
lazy하게 검증**하는 건 MobX(global state version + observing 검사), lazy하게 검증**하는 건 MobX(global state version + observing 검사),
Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준 Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준
@ -99,8 +126,11 @@ State
- `sourceList`의 크기는 **그 노드 상류에 있는 서로 다른 루트 Source의 수**다. - `sourceList`의 크기는 **그 노드 상류에 있는 서로 다른 루트 Source의 수**다.
체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가 체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가
큰 경우는 드물다. 큰 경우는 드물다.
- `rawInvalid`가 false면 훑지도 않으므로, **정말 안 바뀐 흔한 경로는 지금과 - **[2026-08-21 정정]** 순회 조건이 뒤집혔으므로 비용 구도도 뒤집힌다 —
같은 비용**(플래그 하나)이다. **훑는 쪽이 흔한 경로**(`rawInvalid == false`, 즉 "안 바뀐 것 같다")다.
그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼
**재계산은 안 도는** 경로다. 반대로 `rawInvalid == true`면 순회를 아예
건너뛰고 바로 재계산한다.
## 5. 열린 질문 (채택 전에 답이 필요) ## 5. 열린 질문 (채택 전에 답이 필요)
@ -114,10 +144,32 @@ State
2. **동적 의존성.** 조건에 따라 다른 상류를 읽는 계산이면 `sourceList` 2. **동적 의존성.** 조건에 따라 다른 상류를 읽는 계산이면 `sourceList`
보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다. 보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다.
그대로 감수할지 확인. 그대로 감수할지 확인.
3. **`Blocker`/`Gate`와의 상호작용.** 게이트는 **통지만** 막고 `Get()`엔 영향이 3. **⭐ [2026-08-21 확장] 순회로 발견한 변경을 뒤로 emit 할 것인가 —
없다는 게 확정 계약이므로(`base/blocker-plan.md`), 게이트 아래에서 `Get()`하면 그리고 `Blocker`/`Gate`와의 상호작용.**
에포크 비교가 "갱신 필요"로 판정해 **막아둔 값이 그대로 보인다**. 지금도 `rawInvalid == false`에서 순회를 돌다 "못 받은 emit"을 발견해
같은 동작이지만, 새 모델에선 그게 더 또렷해지므로 문서에 명시할 것. `rawInvalid = true`가 됐을 때, **그 자리에서 뒤로 emit 하는가**가 열려 있다.
두 안:
- **(a) 지연** — 아무것도 안 내려보내고, 나중에 그 소스의 진짜 emit이
도착할 때 정상 경로로 처리한다.
- **(b) 즉시** — 재계산하라는 지시를 받았고 invalid로 판정됐으니 그때 emit.
사용자가 (b)의 위험을 먼저 의심했다가 **스스로 안전하다고 정정**했다:
`A → {B, C} → D`에서 `B``C`를 요구하다 `C`의 변경분이 흘러 `D`가 다시
깨어나는 것처럼 보이지만, **`D`도 count를 갱신하므로 중복을 삼킨다** —
*"안전히 C가 emit 되어도 되는 상황"*.
남는 건 게이트 쪽이다. 게이트가 **앞에서 막아둔 emit**을, 뒤에서 `Get()`
촉발한 순회가 그대로 흘려보낼 수 있다(*"앞에서 막아뒀는데, 뒤에서 필요하다고
해서 그대로 흘러버릴 가능성"*). 그래서 (b)를 택하더라도 **실제 block이 풀린
뒤의 emit까지는 기다려야 한다**. 그리고 **게이트가 풀 때 내보내는 emit은
여러 소스가 섞인 것**이라 어느 소스가 바뀌었는지 지목할 수 없다 — 사용자
제안은 그 emit이 **`source = nil`을 싣고, `nil`이면 받는 쪽이 전체 목록을
확인**하는 것. 게이트는 `Get()`은 막지 않고 emit만 유보하므로 이게 성립한다.
**다만 사용자 판단은 "중간에 게이트가 끼는 건 원래 있던 문제이고, 게이트는
보통 최종단에 쓰므로 실질적으로 의미 없어 보인다"**로 정리됐다 — 즉 이
항목은 **채택을 막는 요소가 아니고**, `nil` emit 규약만 `Gate` 설계
(`research/gate-primitive.md`)와 같이 확정하면 된다.
4. **`:Emit()`(값을 제자리에서 mutate하고 알리는 경로)** — count만 올리면 되므로 4. **`:Emit()`(값을 제자리에서 mutate하고 알리는 경로)** — count만 올리면 되므로
그대로 동작한다. 확인만. 그대로 동작한다. 확인만.
5. **weak 키.** `[source] -> count`를 weak-key로 두면 source가 죽을 때 항목이 5. **weak 키.** `[source] -> count`를 weak-key로 두면 source가 죽을 때 항목이
@ -146,8 +198,9 @@ Ref<T> 로 변환해주는 등"*. 즉 "항상 최신 값을 필드로 들고 있
- **[2026-08-21] 에이전트가 붙였던 조건(선언 안 된 의존성을 UB로 명문화)은 - **[2026-08-21] 에이전트가 붙였던 조건(선언 안 된 의존성을 UB로 명문화)은
사용자 기각으로 빠졌다** — §5의 1번 참고. 사용자 기각으로 빠졌다** — §5의 1번 참고.
- **채택 시 같이 확정되는 것: 중복 통지도 접는다**(§3) — 그러면 다이아몬드에서 - **채택 시 같이 확정되는 것: 중복 통지도 접는다**(§3) — 그러면 다이아몬드에서
값도 통지도 한 번씩만 간다. 대신 노드가 `seen`/`computedAt` 두 카운트를 값도 통지도 한 번씩만 간다. **[2026-08-21 정정]** 여기 있던 "노드가
들어야 한다는 게 구현 요구사항으로 추가된다. `seen`/`computedAt` 두 카운트를 들어야 한다"는 추가 요구는 **철회**됐고,
대신 확정할 것은 **게이트 해제 emit의 `source = nil` 규약**(§5의 3번)이다.
- 실측은 `luau-test`에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서 - 실측은 `luau-test`에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서
(a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 (a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면
사라지는지 대조. 사라지는지 대조.

View file

@ -204,6 +204,19 @@
- `qa-request/pre-implementation-qa-round5.md` — 문항지(205문항) - `qa-request/pre-implementation-qa-round5.md` — 문항지(205문항)
- `qa-request/pre-implementation-qa-round5-response.md` — 회신 원문 - `qa-request/pre-implementation-qa-round5-response.md` — 회신 원문
- `qa-request/pre-implementation-qa-round5-followup.md` — **처리 결과의 소스** - `qa-request/pre-implementation-qa-round5-followup.md` — **처리 결과의 소스**
(A~E절 1차, **F절이 최신**) (절이 라운드마다 쌓임 — **마지막 절이 최신**)
- `archive/bindlifetime-slot-owner-reversed.md` — 역전된 `D-56` 원문 - `archive/bindlifetime-slot-owner-reversed.md` — 역전된 `D-56` 원문
- `research/gate-primitive.md`, `research/state-epoch-validation.md` - `research/gate-primitive.md`, `research/state-epoch-validation.md`
## 8. 후속 — State 에포크 안 3차 정정 (같은 날)
사용자가 `research/state-epoch-validation.md`를 직접 읽고 기제 서술 세 건을
정정했다: (1) `sourceList` 순회는 `rawInvalid`가 **false**일 때만 돈다(문서는
반대로 적고 있었다, 목적은 "못 받은 emit 받기"), (2) `emit`은 count 없이
**발행 source만** 싣는다, (3) 에이전트가 요구했던 **`seen`/`computedAt` 두
카운트 분리는 철회** — count 갱신과 `rawInvalid = true`가 같은 스텝이라
캐시 오인 경로가 없다. 부수로 "순회가 발견한 변경을 뒤로 emit 할 것인가"가
열렸는데, 다이아몬드 쪽은 사용자가 스스로 안전으로 정정했고 게이트 쪽만
**해제 emit이 `source = nil`을 싣는 규약**으로 남았다(게이트는 보통 최종단에
쓰므로 채택을 막지 않는다는 판단). 상세는 그 문서의 §2·§5와
`qa-request/pre-implementation-qa-round5-followup.md`의 M절.

View file

@ -347,7 +347,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] **[2026-08-21 5라운드 — 착수 전 확인]** State 재계산 판정을 "소스 - [ ] **[2026-08-21 5라운드 — 착수 전 확인]** State 재계산 판정을 "소스
에포크 비교"로 바꿀지가 **미정인 채 열려 있다**(`research/state-epoch-validation.md`, 에포크 비교"로 바꿀지가 **미정인 채 열려 있다**(`research/state-epoch-validation.md`,
`question.md` 3번). 채택하면 **State 내부 표현이 바뀌므로**(노드가 `question.md` 3번). 채택하면 **State 내부 표현이 바뀌므로**(노드가
`seen`/`computedAt` 두 카운트를 들고, `Get`이 상류 에포크를 검증) 루트 Source별 count를 들고, `Get`이 상류 에포크를 검증)
아래 `Source.luau`/`State.luau`를 짜기 **전에** 결론이 나야 한다 — 아래 `Source.luau`/`State.luau`를 짜기 **전에** 결론이 나야 한다 —
그 문서 자신이 "M3 뒤로 미루면 되돌리는 비용이 크다"고 경고한다. 그 문서 자신이 "M3 뒤로 미루면 되돌리는 비용이 크다"고 경고한다.
- [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 - [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를