quad/.claude/session/2026-08-14-08-debounce-throttle-backlog.md
qwreey 623c9316fe
docs: Debounce/Throttle 백로그 신설 + "emit은 항상 전파" base 역전 정정
워크트리(worktree-debounce-throttle-plan)에서 네 라운드로 다듬은 결과를
메인의 3단계 분할 구조에 맞춰 필요한 변경만 이식.

## 신설: research/debounce-throttle-plan.md

- Blocker가 이미 쓰는 게이티드 노드의 릴리스 트리거만 타이머로 바꾼 것.
  공개 Blocker API엔 "상류 신호 도착" 통지가 없어 그 위엔 못 얹음 →
  M3에서 게이트를 공용 Gate로 뺄 것.
- 두 도구의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐.
  공개 생성자 2개 + 내부 구현 1개(초안이 옮겨온 lodash식 maxWait 공식엔
  trailing 통과 직후 이중 발화 버그가 있었음).
- quad-base + 주입 op 2개: setTimeout(func, delay) -> Timeout /
  clearTimeout. Roblox는 task.delay/task.cancel로 배선(인자 순서 반대).
  os.clock()은 Luau 표준 라이브러리라 주입 대상 아님(diff 전용).
  Timeout = { __type_timeout: true, _native: any }.

## 역전: emit은 자기 invalid 상태와 무관하게 항상 전파된다

source-state-plan.md의 "이미 invalid였다면 그 아래로 더 전파하지 않는다"가
확정된 Observer 계약(fn이 :Get()을 안 불러도 됨)과 정면 충돌 — 액면대로면
:Get() 안 하는 Observer는 한 번 울고 영구 침묵. architecture.md가 같은
다이아몬드 문제를 pull-recompute로 설명하는 것과도 어긋나 있었음.

정정 모델: invalid는 캐시 낡음 표시일 뿐, 중복 재계산은 pull-recompute+
캐시가 막고 중복 통지는 안 접음(접으려면 Blocker 같은 명시적 게이트).

- source-state-plan.md: 전파 규칙 재작성, "다이아몬드 의존성은 무엇이
  푸는가" 절 신설, Observer 절 상호 참조. 플래튼 기각/:With 빌더 기각
  근거를 캐시 공유로 재작성(두 결론 유지, 근거 강도는 상승)
- architecture.md, blocker-plan.md(전파를 지연시키는 유일한 요소로 위치
  명문화), comparison-fusion-vide.md, framework-comparison-findings.md
- ROADMAP M0 체크리스트: 확인할 것이 정반대가 됨
- luau-test 05 → rewrite-required/(옛 모델을 통과 상태로 검증 중이었음),
  STATUS.md 개수 동기화(rewrite 6→7, done 14→13)
- audit: 05 행 정정 + "12개 전원 통과"를 액면대로 읽지 말라는 경고
- archive/invalidate-dedup-propagation-reversed.md 신설

doc-check: ERROR 0 / WARN 84(작업 전 85).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 05:22:06 +09:00

24 KiB

2026-08-14 여덟 번째 세션 — Debounce/Throttle 백로그 + "emit은 항상 전파" 정정

[읽기 전 주의 — 경로 표기] 이 세션은 워크트리에서 진행됐고, 그 워크트리는 10cd31b 기준이라 bind/store/state 3단계 분할(같은 날 일곱 번째 세션) 이전이었음. 그래서 아래 서술은 전파 모델이 bind-system-plan.md에 있다고 말하지만, 메인에 옮겨진 지금은 base/source-state-plan.md의 "전파 모델 확정" 절임. 원문 보존 원칙에 따라 본문은 당시 표기 그대로 두고 여기 한 줄로만 짚어둠.

요청: "Blocker와 유사하게 Debounce/Throttle를 만들어야 하는데, 이에 대한 백로그를 짜줘. 너가 일단 다 정의해보고 내가 그 정의를 보고 판단해볼게. 워크트리 하나 파서 작업해."

에이전트가 먼저 전부 정의하고 사용자가 판정하는 모드 — 확정 문서를 만드는 게 아니라 판단 재료를 만드는 세션. 산출물은 research/debounce-throttle-plan.md 하나 + 인덱스 레이어 반영.

워크트리 관련 시행착오 (기록)

EnterWorktree가 기본 설정(worktree.baseRef = fresh)대로 origin/master 에서 브랜치를 땄는데, 이 레포의 origin/masterquad v1 시절 원격의 옛 히스토리.claude/가 통째로 없는 상태로 시작됐음(4824bab Merge pull request #7 ...). 로컬 main(10cd31b)으로 git reset --hard해서 바로잡음. 다음에 워크트리를 팔 때도 같은 일이 생길 것 — 이 레포에서 워크트리를 만들면 항상 로컬 main 기준인지 먼저 확인할 것.

읽은 것

base/blocker-plan.md(게이티드 State의 정확한 계약), base/effect-plan.md, base/lifecycle-pattern.md(GC 위임 원칙/bindLifetime), base/ module-lifecycle-plan.md(백엔드 주입 경로), base/purity-and-effects-plan.md, base/bind-system-plan.md의 온톨로지/전파 모델 절, base/tween-plan.md 헤딩(엔진 종속 프리미티브의 선례), research/operator-sugar-plan.md(이 항목이 원래 매달려 있던 자리), research/additional-primitives-plan.md.

실제로 새로 알아낸 것 세 가지

1. Blocker와 같은 자리, 다른 트리거

blocker-plan.md의 게이티드 노드 계약(블록 중이면 전파 안 하고 HasBlockedEmit만 세팅, 열릴 때 정확히 1회 전파)이 debounce/throttle이 필요로 하는 것과 글자 그대로 같음. 차이는 여는 주체뿐(사용자 :Off() vs 타이머). 그래서 새 전파 메커니즘이 아니라 릴리스 트리거 교체로 정리했고, 구현 권고도 "Blocker 구현 시점에 게이트 노드를 공용으로 빼두라"가 됨.

부수적으로 확인한 것: 공개 Blocker API 위에 얹어서는 못 만듦 — "상류 신호가 지금 도착했다"는 통지가 공개 API에 없어서 타이머를 (재)시작할 시점을 알 방법이 없음. 그래서 내부 훅이 필요하고, 그 훅은 Blocker가 이미 갖고 있는 것과 같은 것.

2. 무효화 dedup 때문에 파생 State 위에선 debounce가 throttle로 퇴화

이게 이번 세션의 진짜 발견. bind-system-plan.md의 확정 규칙 —

신호를 받은 State는 자기 invalid 플래그만 세우고, 이미 invalid였다면 그 아래로 더 전파하지 않는다

— 때문에, 게이트가 파생 State 위에 얹히면 버스트 중 두 번째 이후 신호를 아예 못 받음(중간 노드가 이미 invalid라 삼킴). 결과적으로 "마지막 변경 후 T초"가 아니라 "첫 변경 후 T초"가 되어 주기 T의 throttle처럼 동작함. 값 자체는 커밋 시점에 pull하므로 항상 최신 — 정확성 버그가 아니라 타이밍 의미론이 조용히 바뀌는 종류라 오히려 더 안 보임.

Source 바로 위에 얹으면 문제없음(Source는 invalid 개념이 없어 매 :Set()마다 신호). 그래서 규칙이 나옴 — Debounce/Throttle은 Source에 가깝게, Blocker는 파이프라인 끝에. 정확한 거울상이라 문서화하기 좋음.

"게이트가 신호 받을 때마다 상류를 :Get()해서 체인을 되살린다"는 우회는 laziness를 정면으로 깨고 debounce의 존재 이유와 반대라 기각 권고로 적음.

3. 배치 근거가 operator-sugar-plan.md의 추측과 다름

그 문서는 "타이머가 필요하니 Tween처럼 quad-roblox"로 추정해뒀는데, Tween이 quad-roblox인 진짜 이유는 TweenService라는 엔진 기계(보간 엔진/easing/per-instance 객체)에 의존해서고, debounce가 엔진에서 필요로 하는 건 시계 하나뿐임. 14차 세션이 Tag/Attribute에서 내린 판단 (부기 알고리즘은 quad-base, 엔진에 손대는 한 줄만 주입)과 정확히 같은 상황이라 quad-base + 주입 op 2개를 권고로 뒤집음.

주입 op는 (당시 가칭) scheduleAfter(seconds, fn) -> handle / cancelScheduled(handle) 둘 — 2차 리뷰에서 사용자가 setTimeout(func, delay) / clearTimeout(handle)로 지정, 인자 순서도 함수 먼저로 확정. now()는 일부러 뺐음(이 판단도 2차 리뷰에서 정정됨, 아래 참고) — Debounce/Throttle/ MaxTime 전부 "창이 끝날 때 콜백"으로 표현돼서 시계 없이 닫힘. 부수 효과로 quad-mock에서 가상 시계 결정론적 테스트가 공짜(이것 자체가 base 배치를 미는 또 하나의 근거).

그 밖에 정리한 것

  • 의미론 두 갈래: (A) emit-gate(Blocker와 동일, :Get()은 최신값) vs (B) value-hold(:Get()도 지연된 값). (B) 권장 — VueUse/RxJS 의미론과 일치하고 Blocker와 역할이 깔끔히 갈림. 확인 과정에서 blocker-plan.md가 인용하는 "Get()은 라이브 레퍼런스를 준다"가 실제로는 store-semantics.md레퍼런스 의미론(테이블을 복사본 아닌 라이브 참조로 준다) 원칙이지 "항상 상류 최신값"이 아니라는 걸 확인 — 그래서 (B)가 확정 원칙과 충돌하지 않음. 다만 그 한 줄이 오해될 여지가 있어 열린 질문 Q7로 남김 (확정 문서라 임의 수정 안 함).
  • ThrottleDebounce 프리셋으로 권고 — Throttle{Time=t} == Debounce{Time=t, Leading=true, Trailing=true, MaxTime=t} 가 lodash의 실제 구현 관계 그대로. quad가 반복해온 "같은 일 하는 두 번째 경로 안 만들기"와 맞음.
  • 이름 위험: Roblox 커뮤니티의 debounce는 재진입 방지 불리언이라 정면 충돌. 그래도 업계 표준 이름 유지 + 문서 경고를 권고(검색/이주 비용). -ed를 안 붙이는 건 코퍼스 규칙으로 이미 결정됨(lazy한 건 원형, ComputeComputed가 아닌 것과 같은 이유) — 열린 질문 아님.
  • GC: 대기 중 타이머가 게이트를 강참조하므로 다운스트림이 다 죽어도 최대 Time초 생존 — 유계·자가치유라 누수 아님. 게이트는 inst에 안 묶인 순수 값 계층이라 bindLifetime 배선 불필요, Relate도 안 써서 두-Relate 상호 순환 위험과 무관. Dispatch 변경 없음.
  • 열린 질문 8개를 문서 12번 절에 번호로 모으고, 그중 사용자 취향이 실제로 갈리는 넷(의미론/제어핸들/이름/파생 State 적용)만 question.md 3번에 요약.

1차 리뷰 라운드 (같은 세션, 사용자가 초안 읽고 지적)

사용자 지적: "스로틀은 '나중에 처리해준다'가 필요한 부분이다. 1초 스로틀에 0.0과 0.1에 누르면 1.0에 0.1 때의 값이 다시 적용돼야 한다. 즉 setTimeout이든 뭐든 태스크가 필요하다. 로블록스는 task.wait/delay가 있지만 다른 엔진은 다를 수 있고, task 자체가 그냥 Luau에는 없다. 기본 구현은 quad-base에 있고 엔진 따라 해당 태스크 부분만 배선하면 되도록 만들어져야 한다 생각함."

  • "디바운스가 정확히 보통 어떤 동작인지 알려달라."

세 가지가 바뀜:

  1. Q5(패키지 경계) 해소 — 사용자가 quad-base + 엔진별 배선으로 확정. 추가로 얻은 근거: 순수 Luau엔 task가 없을 뿐 아니라 이벤트 루프 자체가 없어서 base가 "동작하는 기본 스케줄러"를 제공할 수가 없음 — 엔진 op 3개와 같은 관례대로 미배선 시 명확한 에러를 내는 스텁이어야 함. 그리고 사용자가 짚은 대로 Throttle도 trailing 때문에 이 배선이 똑같이 필요함(스로틀만 타이머 없이 되는 게 아님).

  2. 초안 의사코드의 실제 버그 발견·수정. 사용자의 0.0/0.1 시나리오를 트레이싱하다가, 초안이 lodash식 MaxTime(maxWait) 공식을 그대로 옮긴 탓에 trailing 통과 직후 타이머를 전부 회수해버려 바로 뒤 신호가 "창 밖"으로 판정돼 또 즉시 발화하는 걸 발견(1초 안에 두 번). 고치면서 훨씬 나은 정식화가 나옴 — 디바운스와 스로틀의 차이는 "신호가 창 타이머를 리셋하는가" 한 비트뿐이고, leading/trailing/통과 후 창 재개방은 완전히 동일함. maxWait 트릭 없이 스로틀이 정확히 나오고 이중 발화 구멍도 구조적으로 사라짐. 그래서 구 Q3("Throttle은 Debounce의 프리셋")을 폐기하고 **공개 생성자 2개 + 내부 구현 1개(Reset 파라미터, 비공개)**로 개정. MaxTime은 디바운스 전용 안전장치로 역할 축소 ("신호가 안 끊기면 영원히 발화 안 함"이 디바운스의 정의라서 필요한 것이고, 스로틀은 원래 주기 발화라 무의미).

  3. 3절 발견의 범위 축소. 무효화 dedup 퇴화는 Debounce 겪음 — 스로틀은 창 안에서 "뭔가 바뀌었나" 불리언 하나만 알면 되고, leading 통과가 소비자의 :Get()을 유발해 체인을 되살리므로 사실상 면역. 초안이 둘 다 영향받는 것처럼 써놨던 걸 정정. Q6의 적용 범위도 같이 축소.

디바운스 동작 설명 요청에 대한 답은 문서 1-1절로 들어감(정의 + 타임라인

  • "버스트 중간/끝/무한 연속" 3열 비교표). 핵심은 신호가 끊이지 않으면 디바운스는 영원히 발화하지 않는다는 것이 버그가 아니라 정의 그 자체라는 점 — lodash maxWait의 존재 이유이기도 함.

2차 리뷰 라운드 (같은 세션) — 주입 op 시그니처 확정

사용자가 구현 방식까지 구체적으로 지정: os.clock()으로 버스트 시작 시각을 잡아 maxWait을 처리하거나, 아니면 처음에 maxWait 타이머와 리셋 가능한 타이머 둘을 걸고 변경마다 후자를 재시작하는 식. 그리고 "set/clear timeout 둘 다 quad-base에서 프로바이더가 구현해야 할 사항으로 넣자"setTimeout(func, delay) -> Timeout / clearTimeout(Timeout). Roblox는 task.delay(duration, fn, ...)/ task.cancel(thread)로 배선하고, quad-roblox가 간단한 릴레이션으로 Timeout -> coroutine를 얻어내면 됨.

반영하면서 정리된 것 넷:

  1. os.clock()은 주입 대상이 아님 — 초안 판단 정정. 초안은 "시계가 필요해지면 now()를 세 번째 주입 op로 추가"라고 적어뒀는데, 사용자가 알려준 사실로 무효가 됨 — os.clock()Luau 표준 라이브러리이지 task처럼 Roblox가 얹은 전역이 아니고, Lua 5.x가 리눅스에서 "프로세스가 소비한 CPU 시간"을 주는 것과 달리 Luau는 일관되게 고정밀 값을 줌. 그래서 결론이 깔끔해짐: 주입이 필요한 건 "미래에 실행시키는 능력"뿐, "얼마나 지났나"는 언어가 이미 준다. 덕분에 6-1절의 최적화들(lazy timer, MaxTime 단일 타이머화)이 주입 표면을 안 늘리고 가능해짐. [같은 라운드 재지적]os.clock()"현재 시각"이 아니라 기준점 없는 카운터라 diff 전용 — 절대 시각으로 해석하거나 다른 시간 개념과 비교하면 안 됨. 이 설계는 원래 maxDeadline - os.clock() 같은 남은 시간 계산만 하므로 제약을 안 건드리고, 부수적으로 벽시계 보정(NTP 등)에 영향받지 않는다는 장점까지 있음. 문서 6절에 경고 박스로 명시.

  2. MaxTime을 타이머 1개로. 사용자가 제시한 두 갈래 중 의사코드는 (b)(타이머 둘)를 쓰지만, os.clock()을 그냥 쓸 수 있으니 마감을 min(Time, maxDeadline - os.clock())로 잡으면 한 타이머가 "조용해짐"과 "더 못 기다림" 둘 다를 표현함. MaxTime이 없으면 maxDeadline이 무한대라 min이 항상 Time이 되어 분기 없이 흡수됨. 의사코드는 읽기 쉬운 (b)로 남기고 이 최적화는 구현 권고로 적어둠.

  3. Timeout 타입 — 에이전트가 any를 권했다가 사용자 반론으로 뒤집힘. 에이전트 근거 셋은 (a) bindLifetime(inst: any, value: any) 선례, (b) base 내부 배관이라 타입 안전이 사줄 게 없음, (c) 전용 타입이면 Roblox가 타이머마다 테이블 + Relate 엔트리를 강제당함(디바운스는 신호마다 거는 핫패스)였는데, 사용자가 셋 다 반박:

    • (a)가 핵심 오류bindLifetimeany인 건 거기 진짜로 아무거나 오기 때문이고, setTimeout/clearTimeout자기가 만들어낸 것만 주고받는 닫힌 루프라 성격이 정반대. 같은 선례로 묶을 수 없음.
    • (b)도 뒤집힘 — 닫힌 루프이기 때문에 오히려 clearTimeout(1)타입 에러로 잡아줄 수 있음. any는 그 공짜 검사를 스스로 끄는 것.
    • (c)는 더 나은 구현으로 무력화Relate를 걸 게 아니라 네이티브 핸들을 Timeout 테이블 필드에 직접 넣으면 됨(타입은 캐스트로 맞춤). 릴레이션 층이 사라지고 남는 건 테이블 1개 할당인데, task.delay코루틴을 하나 만드는 호출이라 그 옆에서 노이즈 수준 — 핫패스 논거가 성립 안 함.
    • 런타임에 마커 필드를 넣는 것도 무방하다고 확인해줌.

    확정(후속 한 왕복으로 페이로드 자리까지 합의):

    export type Timeout = {
        __type_timeout: true,  -- 판별 마커. 런타임에도 실제로 넣음
        _native: any,          -- 백엔드 전용 페이로드. base는 절대 안 읽음
    }
    

    _native를 타입에 미리 선언해두는 쪽을 에이전트가 제안하고 사용자가 동의 — 백엔드가 :: any 캐스트 없이 그냥 대입할 수 있고 any 탈출이 필드 하나에 갇혀 경계가 문서화됨(캐스트 방식은 탈출구가 호출 지점마다 흩어져서, 나중에 누가 다른 필드를 더 끼워넣어도 아무도 모르는 게 문제였음 — typing-limits.md가 싫어하는 "조용히 타입 검사 끄기"). _ 접두사는 handle._observer/slot._mountedInst/_fired 관례와 일치. 부수 효과로 "취소 없는 엔진" 스케치도 깔끔해짐 — 그 백엔드는 _native에 coroutine 대신 취소 클로저를 담으면 되고, base는 어느 쪽이든 모름. Brand 편입은 불필요 — Brand는 사용자가 값 종류를 판별하는 용도인데 Timeout은 사용자 표면에 안 나옴.

  4. 취소 없는 엔진 대응이 확인됨. 사용자가 제시한 래핑+유효 플래그 트릭으로 어떤 엔진에서도 clearTimeout을 구현할 수 있으므로, clearTimeout은 base가 백엔드에 요구해도 되는 계약. 단 이 방식은 타이머가 예정대로 깨어나 아무 일도 안 하는 형태라 8절의 "대기 타이머가 게이트를 붙잡는다"는 성질은 그대로 남음(여전히 유계).

setTimeout/clearTimeout이라는 이름을 고른 근거(사용자 명시): task는 표준도 아니고 Luau의 것도 아닌 한 엔진의 것이라, base처럼 "누가 실제로 그려주는지 모르는" 층에 그 어휘를 새기면 특정 백엔드만 특별대우하는 셈이 됨 — 그래서 가장 대중적이고 엔진 중립적인 JS 어휘를 가져옴. (14차 세션이 엔진 op를 addTag/setAttribute로 정할 때 Roblox CollectionService와 웹 className/data-* 양쪽에 걸치는 이름을 고른 것과 같은 결.) 에이전트가 처음엔 이걸 단순 "함정"으로만 적었다가, 사용자가 "내부 배선에서 틀리면 안 되는 건 맞지만 이유는 있었다"고 짚어줘 근거를 같이 기록.

부수적으로 기록한 구현 함정 둘: task.delay는 시간이 먼저setTimeout(func, delay)로 배선할 때 인자가 뒤집힘(중립 이름을 택한 대가이고, 배선 지점이 백엔드당 한 곳뿐이라 감당 가능), 그리고 task.delay...(추가 인자 전달)은 일부러 안 받음 — 게이트 콜백은 게이트당 하나씩 만들어져 재사용되는 안정된 클로저라 varargs로 아낄 할당이 애초에 없음.

3차 리뷰 라운드 (같은 세션) — 3절 전제 붕괴, base 모순 발견

사용자 지적: "emit은 항상 재전파되고. 정확히 저 동작은 Blocker가 하는거야. 아니면 너가 잘못 읽었을지도." — 이 문서 3절("가장 중요한 발견")이 인용하던 bind-system-plan.md의 무효화 dedup 문장에 대해.

원문 확인 결과 인용 자체는 정확했음(분할 전 bind-system-plan.md 708-710행(현 source-state-plan.md), verbatim). 즉 잘못 읽은 게 아니라 그 base 문장 자체가 문제. 확인한 근거 셋:

  1. 다이아몬드 근거가 요구하는 범위를 넘어섬 — 다이아몬드는 한 번의 변경이 여러 경로로 같은 노드에 닿는 것이라 전파 파동 안에서만 접으면 되는데, 문장은 invalid를 시간에 걸쳐 유지되는 상태로 써서 ("이미 invalid였다면") 이후의 모든 변경까지 삼킴.
  2. architecture.md와 어긋남 — 그쪽은 같은 문제를 "pull-recompute (Get() 시점) — Fusion식 eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림"이라고 서술. 즉 중복 평가를 막는 주체는 pull-recompute 자체고, invalid dedup이 아낄 수 있는 건 순회 비용뿐. base 안에서 같은 문제의 해결 주체를 두 문서가 다르게 지목 중이었음.
  3. 확정된 Observer 계약과 정면 충돌 — 같은 파일이 fn에서 :Get()을 안 부르는 걸 명시적으로 허용해뒀는데("Get() 호출 여부를 작성자가 직접 결정하게 열어둔 것"), dedup 문장을 액면대로 적용하면 그런 Observer는 한 번 울고 영구 침묵함. 취향이 아니라 실제 모순.

사용자의 "저건 Blocker가 하는 것"도 정확 — HasBlockedEmit이 바로 그 동작이고 Blocker는 그걸 opt-in 게이트로 제공함. 모든 State에 암묵적으로 심으면 Blocker의 존재 의의가 절반 사라짐.

이 문서에 미친 영향(큼): 3절 전체 철회. 파생 State 위 퇴화 없음, "Source에 가깝게 걸어라" 규칙 폐기(그 "Blocker의 정확한 거울상"이라는 예쁜 대칭도 틀린 전제 위였음), Q6 소멸, 2차 리뷰의 "Throttle은 면역" 범위 축소도 무의미. 두 라운드를 버틴 "가장 중요한 발견"이 세 번째 라운드에 통째로 무너진 사례로 기록해둠 — 확정 문서를 인용할 때 "그 문장이 다른 확정 문장과 모순되지 않는가"까지 확인하지 않으면 발견이 통째로 헛돈다는 교훈.

대신 생긴 것: base 정정 항목 question.md 0-E 신설(당시엔 확정 문서라 안 고치고 승인 대기) — 아래 4차 라운드에서 사용자 확정으로 같은 세션에 해소·전면 반영됨.

4차 리뷰 라운드 (같은 세션) — 사용자 확정 후 코퍼스 전면 정정

사용자가 세 항목에 각각 답하며 모델을 확정:

2(재계산 방지)는 맞음 — 한 곳에서 Get이 되면, invalid하다면 위로 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음 invalid가 꺼짐. 1: emit은 항상 전파함. Blocker나 emit 전파 지연요소만 이를 지연할 수 있음. 3: 맞음, 내가 원래 emit은 항상 전파라 했는데 어떤 엉뚱한 에이전트가 이상한짓 하고 간듯. 다 다시 써야 해.

즉 에이전트가 3차 라운드에서 제시한 두 갈래 중 (a)에 가깝되, "파동 단위 dedup"은 지금 넣지 않고 순수 구현 최적화로만 남기는 쪽. 확정 모델:

  • invalid = "내 캐시가 낡았다"는 표시 하나뿐, 전파 제어 장치 아님.
  • emit은 자기 invalid 상태와 무관하게 항상 전파.
  • 중복 재계산은 pull-recompute + 노드별 캐시가 막음.
  • 중복 통지는 안 접음 — 다이아몬드에서 아래쪽 Observer가 한 사이클에 두 번 우는 건 의도된 동작. 접으려면 Blocker 같은 명시적 게이트.

고친 곳 (코퍼스 전체)

파일 무엇을
base/source-state-plan.md 전파 규칙 재작성, "다이아몬드 의존성은 무엇이 푸는가" 절 신설(캐시가 주체임을 3단계 트레이싱으로), Observer 절에 "이 허용은 항상-전파에 의존한다"는 상호 참조 추가, 플래튼 기각 근거와 :With 빌더 기각 근거 2번을 캐시 공유로 재작성(두 결론 모두 유지, 근거 강도는 오히려 상승 — 예전엔 순회 비용이었지만 지금은 실제 중복 계산)
base/architecture.md 원래도 맞는 서술("pull-recompute가 다이아몬드를 푼다")이었으나 주체가 캐시임을 명시 보강 + 역전 포인터
base/blocker-plan.md **"emit 전파를 지연시킬 수 있는 유일한 요소"**로 위치 명문화 — 옛 서술이 사실상 Blocker의 일을 모든 State에 암묵적으로 심어 존재 의의를 반쯤 지웠다는 점까지
reference/comparison-fusion-vide.md Vide 대비 "전파를 끊어서"가 아니라 "애초에 push 시점에 계산을 안 해서"로
research/framework-comparison-findings.md 같은 정정. quad가 더 낫다는 결론은 유지, 다만 중복 통지는 안 접음을 명시
ROADMAP.md M0 체크리스트의 "이미 invalid면 전파 중단되는지"를 정반대로 교체 + :Get() 안 하는 Observer 확인 항목 추가
luau-test 05-store-state-diamond-propagation.luau 옛 모델을 통과 상태로 검증 중이었음rewrite-required/. STATUS.md 개수(done 14→13, rewrite 5→6)/README.md도 동기화
audit/luau-test-first-run-2026-08-13.md 05 행 정정 + "런타임 12개 전원 통과"를 액면대로 읽지 말라는 경고 추가(검증 대상이 바뀐 게 이제 셋이라 현행 설계 기준으론 9개)
archive/invalidate-dedup-propagation-reversed.md 신설 — 원문·역전 근거 셋·영향 범위 표·교훈
question.mdarchive/question-resolved.md 0-E 해소 이전(신설된 날 해소)

교훈으로 남긴 것: doc-check.py는 참조가 존재하는지는 보지만 서로 모순되는지는 못 봄. 이 건은 인용이 verbatim으로 정확했는데 인용된 문장 자체가 틀린 경우였고, 그 위에 두 라운드에 걸쳐 설계가 쌓였다가 통째로 무너졌음.

반영한 인덱스 레이어

README.md research 표 새 행, question.md 3번, ROADMAP.md 백로그 (+"M3에서 Blocker 구현할 때 게이트를 공용으로 빼둘 것" 지시), operator-sugar-plan.md(이관 완료 표시 + 배치 추측 정정), additional-primitives-plan.md(2026-08-06의 "빈 자리 아님" 판정이 뒤집힘 표시). doc-check.py — 이번 변경으로 새로 생긴 ERROR/WARN 0건(워크트리엔 initreq/가 gitignore로 없어서 그 참조 1건이 ERROR로 뜨지만 워크트리 아티팩트, 본 레포에선 정상).