quad/.claude/base/gate-plan.md
qwreey 930e45bfad
design: 게이트 통과/유보 미구분으로 단순화 + 새 노드 두 맵 비대칭 초기화 확정
1) 게이트는 통과와 유보를 구분하지 않는다. 상류 emit이 오면 정책 실행 전에
   무조건 withheld[source] = true 로 넣고, 정책이 emit()을 부르면 게이트가
   자기를 출처로 전파한 뒤(동기) table.clear 한다. 그냥 통과시킬 때도 상류
   출처를 넘기지 않고 언제나 자기를 낸다 — 하류가 보는 차이는 집합 원소가
   하나냐 여럿이냐뿐이고 판정 규칙은 같다. 그래서 직전 라운드에 넣었던
   "정책이 그 자리에서 emit()을 불렀는지 노드가 되짚는다"는 감지 로직이
   통째로 불필요해졌다.

2) 새 노드의 두 맵은 비대칭으로 초기화한다.
   - sourceEmitMap: 비운다. nil ~= source.count 라 어떤 emit도 "처음 보는
     것"으로 걸리고, 새 노드는 실제로 emit을 받아본 적이 없으므로 그게 맞다.
   - sourceCountMap: 상류에서 전부 끌어와 실제 count로 채우고 rawInvalid를
     true로 둔다. 순회가 훑을 목록이 곧 이 맵이라 비워두면 "훑을 게 없으니
     유효하다"로 오판한다 — 여기는 lazy할 수 없고 "내가 뭘 추적하는가"가
     필요하다.
   그래서 :With 병합 규칙은 필요 없어졌다(생성 시점 라이브 count로 통일되므로
   두 상류가 같은 소스에 다른 count를 들 일이 없다). /code-review Med-3이
   열어둔 (b)/(c)가 이걸로 전부 닫혔다.

처리 전량은 round5-followup.md의 R절. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 21:57:45 +09:00

13 KiB

Gate — emit을 가로채는 게이트 노드 (2026-08-21 확정)

상태: 표면 확정. Blocker/Debounce/Throttle이 공유하는 게이팅 메커니즘을 state:Gate(setup) 메소드로 확정했다 — "Gate 는 따로 프리미티브 없이 state:Gate( (emit) -> ()->() ) 처럼 선언되고 마치 Compute 처럼 GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부 배선을 따른다 ← 동의합니다 해당 방법대로 확정하면 됩니다." 구현은 M2("게이팅 먼저" 결정, ROADMAP.md). 남은 것은 아래 "아직 안 정한 것"의 생명주기·재진입 계약과 M2 범위뿐/code-review high가 잡았던 4번(유보된 emit이 싣는 출처)은 같은 날 emit(self) + 흡수 집합으로 닫혔고, setup 시그니처는 안 바뀌었다.

⚠️ 처음 방향이 한 번 바뀌었다. 신설 당시엔 *"공용 Gate 프리미티브를 꺼내고 Blocker가 그걸 컴포지션한다"*였는데, 확정된 형태는 프리미티브를 따로 안 만들고 State 메소드 하나로 끝낸다이다 — "Gate 프리미티브를 만들고 Blocker 가 컴포지션 하는걸 생각했는데, 그럴 필요가 없네요. :Gate 는 또 Apply 에서 쓸만한 표면을 주기도 하구요." 아래 본문 중 "공개 프리미티브로 꺼낸다"류 서술은 그 이전 시점 표현이니 이 배너 기준으로 읽을 것.

한 줄: Blocker가 쓰던 "게이티드 State 노드"를 한 겹 일반화해서, 상류 emit을 가로채 내려보낼지 말지를 정책이 정하는 노드를 state:Gate(setup) 공개 메소드로 낸다(탑레벨 생성자가 아니라 — 위 배너 참고). Blocker/Debounce/Throttle이 그 위에 얹히는 서로 다른 정책이 된다.

왜 지금인가 — 두 갈래가 같은 자리를 가리켰다

  1. CR-3(마일스톤 순서)Dispatch.drive의 배치 등록이 Blocker 게이팅을 전제하므로 M2가 M3의 Blocker.luau에 구조적으로 의존한다. 사용자 결정: 게이팅을 먼저 만든다.
  2. DT-4(Debounce/Throttle) — 공개 Blocker API 위에는 시간 기반 게이트를 못 얹는다. base/debounce-throttle-plan.md가 이미 "게이티드 노드를 내부 공용 Gate로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, M3에서 Blocker를 만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다.

사용자 논거(DT-4) — Blocker + Observer 조합으로는 왜 안 되는가: "스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer 가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가 되면 이를 전혀 못 지키기 때문." → 게이트가 emit 경로 자체에 끼어들어야 하고, 바깥에서 관측만 해서는 순서를 보장할 수 없다.

공개 여부: 사용자 판단 — "이 API가 비공개일 이유는 없어보인다." 즉 내부 배관이 아니라 공개 표면으로 낸다. [2026-08-21] 다만 그 표면은 탑레벨 프리미티브가 아니라 State 메소드 :Gate 다(아래 2번).

제안된 모양 (사용자 스케치 그대로)

Gate(function(emit)
    -- 이 `emit`은 setup 밖으로 캡처해 **언제든** 부를 수 있다(타이머 콜백 등).
    return function()
        -- 상류 emit이 도착할 때마다 호출된다.
        -- 여기서 `emit()`을 부를지 말지는 정책 마음.
    end
end)
  • setup(emit) -> onUpstreamEmit 2단 구조. 바깥 함수는 게이트 인스턴스가 만들어질 때 1회, 반환된 함수는 상류 emit마다.
  • Blocker는 이 위의 정책 하나가 된다 — "켜져 있으면 emit()을 안 부르고 플래그만 세워두고, 꺼질 때 한 번 부른다"(HasBlockedEmit이 그 플래그).
  • Debounce/Throttle도 정책 — 타이머를 걸고 창이 끝날 때 emit().

아직 안 정한 것 (사용자 판단 필요)

  1. [2026-08-21 해소] 이름 — Gate. 원래 걸림돌은 *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다"*였는데, 탑레벨 프리미티브를 안 만들기로 하면서 문제 자체가 사라졌다 — 이름이 놓이는 자리가 Blocker() 같은 생성자가 아니라 state:Gate(...) 메소드이고, 메소드 자리에서 Gate:With/:Compute/:Observer와 나란히 자연스럽다. 노드 타입 이름은 ComputeNode와 짝을 맞춘 GateNode.

  2. [2026-08-21 해소] :Apply가 아니라 State의 메소드다 — state:Gate(setup). 확정 형태:

    -- setup: (emit: () -> ()) -> (onUpstreamEmit: () -> ())
    local gated = state:Gate(setup)   -- ComputeNode처럼 GateNode를 하나 만든다
    

    Blocker그 위에 얹히는 별개 프리미티브로, state:Block(blocker)가 내부에서 이 배선을 그대로 쓴다. 탑레벨 Gate(...) 생성자는 안 만든다. :Gate가 메소드라고 :Apply와 배타적인 것도 아니다 — 사용자 지적대로 ":Gate 는 또 Apply 에서 쓸만한 표면을 주기도" 하므로, Debounce{...} 같은 유저랜드 팩토리가 내부에서 s:Gate(policy)를 부르는 형태로 state:Apply(Debounce{...}) 관용구가 그대로 성립한다.

    사용자 확정: "gate 는 apply 불가하다고 판단함. 순수 슈가가 아니기 때문, state 의 전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."

    • 정확한 경계: Applyfactory(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. [2026-08-21 확정] Get()과의 관계 — 값이 아니라 통지만 막는다. Blocker가 이미 그렇고(base/blocker-plan.md의 ":Get()엔 영향 없음"), Debounce/Throttle도 emit-gate로 확정돼 있어(base/debounce-throttle-plan.md §4) Gate도 같은 계약으로 통일한다. 공개 계약 문구: "게이트를 통과하지 않은 값도 :Get()으로는 보인다." base/state-epoch-plan.md가 이 계약에 의존한다 — 그 문서 §5의 3번이 "게이트를 에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 뒤집지 않기 위해서다.

  4. [2026-08-21 해소] 게이트가 유보했다 내보내는 emit — emit(self) + 흡수 집합. 문제는 실재했다: 확정 setup(emit: () -> ()) -> (() -> ())라 양쪽 다 source를 안 받는데 base/state-epoch-plan.md의 수신 규칙은 전부 [source] 키로 판정하므로, blocker:Off()가 묶어뒀던 배치 emit이 아무 원천도 못 지목한 채 도착해 하류에서 삼켜진다(2026-08-21 /code-review high 발견).

    확정 기제(사용자 안, 에이전트가 냈던 (a) nil 전체 확인 / (b) 소스마다 개별 emit은 기각 — 각각 O(1) 판정을 깨거나 Blocker의 "정확히 1회"를 깬다):

    • GateNode자기를 거쳐간 소스 집합을 들고 있는다 — withheld : { [source] : true }.
    • [2026-08-21 단순화] 통과와 유보를 구분하지 않는다. 상류 emit이 오면 정책을 실행하기 전에 무조건 그 출처를 withheld에 넣고, 그 다음 정책을 실행한다. 정책이 emit()을 부르면 게이트가 자기 자신을 출처로 하류에 전파하고, 전파는 동기이므로 반환된 뒤에 table.clear(withheld) 한다. 정책이 안 부르면 집합에 그대로 쌓인다.
    • 그래서 게이트는 언제나 자기를 출처로 낸다 — 그냥 통과시킬 때도 상류 출처를 그대로 넘기지 않는다. 사용자: "후행 노드들은 한개가 지연된거로 생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가 없습니다." 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정 규칙은 완전히 같다.
    • 하류는 출처가 GateNode면 그 집합을 순회하며 각 소스에 평소 규칙(1~3)을 적용하고, 하나라도 걸리면 받은 출처를 그대로 더 아래로 넘긴다 (state-epoch-plan.md §2).
    • OffWithoutEmit()로 emit 없이 풀어도 문제가 없다 — 하류의 sourceEmitMap이 뒤에 남지만, 그 소스의 다음 진짜 emit이 규칙 1/2로 걸려 스스로 낫는다.

    그래서 setup 시그니처는 안 바뀐다. 집합을 채우는 건 정책이 아니라 노드이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화). 정책은 소스를 몰라도 되고, Throttle처럼 나중에 타이머에서 emit()을 부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다.

  5. 생명주기. 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 — 지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적, debounce-throttle-plan.md 5-4). Gate 자체에 Flush/Cancel 같은 표면을 둘지, 그건 정책(Debounce)만의 것으로 둘지.

  6. 재진입. Blocker의 "재진입 의도적 미지원"(blocker-plan.md)이 Gate 레벨의 계약으로 올라가는지 — 즉 onUpstreamEmit 안에서 같은 게이트의 emit()을 재귀적으로 부르는 경우.

  7. 소비자가 하나 더 있다 — Effect(fn, ...deps)의 최초 1회 억제. 2026-08-21 5라운드 C-6에서 확정된 다중 의존성 Effect는, 의존성마다 구독을 걸면 각 구독의 "등록 즉시 1회 실행"이 N번 발화하므로 설치 구간 동안 발화를 눌러뒀다가 마지막에 한 번만 실행해야 한다(base/effect-plan.md의 그 절). 즉 Gate(또는 Blocker의 직접 사용)가 "설치 구간을 감싸 최초 발화를 한 번으로 접는" 용례까지 커버해야 한다 — 설계할 때 이 소비자를 같이 볼 것.

  8. M2 범위. M2에 Gate만 넣고 Blocker는 M3에 그대로 둘지, 아니면 Blocker까지 같이 앞당길지. Dispatch.drive의 배치 등록이 실제로 쓰는 건 blocker:On()/OffWithoutEmit()/IsOn()이므로(배치 게이팅 절), 최소한 그 세 메서드가 도는 형태까지는 M2에 필요하다.

관련 문서

  • base/blocker-plan.md — 현행 Blocker 확정(이 문서가 일반화하려는 대상).
  • base/debounce-throttle-plan.md — "공개 Blocker API 위엔 못 얹음" 절이 Gate 일반화를 처음 권고한 자리, 그리고 정책 쪽 설계 전량.
  • base/dispatch-core-plan.md — "배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 M2가 실제로 요구하는 표면.
  • ROADMAP.md M2/M3.