diff --git a/.claude/question.md b/.claude/question.md index ee26668..fee1f6d 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -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()` 때 비교해 재계산 여부를 정한다. diff --git a/.claude/research/gate-primitive.md b/.claude/research/gate-primitive.md index 3670c17..791415c 100644 --- a/.claude/research/gate-primitive.md +++ b/.claude/research/gate-primitive.md @@ -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) -> 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`도 **값이 아니라 통지만 막는다**로 통일하는 게 맞는지 확인 필요 — diff --git a/.claude/research/state-epoch-validation.md b/.claude/research/state-epoch-validation.md index b97187d..5ed51ad 100644 --- a/.claude/research/state-epoch-validation.md +++ b/.claude/research/state-epoch-validation.md @@ -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 로 변환해주는 등"*. 즉 "항상 최신 값을 필드로 들고 있 - **채택 시 같이 확정되는 것: 중복 통지도 접는다**(§3) — 그러면 다이아몬드에서 값도 통지도 한 번씩만 간다. **[2026-08-21 정정]** 여기 있던 "노드가 `seen`/`computedAt` 두 카운트를 들어야 한다"는 추가 요구는 **철회**됐고, - 대신 확정할 것은 **게이트 해제 emit의 `source = nil` 규약**(§5의 3번)이다. + 대신 확정할 것은 **순회가 발견한 변경의 처분**(§5의 3번) — 에이전트 권고는 + "순회는 count를 안 올리고 `rawInvalid`만 세운다"이고, 그러면 `nil` emit + 규약도 계약 반전도 필요 없어진다. - 실측은 `luau-test`에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서 (a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 사라지는지 대조. diff --git a/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md b/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md index f165d0a..51c2a4a 100644 --- a/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md +++ b/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md @@ -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번이 해소로 갱신됨.