design: 에포크 순회의 count 갱신 문제 정리 + Gate는 :Apply가 아니라 State 메소드로 확정

(1) research/state-epoch-validation.md §5-3 재작성. 사용자 지적대로 순회가
count를 올리면 뒤늦게 온 진짜 emit이 삼켜져 하류 통지가 영구히 죽는다
(2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열). 두 해법을
대조로 남김 — (b) 순회도 emit(사용자 제안, 게이트 누출이 남고 그 누출을
막을 기제가 둘 다 대가가 큼: 에포크 경계는 blocker의 ":Get()엔 영향 없음"
확정 계약을 뒤집고, source = nil 규약은 사후 정합성만 맞춤) vs
(c) 순회는 rawInvalid만 세우고 count는 안 올림(에이전트 권고 — 원인만
제거하므로 누출도 nil 규약도 계약 반전도 안 생김, 대가는 emit 도착 전까지
Get마다 재계산과 OffWithoutEmit 캐비엇). 미결로 남김.

(2) research/gate-primitive.md 2번 해소. 사용자 확정으로 Gate는
state:Gate(setup) 메소드다. 경계는 "Apply가 노드를 못 만든다"가 아니라
"프리미티브는 메소드 / 유저랜드 조합 팩토리는 :Apply". 그래서
Debounce/Throttle의 Apply 관용구는 유지되고, Blocker 배선은 이미 확정된
state:Block(blocker)가 내부에서 :Gate를 부르는 것으로 자동 해소되며,
__call은 쓰지 않는다.

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:
qwreey 2026-08-21 20:24:51 +09:00
parent 4c395cd383
commit 9627558046
Signed by: qwreey
GPG key ID: D28DB79297A214BD
4 changed files with 118 additions and 33 deletions

View file

@ -213,9 +213,13 @@
**공개 API로 낸다**(사용자: *"이 API가 비공개일 이유는 없어보인다"*). 남은 것 —
(a) **이름**(사용자: *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의
문제"*, 에이전트 권고는 `Gate` 그대로 — `gate`는 이미 장치를 가리키는 명사),
(b) `:Apply` 팩토리인지 독립 생성자인지, (c) `Blocker`가 그 위에 어떻게
얹히는지, (d) M2에 `Gate`만 넣을지 `Blocker`까지 넣을지. 상세는
`research/gate-primitive.md`.
(b) M2에 `Gate`만 넣을지 `Blocker`까지 넣을지, 그리고 생명주기/재진입 계약.
**[2026-08-21 해소]** 여기 있던 "`:Apply` 팩토리인가"와 "`Blocker`가 그 위에
어떻게 얹히는가"는 닫혔다 — **사용자 확정으로 `Gate``:Apply`가 아니라
`:With`류 State 메소드**(*"state 의 전파를 손대는 작업이라 with 처럼 다른
노드가 나는게 맞음"*)이고, 그러면 `Blocker`는 이미 확정된
`state:Block(blocker)` 메소드가 내부에서 그걸 부르면 되므로 배선 문제 자체가
없어진다. 상세는 `research/gate-primitive.md`.
- **⭐ [신설, 2026-08-21 구현 전 QA 5라운드] State 재계산 판정을 "소스 에포크
비교"로 바꿀지 — M3 착수 전 필요.** 사용자 제안: 각 State가 자기 상류 루트
`Source`들의 카운트를 들고 있다가 `Get()` 때 비교해 재계산 여부를 정한다.

View file

@ -1,6 +1,7 @@
# `Gate` — emit을 가로채는 공용 게이트 노드 (2026-08-21 신설)
**상태**: research — **방향은 사용자 확정, 정확한 표면이 미정.
**상태**: research — **방향은 사용자 확정, 정확한 표면이 미정(단 [2026-08-21]
"`:Apply`가 아니라 State 메소드"까지는 확정됨 — 아래 2번).
[2026-08-21] 사용자 지시로 설계 자체는 다음 세션으로 미룸** — *"고칠것이 많으므로
Gate 는 다음 세션에 다루겠음. 해당 부분은 정정이 아니고 추가이고, 새 인터페이스를
고민해야하므로 해결해야할 일로 남겨두길 바람. 단지 지금 세션 상 지식만 이전될 수
@ -62,11 +63,32 @@ end)
(`Source`/`Ref`/`Slot`/`Tween`도 전부 `-er` 없는 명사). 대안 후보:
`Valve`(밸브 — 흐름 제어라는 뜻은 더 정확하지만 코퍼스 어휘와 멀다),
`Relay`(전기 릴레이 — "받아서 다시 보낸다"는 뜻은 맞으나 "중계"로 오독 여지).
2. **`:Apply` 팩토리인가, 독립 생성자인가.** `Debounce`/`Throttle`이
`state:Apply(Debounce{...})` 관용구로 확정돼 있으므로 `state:Apply(Gate(setup))`
자연스럽다. 그런데 `Blocker()`는 **여러 state에 공유되는 외부 객체**라 모양이
다르다 — `Blocker``Gate` 위에 어떻게 얹히는지(`blocker`가 각 gated state마다
`Gate`를 하나씩 만들어 자기 정책을 심는 형태?)를 같이 정해야 한다.
2. **[2026-08-21 해소] `:Apply`가 아니라 State의 메소드다 — `state:Gate(setup)`.**
**사용자 확정**: *"gate 는 apply 불가하다고 판단함. 순수 슈가가 아니기 때문,
state 의 전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."*
- **정확한 경계**: `Apply``factory(self)`일 뿐이라(`base/source-state-plan.md`의
"`state:Apply(factory)`" 절) 팩토리가 노드를 만드는 것 **자체는** 금지가
아니다 — 확정 예시의 `capAt(100)``:With` 노드를 만든다. 갈리는 지점은
**누가 프리미티브인가**다: `:With`/`:Compute`처럼 **전파 경로에 새 종류의
노드를 끼우는 것은 State의 메소드**, 그 프리미티브들을 조합한 **유저랜드
팩토리는 `:Apply`**. `Gate`는 전자다.
- **그래서 `Debounce`/`Throttle`은 `:Apply` 그대로 둔다**
`Debounce{...}`가 돌려주는 팩토리가 내부에서 `s:Gate(policy)`를 부르면
되므로 `base/debounce-throttle-plan.md`의 확정 관용구는 안 건드려도 된다.
- **`Blocker` 배선 문제도 같이 사라진다.** `base/blocker-plan.md`가 이미
**`state:Block(blocker) -> state`(새 gated state 반환)** 라는 **메소드**로
확정해뒀으므로, `Block`이 내부에서 `self:Gate(blocker의 정책)`을 부르는
것으로 끝난다 — `blocker` 객체를 `Apply`에 넘길 일이 없다.
- **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고,
타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State<T>) -> U`
함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력).
`Apply`를 쓸 이유 자체가 없어졌으므로 확인할 필요도 없어졌지만, 혹시
되살아나면 `luau-test` 스파이크 한 개로 판정할 것.
- **2단 구조는 그대로 유효하다** — 사용자 관찰(*"Gate 의 callback 으로 얻어진
emit과, 리턴해낸 클로저가 호출되는걸로 배선은 가능"*) 대로, 바깥 함수가
**그 노드의 `emit`을 캡처**하고 반환 클로저가 상류 emit마다 정책을 태운다.
`Blocker`처럼 **여러 노드가 공유하는 정책**은 공유 상태를 바깥 객체가 들고,
노드별 `emit`만 2단 구조로 받아 등록하면 된다.
3. **`Get()`과의 관계.** `Blocker``:Get()`에 영향이 없고(`base/blocker-plan.md`),
`Debounce`/`Throttle`도 emit-gate로 확정됐다(`base/debounce-throttle-plan.md` §4).
`Gate`도 **값이 아니라 통지만 막는다**로 통일하는 게 맞는지 확인 필요 —

View file

@ -62,7 +62,9 @@ State
(이미 확정이라 더 알아낼 게 없다).
- `rawInvalid == false`**그때만 `sourceList`를 훑는다.** 목적은 하나뿐 —
**못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에 막혀 있었다거나).
다른 항목이 발견되면 count를 갱신하고 `rawInvalid = true`로 만든다.
다른 항목이 발견되면 `rawInvalid = true`로 만든다.
**⚠️ 이때 count를 갱신할지는 열려 있다 — §5의 3번이 소스**(갱신하면 나중에
도착하는 진짜 emit이 삼켜져 하류가 영영 통지를 못 받는다).
- **[2026-08-21 사용자 정정]** 앞선 정리는 이 순회 조건을 `rawInvalid == true`
일 때로 적었는데 **반대**다.
- 재계산이 끝나면 `rawInvalid = false`. count는 emit을 통과시킬 때 이미
@ -144,32 +146,61 @@ count 와 source 를 ref해두는 구현은 문제가 없어보인다. 단순 ra
2. **동적 의존성.** 조건에 따라 다른 상류를 읽는 계산이면 `sourceList`
보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다.
그대로 감수할지 확인.
3. **⭐ [2026-08-21 확장] 순회로 발견한 변경을 뒤로 emit 할 것인가 —
그리고 `Blocker`/`Gate`와의 상호작용.**
`rawInvalid == false`에서 순회를 돌다 "못 받은 emit"을 발견해
`rawInvalid = true`가 됐을 때, **그 자리에서 뒤로 emit 하는가**가 열려 있다.
두 안:
- **(a) 지연** — 아무것도 안 내려보내고, 나중에 그 소스의 진짜 emit이
도착할 때 정상 경로로 처리한다.
- **(b) 즉시** — 재계산하라는 지시를 받았고 invalid로 판정됐으니 그때 emit.
3. **⭐⭐ [2026-08-21 재확장] 순회가 발견한 변경을 어떻게 처분하는가 — 이 안에서
유일하게 안 닫힌 자리.**
사용자가 (b)의 위험을 먼저 의심했다가 **스스로 안전하다고 정정**했다:
`A → {B, C} → D`에서 `B``C`를 요구하다 `C`의 변경분이 흘러 `D`가 다시
깨어나는 것처럼 보이지만, **`D`도 count를 갱신하므로 중복을 삼킨다** —
*"안전히 C가 emit 되어도 되는 상황"*.
**먼저 확정된 것: 순회가 count를 갱신하면 안 된다(또는 갱신하면 반드시
emit해야 한다).** 사용자 지적 — *"순회가 발견한거면, count 를 최신으로
올리기에 나중에 emit 와도 전파가 안 되는 이슈가 생기긴 함."* 실제로 그렇다:
`C`가 순회로 `A`의 변경을 발견해 count를 최신으로 올려두면, 뒤늦게 도착한
`A`의 진짜 emit이 `C`에서 **삼켜지고**(count가 같으므로) `C`의 하류는 그
에포크를 **영영** 못 받는다. 값은 맞지만 통지가 죽는, **2026-08-14에 폐기된
옛 dedup의 "영구 침묵"과 같은 계열의 실패 모드**다
(`archive/invalidate-dedup-propagation-reversed.md`).
남는 건 게이트 쪽이다. 게이트가 **앞에서 막아둔 emit**을, 뒤에서 `Get()`
촉발한 순회가 그대로 흘려보낼 수 있다(*"앞에서 막아뒀는데, 뒤에서 필요하다고
해서 그대로 흘러버릴 가능성"*). 그래서 (b)를 택하더라도 **실제 block이 풀린
뒤의 emit까지는 기다려야 한다**. 그리고 **게이트가 풀 때 내보내는 emit은
여러 소스가 섞인 것**이라 어느 소스가 바뀌었는지 지목할 수 없다 — 사용자
제안은 그 emit이 **`source = nil`을 싣고, `nil`이면 받는 쪽이 전체 목록을
확인**하는 것. 게이트는 `Get()`은 막지 않고 emit만 유보하므로 이게 성립한다.
두 해법이 있다:
- **(b) 순회가 count를 올리고, 그 자리에서 뒤로 emit 한다** — 사용자 제안.
통지가 죽지 않는다. 다이아몬드에서 새는 것도 없다(사용자가 스스로 정정한
대로 `D`도 count를 갱신해 중복을 삼킨다). **남는 문제는 게이트뿐** — 아래.
- **(c) 순회는 `rawInvalid = true`만 세우고 count는 그대로 둔다** — 에이전트
제안. count의 역할을 **"이 에포크를 내가 실제로 전파했는가"** 하나로
유지한다. `Get()``rawInvalid` 덕에 신선하고, 나중에 진짜 emit이 오면
count가 다르므로 **정상 경로로** 하류까지 전파된다. **게이트를 새게 하는
경로가 아예 생기지 않는다**(순회는 절대 emit하지 않으므로).
- 대가: emit이 도착하기 전까지 **`Get()`마다 다시 재계산**한다(매번 순회가
같은 차이를 재발견하므로). 일반 경로에선 파동이 같은 틱에 도착하니 창이
아주 좁고, 게이트 경로에선 "묶어놨다가 한 번에 푼다"가 목적이라 그 구간에
`Get()`이 잦을 이유가 없다.
- ⚠️ 단 `blocker:OffWithoutEmit()`처럼 **emit 없이 푸는 경로**에선 count가
영원히 안 따라잡아 그 상태가 무기한 남는다 — 그 경로가 count를 밀어주게
할지 같이 정해야 한다.
**게이트 문제(=(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` 캐비엇 하나만 같이 정하면 된다.
**다만 사용자 판단은 "중간에 게이트가 끼는 건 원래 있던 문제이고, 게이트는
보통 최종단에 쓰므로 실질적으로 의미 없어 보인다"**로 정리됐다 — 즉 이
항목은 **채택을 막는 요소가 아니고**, `nil` emit 규약만 `Gate` 설계
(`research/gate-primitive.md`)와 같이 확정하면 된다.
보통 최종단에 쓰므로 실질적으로 의미 없어 보인다"**로 이미 나와 있다 — 즉
(b)를 택해도 채택을 막는 요소는 아니다.
4. **`:Emit()`(값을 제자리에서 mutate하고 알리는 경로)** — count만 올리면 되므로
그대로 동작한다. 확인만.
5. **weak 키.** `[source] -> count`를 weak-key로 두면 source가 죽을 때 항목이
@ -200,7 +231,9 @@ Ref<T> 로 변환해주는 등"*. 즉 "항상 최신 값을 필드로 들고 있
- **채택 시 같이 확정되는 것: 중복 통지도 접는다**(§3) — 그러면 다이아몬드에서
값도 통지도 한 번씩만 간다. **[2026-08-21 정정]** 여기 있던 "노드가
`seen`/`computedAt` 두 카운트를 들어야 한다"는 추가 요구는 **철회**됐고,
대신 확정할 것은 **게이트 해제 emit의 `source = nil` 규약**(§5의 3번)이다.
대신 확정할 것은 **순회가 발견한 변경의 처분**(§5의 3번) — 에이전트 권고는
"순회는 count를 안 올리고 `rawInvalid`만 세운다"이고, 그러면 `nil` emit
규약도 계약 반전도 필요 없어진다.
- 실측은 `luau-test`에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서
(a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면
사라지는지 대조.

View file

@ -220,3 +220,29 @@
**해제 emit이 `source = nil`을 싣는 규약**으로 남았다(게이트는 보통 최종단에
쓰므로 채택을 막지 않는다는 판단). 상세는 그 문서의 §2·§5와
`qa-request/pre-implementation-qa-round5-followup.md`의 M절.
## 9. 후속 — 순회의 count 갱신 문제 + `Gate``:Apply`가 아니다 (같은 날)
**(1) 순회가 count를 올리면 통지가 죽는다.** 사용자 지적 — 순회로 발견해
count를 최신으로 올려두면 뒤늦게 온 진짜 emit이 삼켜져 하류가 그 에포크를
영영 못 받는다(2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열).
사용자 해법은 **(b) 순회도 emit 한다**이고, 게이트 경우엔 그 emit을 해제
시점까지 민다는 것. 에이전트는 **(c) 순회는 `rawInvalid`만 세우고 count는
안 올린다**를 대안으로 냈다 — 원인이 정확히 "순회가 count를 올리는 것"이라
그것만 안 하면 통지 죽음도 게이트 누출도 `nil` emit 규약도 안 생긴다.
대가는 emit이 올 때까지 `Get()`마다 재계산, 그리고 `OffWithoutEmit` 경로에서
count가 안 따라잡는 것. 또 (b)에서 "게이트까지 민다"를 실제로 구현하려면
순회하는 노드가 상류 게이트를 알아야 하는데 `sourceList`가 루트로 평탄화돼
있어 불가능하고, 유일하게 깔끔한 기제(게이트를 에포크 경계로)는
`blocker-plan.md`의 "`:Get()`엔 영향 없음" 확정 계약을 뒤집는다는 걸 짚었다.
**미결** — `research/state-epoch-validation.md` §5의 3번이 소스.
**(2) `Gate``:Apply`가 아니라 State 메소드.** 사용자 확정 — *"state 의
전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."* 경계를 정확히
적어두면, `Apply`가 노드를 못 만드는 게 아니라(확정 예시 `capAt(100)`
`:With` 노드를 만든다) **프리미티브는 메소드 / 유저랜드 조합 팩토리는
`:Apply`**라는 층위 구분이다. 부수 결론 셋 — `Debounce`/`Throttle`의
`state:Apply(...)` 관용구는 그대로 유효(팩토리가 내부에서 `:Gate`를 부르면
됨), `Blocker` 배선 문제는 이미 확정된 `state:Block(blocker)` 메소드로
자동 해소, `__call`은 안 씀(사용자 선호 + Luau 함수 타입 자리 통과 여부
불확실). `research/gate-primitive.md`의 2번이 해소로 갱신됨.