quad/.claude/archive/invalidate-dedup-propagation-reversed.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

129 lines
7.5 KiB
Markdown

# [역전됨] "이미 `invalid`면 그 아래로 더 전파하지 않는다" — 무효화 전파 dedup
**신설**: 2026-08-14 (Debounce/Throttle 설계 중 사용자 지적으로 발견)
**역전 대상**: `base/source-state-plan.md`의 "전파 모델 확정" 절
(2026-08-14 세 번째 세션의 3단계 분할 전에는 `base/bind-system-plan.md`
있었음 — 이 문서가 인용하는 옛 경로는 그 시절 것)
**현재 유효한 서술**: 같은 절의 "전파 모델 확정" + "다이아몬드 의존성은
무엇이 푸는가"(2026-08-14 재작성)
---
## 뒤집힌 원문
`base/source-state-plan.md`(분할 전에는 `base/bind-system-plan.md`)에
2026-08-04부터 2026-08-14까지 이렇게 적혀 있었음:
> - `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만
> 쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다
> 업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구).
> - **신호를 받은 State는 자기 `invalid` 플래그만 세우고, 이미 `invalid`였다면
> 그 아래로 더 전파하지 않는다 — 다이아몬드 의존성에서 중복 워크를 막는
> 장치(Vide가 저자 스스로 `todo.md`에 미해결로 남긴 문제의 해결책).**
> - 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 — "필요할 때 계산"
> 원칙(사용자 확정).
굵게 표시한 두 번째 항목이 역전 대상. 첫/세 번째 항목은 그대로 유효함.
## 무엇이 맞는가 (사용자 확정, 2026-08-14)
> **emit은 항상 전파함. `Blocker`나 emit 전파 지연요소만 이를 지연할 수 있음.**
>
> 재계산 막아지는 건 맞음 — 한 곳에서 `Get`이 되면, `invalid`하다면 위로
> 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음 `invalid`가
> 꺼짐.
즉:
- `invalid` 플래그의 역할은 **"내 캐시가 낡았다"는 표시 하나뿐**이고,
전파를 제어하는 장치가 아님.
- 중복 **재계산**을 막는 주체는 **pull-recompute + 노드별 캐시**.
- 중복 **통지**는 접지 않음 — 다이아몬드에서 아래쪽 `Observer`가 한
사이클에 두 번 우는 건 의도된 동작. 접고 싶으면 `Blocker` 같은 **명시적
게이트**를 쓸 것.
## 왜 틀렸는가 — 근거 셋
### 1. ⭐ 확정된 `Observer` 계약과 정면 충돌 (결정적)
같은 파일의 "`state:Observer(fn)`" 절이 **`fn``:Get()`을 부르지 않아도
되는 것을 명시적으로 허용**함:
> 재계산이 진짜 필요한지가 다른 `:With`한 값에 따라 갈리는 경우가 있어서
> (…) `Get()` 호출 여부를 작성자가 직접 결정하게 열어둔 것.
역전 전 규칙을 액면대로 적용하면:
```
source:Set(1) → state invalid 세팅 → Observer 발화 → fn이 :Get() 안 함
→ state는 invalid로 남음
source:Set(2) → state 이미 invalid → 전파 중단 → Observer 침묵 ❌
source:Set(3) → 마찬가지 ❌ ... 이후 영원히
```
**`:Get()`을 안 하는 Observer는 딱 한 번 울고 영구히 침묵함.** 문서가
정당한 사용법으로 허용한 것이 문서의 다른 문장 때문에 조용히 깨지는
것이므로, 취향 차이가 아니라 **base 내부의 실제 모순**이었음.
### 2. `base/architecture.md`와 어긋나 있었음
`architecture.md`는 같은 다이아몬드 문제를 이렇게 서술 중이었음:
> 전파는 push-invalidate(신호만)/pull-recompute(`Get()` 시점) — **Fusion식
> eager 노드 없이도 다이아몬드 의존성 중복 재계산 문제가 풀림**
즉 중복 *재계산*을 막는 주체를 **pull-recompute 자체**로 지목함(이쪽이
맞음). `source-state-plan.md`는 같은 문제의 해결 주체를 `invalid` 플래그
dedup으로 지목 — **base 안에서 두 문서가 서로 다른 것을 가리키고 있었음.**
### 3. 다이아몬드 근거가 요구하는 범위를 넘어섰음
다이아몬드는 *한 번의 변경*이 여러 경로로 같은 노드에 닿는 문제라,
막으려면 **그 전파 파동(wave) 안에서만** 접으면 됨. 그런데 역전 전 문장은
`invalid`를 **시간에 걸쳐 유지되는 상태**로 써서("이미 `invalid`였다면"),
누가 `:Get()`할 때까지 **이후의 모든 변경**까지 삼켰음. 파동 내 dedup과
시간축 dedup은 전혀 다른 범위인데 한 문장에 뭉뚱그려져 있었음.
덧붙여, "여러 emit을 하나로 접어뒀다가 나중에 1회 방출"은 사용자 지적대로
**`Blocker``HasBlockedEmit`이 opt-in으로 제공하는 동작**임
(`base/blocker-plan.md`). 같은 동작을 모든 State 노드에 암묵적으로 심으면
`Blocker`의 존재 의의가 절반 사라짐.
## 어떻게 들어왔나 (추정)
사용자는 원래부터 "emit은 항상 전파"로 말해왔음(2026-08-14 확인:
"내가 원래 emit은 항상 전파라 했는데 어떤 엉뚱한 에이전트가 이상한짓
하고 간듯"). Vide의 미해결 문제(`todo.md`의 "복잡한 다이아몬드 그래프에서
중복 재평가 방지")를 quad가 어떻게 푸는지 서술하는 과정에서, **이미 캐시로
풀려 있는 것**을 전파 억제 장치로 잘못 귀속시킨 것으로 보임.
## 영향 범위 — 2026-08-14에 같이 고친 것
| 파일 | 무엇이 바뀌었나 |
|---|---|
| `base/source-state-plan.md` | 전파 규칙 재작성 + "다이아몬드 의존성은 무엇이 푸는가" 절 신설. "왜 State 체인을 플래튼하지 않는가"와 `:With` 빌더 기각 근거 2번의 **근거를 캐시 공유로 교체**(두 결론 자체는 안 바뀜). `Observer` 절에 "이 허용은 항상-전파에 의존한다"는 상호 참조 추가 |
| `base/architecture.md` | 원래도 맞는 서술이었으나, 캐시가 주체임을 명시적으로 보강 |
| `base/blocker-plan.md` | "emit 전파를 지연시킬 수 있는 유일한 요소"라는 위치를 명문화 |
| `reference/comparison-fusion-vide.md` | Vide 대비 서술에서 "플래그 dedup" → "캐시" |
| `research/framework-comparison-findings.md` | 같은 정정 |
| `research/debounce-throttle-plan.md` | 이 문서의 3절 발견이 통째로 철회됨(아래) |
| `ROADMAP.md` | M0 체크리스트의 "이미 invalid면 전파 중단되는지" 항목 교체 |
| `luau-test` `05-store-state-diamond-propagation.luau` | 틀린 모델을 **통과 상태로 검증 중이었음**`rewrite-required/`로 이동 |
| `audit/luau-test-first-run-2026-08-13.md` | 그 스파이크의 통과 기록에 정정 표시 |
### 부수 피해 — `research/debounce-throttle-plan.md` 3절
이 잘못된 서술을 전제로, Debounce 설계 문서가 **"파생 State 위에 얹으면
debounce가 조용히 throttle로 퇴화한다"**는 발견을 만들어냈고, 거기서
"`Debounce`는 `Source`에 가깝게 걸어라"는 규칙(“`Blocker`의 '끝에 걸어라'와
정확한 거울상”)과 열린 질문 하나가 파생됐음. **전제가 무너지면서 전부
철회됨** — 그 문서 3절에 철회 기록으로 남아 있음.
## 교훈
**확정 문서의 한 문장을 근거로 새 설계를 세울 때, 그 문장이 *같은 문서의
다른 확정 문장*과 모순되지 않는지까지 확인할 것.** 이 건은 인용 자체는
정확했는데(verbatim) 인용된 문장이 틀린 경우였고, 그 위에 두 라운드에 걸쳐
설계가 쌓였다가 통째로 무너졌음. `doc-check.py`는 참조가 *존재하는지*는
보지만 *서로 모순되는지*는 못 봄 — 이건 여전히 사람/에이전트가 손으로
대조해야 하는 영역.