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

View file

@ -1,6 +1,7 @@
# `Gate` — emit을 가로채는 공용 게이트 노드 (2026-08-21 신설) # `Gate` — emit을 가로채는 공용 게이트 노드 (2026-08-21 신설)
**상태**: research — **방향은 사용자 확정, 정확한 표면이 미정. **상태**: research — **방향은 사용자 확정, 정확한 표면이 미정(단 [2026-08-21]
"`:Apply`가 아니라 State 메소드"까지는 확정됨 — 아래 2번).
[2026-08-21] 사용자 지시로 설계 자체는 다음 세션으로 미룸** — *"고칠것이 많으므로 [2026-08-21] 사용자 지시로 설계 자체는 다음 세션으로 미룸** — *"고칠것이 많으므로
Gate 는 다음 세션에 다루겠음. 해당 부분은 정정이 아니고 추가이고, 새 인터페이스를 Gate 는 다음 세션에 다루겠음. 해당 부분은 정정이 아니고 추가이고, 새 인터페이스를
고민해야하므로 해결해야할 일로 남겨두길 바람. 단지 지금 세션 상 지식만 이전될 수 고민해야하므로 해결해야할 일로 남겨두길 바람. 단지 지금 세션 상 지식만 이전될 수
@ -62,11 +63,32 @@ end)
(`Source`/`Ref`/`Slot`/`Tween`도 전부 `-er` 없는 명사). 대안 후보: (`Source`/`Ref`/`Slot`/`Tween`도 전부 `-er` 없는 명사). 대안 후보:
`Valve`(밸브 — 흐름 제어라는 뜻은 더 정확하지만 코퍼스 어휘와 멀다), `Valve`(밸브 — 흐름 제어라는 뜻은 더 정확하지만 코퍼스 어휘와 멀다),
`Relay`(전기 릴레이 — "받아서 다시 보낸다"는 뜻은 맞으나 "중계"로 오독 여지). `Relay`(전기 릴레이 — "받아서 다시 보낸다"는 뜻은 맞으나 "중계"로 오독 여지).
2. **`:Apply` 팩토리인가, 독립 생성자인가.** `Debounce`/`Throttle`이 2. **[2026-08-21 해소] `:Apply`가 아니라 State의 메소드다 — `state:Gate(setup)`.**
`state:Apply(Debounce{...})` 관용구로 확정돼 있으므로 `state:Apply(Gate(setup))` **사용자 확정**: *"gate 는 apply 불가하다고 판단함. 순수 슈가가 아니기 때문,
자연스럽다. 그런데 `Blocker()`는 **여러 state에 공유되는 외부 객체**라 모양이 state 의 전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."*
다르다 — `Blocker``Gate` 위에 어떻게 얹히는지(`blocker`가 각 gated state마다 - **정확한 경계**: `Apply``factory(self)`일 뿐이라(`base/source-state-plan.md`의
`Gate`를 하나씩 만들어 자기 정책을 심는 형태?)를 같이 정해야 한다. "`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`), 3. **`Get()`과의 관계.** `Blocker``:Get()`에 영향이 없고(`base/blocker-plan.md`),
`Debounce`/`Throttle`도 emit-gate로 확정됐다(`base/debounce-throttle-plan.md` §4). `Debounce`/`Throttle`도 emit-gate로 확정됐다(`base/debounce-throttle-plan.md` §4).
`Gate`도 **값이 아니라 통지만 막는다**로 통일하는 게 맞는지 확인 필요 — `Gate`도 **값이 아니라 통지만 막는다**로 통일하는 게 맞는지 확인 필요 —

View file

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

View file

@ -220,3 +220,29 @@
**해제 emit이 `source = nil`을 싣는 규약**으로 남았다(게이트는 보통 최종단에 **해제 emit이 `source = nil`을 싣는 규약**으로 남았다(게이트는 보통 최종단에
쓰므로 채택을 막지 않는다는 판단). 상세는 그 문서의 §2·§5와 쓰므로 채택을 막지 않는다는 판단). 상세는 그 문서의 §2·§5와
`qa-request/pre-implementation-qa-round5-followup.md`의 M절. `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번이 해소로 갱신됨.