design: 에포크 순회 처분을 sourceCountMap/sourceEmitMap 두 테이블로 확정
사용자가 열려 있던 마지막 자리를 제3안으로 닫음. 판정 기준을 둘로 나눈다 — sourceCountMap(값 유효성)은 순회가 앞당겨 올리고, sourceEmitMap(전파 dedup)은 상류의 진짜 emit을 기다린다. emit 수신 규칙은 셋: count가 다르면 둘 다 갱신 + rawInvalid + 전파 / count는 같은데 emit 기록이 다르면 전파만(순회가 앞질러 흡수한 경우) / 둘 다 같으면 삼킨다. 순회는 emit을 하지 않는다. 효과 — 통지가 죽는 "영구 침묵"이 사라지고, 순회가 emit을 안 하므로 게이트 누출 경로 자체가 없어져 source = nil 규약도 "게이트를 에포크 경계로" 같은 계약 반전도 불필요해진다. 직전 라운드에서 에이전트가 냈던 (c)안의 약점 (emit 도착 전까지 Get마다 재계산)도 sourceCountMap을 실제로 올리므로 없다. 같이 검토된 rawEmit+nil 안은 구조 위생(상류 emit과 내부 발생 emit의 진입점 통일)만 살리고 해법으로는 안 씀 — 막는 게이트는 보통 순회하는 노드 자신이 아니라 상류에 있어 자기 rawEmit을 태워도 누출이 남고, nil emit은 하류마다 전체 순회를 강제해 같은 문제를 연쇄시킨다. M절에서 철회했던 seen/computedAt 분리가 다른 근거(순회가 값과 통지를 비대칭으로 앞당김)로 되살아난 것이라는 점도 명시. 이제 기제는 다 정해졌고 남은 건 채택 여부 자체 — README/question.md/ROADMAP 동기화. 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:
parent
9627558046
commit
76cf74e80d
6 changed files with 154 additions and 96 deletions
|
|
@ -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] 결론 확정·반영 완료**, 이제 근거 기록용 |
|
||||
| `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`의 배치 등록이 이 게이팅을 전제) |
|
||||
| `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 구현) 착수 전 필요** |
|
||||
| `state-epoch-validation.md` | **[2026-08-21 신설]** State 재계산 판정을 `invalid` 플래그에서 **루트 Source 에포크 비교**로 바꾸는 안(사용자 제안) — DFS 전파 도중 `Get()`이 섞인 값을 캐시하는 glitch를 없앰. 성능이 아니라 **정확성** 결정이고 State 내부 표현을 바꾸므로 M3 전에 결론 필요. 에이전트 분석: 진단·방향 모두 타당. **[같은 날 후속 확정] 중복 *통지*도 같은 장치로 접고**(emit이 발행 source를 실어오므로 판정 O(1)), "선언 안 된 의존성 UB 명문화"는 사용자 기각으로 빠짐. **[같은 날 3·4차 정정]** `rawInvalid` 기제가 뒤집혔고(순회는 `rawInvalid`가 **false**일 때만 돌고, emit은 count 없이 source만 싣는다), 순회가 발견한 변경의 처분이 **테이블 둘**로 확정됐다 — `sourceCountMap`(값 유효성)은 순회가 앞당겨 올리고 `sourceEmitMap`(전파 dedup)은 상류의 진짜 emit을 기다린다. 그래서 통지가 죽지도, 게이트를 새지도 않아 `source = nil` 규약이 불필요해짐. **기제는 사실상 다 정해졌고 남은 건 채택 여부 자체** | **상 — M3(State 구현) 착수 전 필요** |
|
||||
| `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 코어 구현 시점까지 미결 |
|
||||
| `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 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) |
|
||||
|
|
|
|||
|
|
@ -905,3 +905,50 @@ nativeDispose(element) -- 트리 밖
|
|||
전체를 확인**하는 규약이 필요하다. 다만 사용자 판단은 *"중간에 blocker 낀건
|
||||
이전에도 있던 문제다. 이미 최종장에 쓰는게 일반적이므로 의미 없어보인다"* —
|
||||
**채택을 막는 요소가 아니고**, `nil` 규약만 `Gate` 설계와 같이 확정하면 된다.
|
||||
|
||||
---
|
||||
|
||||
# N절 — State 에포크 안: 순회 처분 확정 + `Gate`는 `:Apply`가 아니다 (2026-08-21)
|
||||
|
||||
## N-1. 순회가 발견한 변경의 처분 — 테이블 둘로 확정
|
||||
|
||||
사용자가 M절의 열린 자리를 직접 닫았다. **문제**: 순회가 count를 최신으로
|
||||
올리면 뒤늦게 온 진짜 emit이 삼켜져 **하류가 그 에포크를 영영 못 받는다**
|
||||
(옛 dedup의 "영구 침묵"과 같은 계열).
|
||||
|
||||
**확정**: 판정 기준을 둘로 나눈다 — *"'내 값이, 상류의 상태로 하여금 사용
|
||||
가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시 던져야하는지
|
||||
봐야하나' 를 보는걸 나누는거죠."*
|
||||
|
||||
| 테이블 | 뜻 | 순회가 건드리나 |
|
||||
|---|---|---|
|
||||
| `sourceCountMap = {[source]: count}` | 값 유효성 | **예** — 순회가 앞당겨 올린다 |
|
||||
| `sourceEmitMap = {[source]: count}` | 전파 dedup | **아니오** — 상류의 진짜 emit을 기다린다 |
|
||||
|
||||
emit 수신 규칙 셋: (1) count가 다르면 둘 다 갱신 + `rawInvalid` + 전파,
|
||||
(2) count는 같은데 emit 기록이 다르면 **전파만**(순회가 앞질러 흡수한 경우),
|
||||
(3) 둘 다 같으면 삼킨다. 순회는 emit을 **안 한다** — *"emit 바로 안하고
|
||||
상류가 emit 해줄 때 까지 기다립니다"*.
|
||||
|
||||
**부수 소멸**: 순회가 emit을 안 하므로 게이트 누출 경로가 아예 없어져
|
||||
`source = nil` 규약도, "게이트를 에포크 경계로" 같은 계약 반전도 불필요.
|
||||
같이 검토된 `rawEmit`+`nil` 안은 **구조 위생 쪽만 채택할 만하다**(상류 emit과
|
||||
내부 발생 emit이 같은 진입점을 쓰는 것) — 해법으로는 부족한데, 막는 게이트는
|
||||
보통 순회하는 노드 자신이 아니라 **상류**에 있어 자기 `rawEmit`을 태워도
|
||||
누출이 남고, `nil` emit은 하류마다 전체 순회를 강제해 같은 문제를 연쇄시키기
|
||||
때문. 상세는 `research/state-epoch-validation.md` §2·§5-3.
|
||||
|
||||
**메모**: 이 분리는 M절에서 **철회했던 `seen`/`computedAt` 분리가 다른 근거로
|
||||
되살아난 것**이다. 옛 근거는 여전히 틀렸고, 지금 근거는 **순회가 값과 통지를
|
||||
비대칭으로 앞당긴다**는 것.
|
||||
|
||||
## N-2. `Gate`는 `:Apply`가 아니라 State 메소드
|
||||
|
||||
**사용자 확정**: *"gate 는 apply 불가하다고 판단함. 순수 슈가가 아니기 때문,
|
||||
state 의 전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."* 경계를
|
||||
정확히 하면 `Apply`가 노드를 못 만드는 게 아니라(확정 예시 `capAt(100)`도
|
||||
`:With` 노드를 만든다) **프리미티브는 메소드 / 유저랜드 조합 팩토리는
|
||||
`:Apply`** 라는 층위 구분이다. 부수로 `Debounce`/`Throttle`의 `:Apply` 관용구는
|
||||
그대로 유효하고(팩토리가 내부에서 `:Gate`를 부름), `Blocker` 배선은 이미 확정된
|
||||
`state:Block(blocker)` 메소드로 자동 해소되며, `__call`은 안 쓴다.
|
||||
`research/gate-primitive.md`의 2번이 해소로 갱신됨.
|
||||
|
|
|
|||
|
|
@ -229,12 +229,13 @@
|
|||
(MobX/Adapton류 버전 검증). **[2026-08-21 갱신 — 여기 있던 "중복 통지는 안
|
||||
고쳐지고 선언 안 된 의존성을 UB로 명문화해야 한다"는 서술은 같은 날 둘 다
|
||||
뒤집혔다]**: 중복 통지도 **같은 장치로 접고**, UB 조항은 사용자 기각으로
|
||||
빠졌다. 이어진 3차 정정으로 기제도 확정형에 가까워졌다 — `sourceList` 순회는
|
||||
빠졌다. 이어진 3·4차 정정으로 **기제는 사실상 다 정해졌다** — 순회는
|
||||
`rawInvalid`가 **false**일 때만 돌고(목적은 "못 받은 emit 받기"), emit은
|
||||
count 없이 **발행 source만** 싣고, 카운트는 **하나면 충분**하다. 사용자
|
||||
정리: *"그냥 지금 순수 count 와 source 를 ref해두는 구현은 문제가
|
||||
없어보인다."* **남은 판단은 (a) 채택 여부 자체와 (b) 게이트 해제 emit이
|
||||
`source = nil`을 실어 "전체 확인"을 시키는 규약**이고, State 내부 표현을
|
||||
count 없이 **발행 source만** 싣고, 순회가 발견한 변경은 **테이블 둘**로
|
||||
처분한다(`sourceCountMap`은 순회가 앞당겨 올리고, `sourceEmitMap`은 상류의
|
||||
진짜 emit을 기다림 — *"emit 바로 안하고 상류가 emit 해줄 때 까지
|
||||
기다립니다"*). 그래서 통지가 죽지도 게이트를 새지도 않아 `source = nil`
|
||||
규약도 필요 없어졌다. **남은 판단은 채택 여부 자체 하나**이고, State 내부 표현을
|
||||
바꾸는 결정이라 M3 뒤로 미루면 되돌리는 비용이 크다. 상세는
|
||||
`research/state-epoch-validation.md`.
|
||||
- **[해소, 2026-08-21 구현 전 QA 5라운드 `C-4`] `Dispatch.setLength`의 Observer
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# State 재계산 판정을 "소스 에포크" 비교로 바꾸는 안 (2026-08-21 신설)
|
||||
|
||||
**상태**: research — **사용자 제안, 에이전트 분석 완료. 2026-08-21에 세부가 세 차례 갱신됐다(중복 통지도 접음 / 선언 안 된 의존성 조항 기각 / `rawInvalid` 기제와 `emit` 인자 정정 + `seen`·`computedAt` 분리 철회). 채택 자체는 여전히 미정.**
|
||||
**상태**: research — **사용자 제안, 에이전트 분석 완료. 2026-08-21에 세부가 네 차례 갱신됐다(중복 통지도 접음 / 선언 안 된 의존성 조항 기각 / `rawInvalid` 기제와 `emit` 인자 정정 / 순회 처분을 `sourceCountMap`·`sourceEmitMap` 두 테이블로 확정). 이제 기제는 사실상 다 정해졌고 남은 건 **채택 여부 자체**다 — 여전히 미정.**
|
||||
구현 전 QA 5라운드(`SS-2`/`SS-3`)에서 나왔고 사용자 스스로 *"더 생각해볼
|
||||
이야기라 백로깅이나 리서치에 들어가야할듯"*이라 함. 회신 원문은
|
||||
`qa-request/pre-implementation-qa-round5-response.md`.
|
||||
|
|
@ -34,53 +34,64 @@ A ──> B ──┐
|
|||
시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에
|
||||
발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다.
|
||||
|
||||
## 2. 사용자 제안 (최종 정리형) — **[2026-08-21 정정본]**
|
||||
## 2. 사용자 제안 (최종 정리형) — **[2026-08-21 3차 정정본]**
|
||||
|
||||
각 State가 **자기에게 영향을 주는 루트 `Source`들의 에포크(count)** 를 들고
|
||||
있다가, 그것과 실제 Source의 현재 count를 비교해 재계산/전파 여부를 정한다.
|
||||
**[2026-08-21] 마지막 정정으로 테이블이 둘로 갈렸다** — 아래 "왜 둘인가" 참고.
|
||||
|
||||
```
|
||||
State
|
||||
sourceList : { [source (weak key)] : count }
|
||||
-- "이 소스의 이 에포크까지는 이미 봤다". 상류에서 복사, :With에서 합침
|
||||
sourceCountMap : { [source (weak key)] : count }
|
||||
-- "내 값이 이 소스에 대해 최신인가" (값 유효성)
|
||||
sourceEmitMap : { [source (weak key)] : count }
|
||||
-- "이 소스의 이 에포크를 내가 하류로 이미 던졌는가" (전파 dedup)
|
||||
rawInvalid : boolean
|
||||
-- "재계산이 필요하다"는 확정 플래그. emit이 실제로 통과하면 켜진다
|
||||
-- "재계산이 필요하다"는 확정 플래그
|
||||
```
|
||||
|
||||
둘 다 상류에서 복사되고 `:With`에서 합쳐진다.
|
||||
|
||||
- `Source:Set()`/`:Emit()`은 자기 count를 증가시킨다.
|
||||
- **`emit`은 발행한 source만 전달한다**(값도, count도 안 싣는다) — 받는 쪽이
|
||||
그 source의 count 필드를 그냥 읽으면 된다. **[2026-08-21 사용자 정정]**
|
||||
앞선 정리는 `(source, count)` 쌍을 실어 보낸다고 적었는데 불필요하다.
|
||||
그 source의 count 필드를 **그때그때 라이브로** 읽는다. 그래서 "에포크 5의
|
||||
emit" 같은 건 없고 emit은 그냥 **"이 소스를 확인해봐"** 라는 신호다.
|
||||
- **emit을 받았을 때** — 판정은 O(1)이다, 그 소스 항목 하나만 본다:
|
||||
1. `sourceList[source] == source.count` → **삼킨다.** 이 에포크는 이미 다른
|
||||
경로로 봤다는 뜻이므로 뒤로 안 내려보낸다.
|
||||
2. 다르면 → **`sourceList[source]`를 먼저 갱신**하고 `rawInvalid = true`를
|
||||
둔 다음, **그러고 나서** 뒤로 emit 한다.
|
||||
1. `sourceCountMap[source] ~= source.count` → 둘 다 `source.count`로 갱신하고
|
||||
`rawInvalid = true`를 세운 다음 **뒤로 emit** 한다.
|
||||
2. 같은데 `sourceEmitMap[source] ~= source.count` → **값은 이미 최신이지만
|
||||
통지는 아직 안 나갔다**(아래 순회가 앞질러 흡수한 경우). `sourceEmitMap`만
|
||||
갱신하고 **뒤로 emit** 한다 — `rawInvalid`는 안 건드린다.
|
||||
3. 둘 다 같으면 → **삼킨다.**
|
||||
- **다른 소스 항목은 건드리지 않는다** — 그 소스들은 자기가 직접 emit 하므로.
|
||||
- **재계산 판정**:
|
||||
- `rawInvalid == true` → 그냥 재계산한다. `sourceList`를 훑을 이유가 없다
|
||||
(이미 확정이라 더 알아낼 게 없다).
|
||||
- `rawInvalid == false` → **그때만 `sourceList`를 훑는다.** 목적은 하나뿐 —
|
||||
**못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에 막혀 있었다거나).
|
||||
다른 항목이 발견되면 `rawInvalid = true`로 만든다.
|
||||
**⚠️ 이때 count를 갱신할지는 열려 있다 — §5의 3번이 소스**(갱신하면 나중에
|
||||
도착하는 진짜 emit이 삼켜져 하류가 영영 통지를 못 받는다).
|
||||
- **[2026-08-21 사용자 정정]** 앞선 정리는 이 순회 조건을 `rawInvalid == true`
|
||||
일 때로 적었는데 **반대**다.
|
||||
- 재계산이 끝나면 `rawInvalid = false`. count는 emit을 통과시킬 때 이미
|
||||
갱신돼 있으므로 따로 손댈 게 없다.
|
||||
- `rawInvalid == true` → 그냥 재계산한다. 순회할 이유가 없다(이미 확정).
|
||||
- `rawInvalid == false` → **그때만 `sourceCountMap`을 훑는다.** 목적은 하나뿐 —
|
||||
**못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에 막혀 있었다거나
|
||||
전파 파동이 아직 이 가지에 안 닿았다거나). 다른 항목이 발견되면
|
||||
**`sourceCountMap`만** 갱신하고 `rawInvalid = true`로 만든다.
|
||||
- **⭐ 순회는 `sourceEmitMap`을 건드리지 않고, 뒤로 emit 하지도 않는다.**
|
||||
그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 **하류까지 정상적으로
|
||||
전파된다**(사용자: *"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*).
|
||||
- 재계산이 끝나면 `rawInvalid = false`.
|
||||
- count가 싫다면 `[source] -> {}` 처럼 **유니크 테이블 identity**로 같은 판정이
|
||||
가능하다(사용자 대안).
|
||||
|
||||
**⭐ [2026-08-21] 그래서 카운트는 하나면 된다 — `seen`/`computedAt` 분리는
|
||||
철회.** 앞선 정리는 노드가 전파 dedup용 `seen`과 캐시 검증용 `computedAt`을
|
||||
따로 들어야 한다고 적었으나, 사용자 정정으로 **불필요**하다: count 갱신과
|
||||
`rawInvalid = true`가 **같은 스텝에서 같이 일어나기** 때문에 "전파 시점에
|
||||
갱신된 count가 캐시를 신선한 것으로 오인하게 만드는" 실패 모드 자체가
|
||||
생기지 않는다. 캐시 유효성은 count가 아니라 `rawInvalid`가 들고, count는
|
||||
순수하게 **"이 에포크를 봤는가"** 만 답한다. 사용자 정리: *"그냥 지금 순수
|
||||
count 와 source 를 ref해두는 구현은 문제가 없어보인다. 단순 rawInvalid 가
|
||||
약간 이상하게 사용/기제된 부분만 보일 뿐임."*
|
||||
**⭐ 왜 테이블이 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의
|
||||
상태로 하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시
|
||||
던져야하는지 봐야하나' 를 보는걸 나누는거죠."* 순회가 없다면 **"값을
|
||||
최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서** 카운트
|
||||
하나로 충분하다 — 실제로 2026-08-21 2차 정정에서 그렇게 정리했었다. 순회를
|
||||
넣는 순간 그 둘이 갈라진다(순회는 값만 앞당기고 통지는 안 한다). 그래서 이
|
||||
분리는 **한 번 철회됐던 `seen`/`computedAt` 분리가, 다른 이유로 되살아난
|
||||
것**이다 — 옛 근거("전파 시점에 갱신된 count가 캐시를 신선한 것으로 오인시킨다")는
|
||||
여전히 틀렸고, 지금 근거는 **순회가 값과 통지를 비대칭으로 앞당긴다**는 것이다.
|
||||
|
||||
**구현 메모 — `sourceEmitMap`은 희소 테이블로 두면 된다.** emit이 count를 안
|
||||
싣고 받는 쪽이 라이브로 읽으므로, 이 테이블에 실제로 필요한 정보는 **"순회가
|
||||
앞질러 흡수해서 아직 안 던진 소스가 무엇인가"** 뿐이다. 평상시엔 비어 있고
|
||||
순회가 발견했을 때만 채워지는 `pending` 집합으로 구현해도 위 세 규칙이 그대로
|
||||
성립한다(1번에서 지우고, 2번에서 있으면 던지고 지운다).
|
||||
|
||||
## 3. 에이전트 분석 — 이게 실제로 무엇을 고치는가
|
||||
|
||||
|
|
@ -108,11 +119,12 @@ count 와 source 를 ref해두는 구현은 문제가 없어보인다. 단순 ra
|
|||
(`archive/invalidate-dedup-propagation-reversed.md`). 에포크 비교는 그
|
||||
모드가 없다 — 매 `Set`마다 카운트가 **새 값**이라 항상 통과하고, 접히는 건
|
||||
**같은 에포크가 두 경로로 도착한 두 번째**뿐이다.
|
||||
- **⚠️ [2026-08-21 철회] 여기 있던 "카운트를 `seen`/`computedAt` 둘로
|
||||
분리하라"는 요구는 사용자 정정으로 없어졌다** — §2 마지막 문단이 소스.
|
||||
캐시 유효성을 `rawInvalid`가 들고 있고 count 갱신이 `rawInvalid = true`와
|
||||
같은 스텝이라, 하나로 합쳐도 캐시를 신선한 것으로 오인하는 경로가 없다.
|
||||
- 비교는 `sourceList[source] == source.count`면 삼킨다(단조 증가라 안전).
|
||||
- **⚠️ [2026-08-21 철회 후 재도입] 여기 있던 "카운트를 `seen`/`computedAt`
|
||||
둘로 분리하라"는 요구는 한 번 철회됐다가 **다른 근거로 되살아났다** —
|
||||
지금 형태는 `sourceCountMap`/`sourceEmitMap`이고, 근거는 원래 적었던
|
||||
"전파 시점 갱신이 캐시를 오인시킨다"(틀림)가 아니라 **순회가 값만 앞당기고
|
||||
통지는 안 한다**는 비대칭이다. §2의 "왜 테이블이 둘인가" 문단이 소스.
|
||||
- 비교는 두 맵 모두 `source.count`와 같으면 삼킨다(단조 증가라 안전).
|
||||
- **선례가 있다** — 값 자체를 비교하는 게 아니라 **버전/에포크를 비교해
|
||||
lazy하게 검증**하는 건 MobX(global state version + observing 검사),
|
||||
Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준
|
||||
|
|
@ -146,60 +158,43 @@ count 와 source 를 ref해두는 구현은 문제가 없어보인다. 단순 ra
|
|||
2. **동적 의존성.** 조건에 따라 다른 상류를 읽는 계산이면 `sourceList`가
|
||||
보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다.
|
||||
그대로 감수할지 확인.
|
||||
3. **⭐⭐ [2026-08-21 재확장] 순회가 발견한 변경을 어떻게 처분하는가 — 이 안에서
|
||||
유일하게 안 닫힌 자리.**
|
||||
3. **[2026-08-21 해소] 순회가 발견한 변경을 어떻게 처분하는가 — 두 테이블로
|
||||
나눠 "값은 앞당기고 통지는 기다린다"로 확정.**
|
||||
|
||||
**먼저 확정된 것: 순회가 count를 갱신하면 안 된다(또는 갱신하면 반드시
|
||||
emit해야 한다).** 사용자 지적 — *"순회가 발견한거면, count 를 최신으로
|
||||
올리기에 나중에 emit 와도 전파가 안 되는 이슈가 생기긴 함."* 실제로 그렇다:
|
||||
`C`가 순회로 `A`의 변경을 발견해 count를 최신으로 올려두면, 뒤늦게 도착한
|
||||
`A`의 진짜 emit이 `C`에서 **삼켜지고**(count가 같으므로) `C`의 하류는 그
|
||||
에포크를 **영영** 못 받는다. 값은 맞지만 통지가 죽는, **2026-08-14에 폐기된
|
||||
옛 dedup의 "영구 침묵"과 같은 계열의 실패 모드**다
|
||||
**문제**(사용자 지적): 순회가 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)의 유일한 약점이 여기서 사라진다).
|
||||
|
||||
- **(b) 순회가 count를 올리고, 그 자리에서 뒤로 emit 한다** — 사용자 제안.
|
||||
통지가 죽지 않는다. 다이아몬드에서 새는 것도 없다(사용자가 스스로 정정한
|
||||
대로 `D`도 count를 갱신해 중복을 삼킨다). **남는 문제는 게이트뿐** — 아래.
|
||||
- **(c) 순회는 `rawInvalid = true`만 세우고 count는 그대로 둔다** — 에이전트
|
||||
제안. count의 역할을 **"이 에포크를 내가 실제로 전파했는가"** 하나로
|
||||
유지한다. `Get()`은 `rawInvalid` 덕에 신선하고, 나중에 진짜 emit이 오면
|
||||
count가 다르므로 **정상 경로로** 하류까지 전파된다. **게이트를 새게 하는
|
||||
경로가 아예 생기지 않는다**(순회는 절대 emit하지 않으므로).
|
||||
- 대가: emit이 도착하기 전까지 **`Get()`마다 다시 재계산**한다(매번 순회가
|
||||
같은 차이를 재발견하므로). 일반 경로에선 파동이 같은 틱에 도착하니 창이
|
||||
아주 좁고, 게이트 경로에선 "묶어놨다가 한 번에 푼다"가 목적이라 그 구간에
|
||||
`Get()`이 잦을 이유가 없다.
|
||||
- ⚠️ 단 `blocker:OffWithoutEmit()`처럼 **emit 없이 푸는 경로**에선 count가
|
||||
영원히 안 따라잡아 그 상태가 무기한 남는다 — 그 경로가 count를 밀어주게
|
||||
할지 같이 정해야 한다.
|
||||
**같이 검토된 대안 — `rawEmit`을 태우고 `nil`을 던지는 안**(사용자 제안 1안):
|
||||
순회가 만든 emit을 **상류가 부르는 것과 같은 내부 진입점(`rawEmit`)** 으로
|
||||
흘려서, 그 노드에 `Gate`가 껴 있으면 자연히 게이트를 타게 하고, 순회가 찾은
|
||||
변경은 여러 소스일 수 있으니 하류엔 `source = nil`을 던진다는 안.
|
||||
**구조 위생 쪽은 채택할 만하다** — 상류 emit과 내부 발생 emit이 같은 진입점을
|
||||
쓰면 게이트가 붙은 노드에서 두 경로가 갈리지 않는다. 다만 **이 문제의 해법으로는
|
||||
위 2안이 더 낫다**:
|
||||
- 막고 있는 게이트는 보통 **순회하는 노드 자신이 아니라 상류**에 있다. 자기
|
||||
`rawEmit`을 태워봐야 상류 게이트는 안 물어보므로 **누출이 그대로 남는다.**
|
||||
- `nil` emit은 받는 쪽마다 **전체 순회를 강제**하고, 그 순회가 다시 count를
|
||||
올리면 같은 "영구 침묵" 문제가 하류에서 재발한다(연쇄).
|
||||
- 2안은 순회가 애초에 emit을 안 하므로 두 문제가 **생기지 않는다.**
|
||||
|
||||
**게이트 문제(=(b)를 택할 때만 남는다).** 게이트가 앞에서 막아둔 emit을,
|
||||
뒤의 `Get()`이 촉발한 순회가 그대로 흘려보낸다. 사용자 판단은 *"gate 의 경우
|
||||
이것 또한 gate 가 emit 하는, 즉 blocker off 되는 시점까지 밀리면 된다"*인데,
|
||||
**그걸 실행할 기제가 간단하지 않다** — 순회하는 노드 `C`는 자기 상류 어딘가에
|
||||
게이트가 걸려 있다는 걸 알 방법이 없다(`sourceList`는 루트 Source로 평탄화돼
|
||||
있어 중간 노드가 안 보인다). 기제 후보는 둘이고 **둘 다 대가가 있다**:
|
||||
- **게이트를 에포크 경계로 만든다** — 게이트 하류의 `sourceList`가 루트
|
||||
Source 대신 **게이트 자신**을 (자기 count와 함께) 들게 한다. 그러면 막혀
|
||||
있는 동안 하류 순회가 변경을 **발견 자체를 못 하므로** 새지 않고, 해제 emit도
|
||||
`source = nil`이 필요 없어진다(게이트가 곧 그 소스다). **대가가 크다** —
|
||||
하류 `Get()`이 막힌 값을 못 보게 되어 `base/blocker-plan.md`의 확정 계약
|
||||
(`:Get()`엔 영향 없음)이 **뒤집힌다.**
|
||||
- **해제 emit이 `source = nil`을 싣는다** — 게이트가 풀 때 내보내는 emit은
|
||||
여러 소스가 섞여 있어 지목이 안 되므로, `nil`을 싣고 받는 쪽이 전체 목록을
|
||||
확인한다. 새는 것 자체는 못 막고, 새고 난 뒤 정합성만 맞춘다.
|
||||
|
||||
**에이전트 권고: (c).** 사용자가 발견한 문제의 원인이 정확히 "순회가 count를
|
||||
올리는 것"이므로 **그것만 안 하면** 통지 죽음도, 게이트 누출도, `nil` emit
|
||||
규약도, 계약 반전도 전부 안 생긴다. 가장 적게 건드리는 해법이다.
|
||||
`OffWithoutEmit` 캐비엇 하나만 같이 정하면 된다.
|
||||
|
||||
**다만 사용자 판단은 "중간에 게이트가 끼는 건 원래 있던 문제이고, 게이트는
|
||||
보통 최종단에 쓰므로 실질적으로 의미 없어 보인다"**로 이미 나와 있다 — 즉
|
||||
(b)를 택해도 채택을 막는 요소는 아니다.
|
||||
**부수 확인**: `blocker:OffWithoutEmit()`처럼 emit 없이 푸는 경로에서
|
||||
`sourceEmitMap`이 뒤에 남아도, 그 소스의 **다음 진짜 emit**이 위 1번(둘 다
|
||||
다름)으로 걸려 정상화된다 — 별도 조치가 필요 없다.
|
||||
|
||||
4. **`:Emit()`(값을 제자리에서 mutate하고 알리는 경로)** — count만 올리면 되므로
|
||||
그대로 동작한다. 확인만.
|
||||
|
|
@ -231,9 +226,10 @@ Ref<T> 로 변환해주는 등"*. 즉 "항상 최신 값을 필드로 들고 있
|
|||
- **채택 시 같이 확정되는 것: 중복 통지도 접는다**(§3) — 그러면 다이아몬드에서
|
||||
값도 통지도 한 번씩만 간다. **[2026-08-21 정정]** 여기 있던 "노드가
|
||||
`seen`/`computedAt` 두 카운트를 들어야 한다"는 추가 요구는 **철회**됐고,
|
||||
대신 확정할 것은 **순회가 발견한 변경의 처분**(§5의 3번) — 에이전트 권고는
|
||||
"순회는 count를 안 올리고 `rawInvalid`만 세운다"이고, 그러면 `nil` emit
|
||||
규약도 계약 반전도 필요 없어진다.
|
||||
**[2026-08-21 갱신]** 대신 확정된 것은 **테이블 둘**(`sourceCountMap` /
|
||||
`sourceEmitMap`)이다 — 순회가 값만 앞당기고 통지는 상류 emit을 기다린다.
|
||||
그래서 `nil` emit 규약도, 게이트를 에포크 경계로 만드는 계약 반전도 필요
|
||||
없다(§5의 3번).
|
||||
- 실측은 `luau-test`에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서
|
||||
(a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면
|
||||
사라지는지 대조.
|
||||
|
|
|
|||
|
|
@ -246,3 +246,17 @@ count가 안 따라잡는 것. 또 (b)에서 "게이트까지 민다"를 실제
|
|||
됨), `Blocker` 배선 문제는 이미 확정된 `state:Block(blocker)` 메소드로
|
||||
자동 해소, `__call`은 안 씀(사용자 선호 + Luau 함수 타입 자리 통과 여부
|
||||
불확실). `research/gate-primitive.md`의 2번이 해소로 갱신됨.
|
||||
|
||||
## 10. 후속 — 순회 처분을 테이블 둘로 확정 (같은 날)
|
||||
|
||||
9절에서 열어둔 (b)/(c)를 사용자가 **제3안으로 닫았다**: 판정 기준을 둘로
|
||||
나눠 `sourceCountMap`(값 유효성, 순회가 앞당겨 올림)과 `sourceEmitMap`(전파
|
||||
dedup, 상류의 진짜 emit을 기다림)을 따로 둔다. 순회는 emit을 안 하므로
|
||||
게이트 누출이 아예 안 생기고, 뒤늦은 emit은 "count는 같은데 emit 기록이
|
||||
다름"으로 걸려 정상 전파된다. (c)의 유일한 약점(`Get()`마다 재계산)도
|
||||
사라진다 — 순회가 `sourceCountMap`을 실제로 올리기 때문. 같이 나온
|
||||
`rawEmit`+`nil` 안은 구조 위생(상류 emit과 내부 emit의 진입점 통일)만
|
||||
살리고 해법으로는 안 쓴다(막는 게이트는 보통 상류에 있어 자기 `rawEmit`을
|
||||
태워도 누출이 남고, `nil` emit은 하류마다 전체 순회를 강제해 연쇄한다).
|
||||
M절에서 철회했던 두-카운트 분리가 **다른 근거로 되살아난** 셈이다.
|
||||
이제 이 안은 기제가 다 정해졌고 **남은 건 채택 여부 자체**다.
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
- [ ] **[2026-08-21 5라운드 — 착수 전 확인]** State 재계산 판정을 "소스
|
||||
에포크 비교"로 바꿀지가 **미정인 채 열려 있다**(`research/state-epoch-validation.md`,
|
||||
`question.md` 3번). 채택하면 **State 내부 표현이 바뀌므로**(노드가
|
||||
루트 Source별 count를 들고, `Get`이 상류 에포크를 검증)
|
||||
루트 Source별 count 테이블 둘을 들고, `Get`이 상류 에포크를 검증)
|
||||
아래 `Source.luau`/`State.luau`를 짜기 **전에** 결론이 나야 한다 —
|
||||
그 문서 자신이 "M3 뒤로 미루면 되돌리는 비용이 크다"고 경고한다.
|
||||
- [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를
|
||||
|
|
|
|||
Loading…
Reference in a new issue