워크트리(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>
341 lines
24 KiB
Markdown
341 lines
24 KiB
Markdown
# 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/master`는 **quad 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로 남김
|
|
(확정 문서라 임의 수정 안 함).
|
|
- **`Throttle`은 `Debounce` 프리셋으로** 권고 —
|
|
`Throttle{Time=t} == Debounce{Time=t, Leading=true, Trailing=true, MaxTime=t}`
|
|
가 lodash의 실제 구현 관계 그대로. quad가 반복해온 "같은 일 하는 두 번째
|
|
경로 안 만들기"와 맞음.
|
|
- **이름 위험**: Roblox 커뮤니티의 `debounce`는 재진입 방지 불리언이라
|
|
정면 충돌. 그래도 업계 표준 이름 유지 + 문서 경고를 권고(검색/이주
|
|
비용). `-ed`를 안 붙이는 건 코퍼스 규칙으로 이미 결정됨(lazy한 건 원형,
|
|
`Compute`가 `Computed`가 아닌 것과 같은 이유) — 열린 질문 아님.
|
|
- **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)가 핵심 오류** — `bindLifetime`이 `any`인 건 **거기 진짜로
|
|
아무거나 오기 때문**이고, `setTimeout`/`clearTimeout`은 **자기가
|
|
만들어낸 것만 주고받는 닫힌 루프**라 성격이 정반대. 같은 선례로
|
|
묶을 수 없음.
|
|
- **(b)도 뒤집힘** — 닫힌 루프이기 때문에 오히려 `clearTimeout(1)`을
|
|
**타입 에러로 잡아줄 수 있음**. `any`는 그 공짜 검사를 스스로 끄는 것.
|
|
- **(c)는 더 나은 구현으로 무력화** — `Relate`를 걸 게 아니라
|
|
**네이티브 핸들을 `Timeout` 테이블 필드에 직접 넣으면 됨**(타입은
|
|
캐스트로 맞춤). 릴레이션 층이 사라지고 남는 건 테이블 1개 할당인데,
|
|
`task.delay`가 **코루틴을 하나 만드는** 호출이라 그 옆에서 노이즈
|
|
수준 — 핫패스 논거가 성립 안 함.
|
|
- 런타임에 마커 필드를 넣는 것도 무방하다고 확인해줌.
|
|
|
|
**확정**(후속 한 왕복으로 페이로드 자리까지 합의):
|
|
|
|
```lua
|
|
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.md` → `archive/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로 뜨지만 워크트리
|
|
아티팩트, 본 레포에선 정상).
|