사용자가 research/state-epoch-validation.md를 직접 읽고 기제 서술 세 건을
정정. 채택 여부 자체는 여전히 미정(M3 전 결론 필요).
- sourceList 순회 조건이 반대였다: rawInvalid == false일 때만 돈다.
true면 재계산이 이미 확정이라 훑을 이유가 없고, 순회의 목적은 오직
"못 받은 emit(게이트에 막혔던 것)을 여기서 먼저 받는 것".
- emit은 (source, count)가 아니라 발행 source만 싣는다 — 받는 쪽이
source의 count 필드를 그냥 읽으면 된다.
- 에이전트가 요구했던 seen/computedAt 두 카운트 분리는 철회. count 갱신과
rawInvalid = true가 같은 스텝이라 캐시 오인 경로가 없다.
- emit 수신 규약 확정형: 같으면 삼킴 / 다르면 count 먼저 갱신 →
rawInvalid = true → 그 다음 뒤로 emit. 다른 소스 항목은 안 건드림.
- 부수로 열린 것: 순회가 발견한 변경을 뒤로 emit 할 것인가. 다이아몬드
쪽은 사용자가 스스로 안전으로 정정(D도 count를 갱신해 중복을 삼킴),
게이트 쪽만 "해제 emit이 source = nil을 싣고 받는 쪽이 전체 확인"
규약으로 남음 — 게이트는 보통 최종단이라 채택을 막지 않는다는 판단.
- 비용 서술도 뒤집었고(훑는 쪽이 흔한 경로), question.md에 남아 있던
이미 뒤집힌 옛 결론("중복 통지는 안 고쳐짐 / UB 명문화 필요")도 정정.
처리 기록은 round5-followup.md M절. doc-check.py ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
15 KiB
State 재계산 판정을 "소스 에포크" 비교로 바꾸는 안 (2026-08-21 신설)
상태: research — 사용자 제안, 에이전트 분석 완료. 2026-08-21에 세부가 세 차례 갱신됐다(중복 통지도 접음 / 선언 안 된 의존성 조항 기각 / rawInvalid 기제와 emit 인자 정정 + seen·computedAt 분리 철회). 채택 자체는 여전히 미정.
구현 전 QA 5라운드(SS-2/SS-3)에서 나왔고 사용자 스스로 *"더 생각해볼
이야기라 백로깅이나 리서치에 들어가야할듯"*이라 함. 회신 원문은
qa-request/pre-implementation-qa-round5-response.md.
⚠️ base/source-state-plan.md가 여전히 정본이다 — 이 문서는 아무것도
확정하지 않는다. 다만 State 내부 표현을 바꾸는 제안이라 M3(=State 구현)
착수 전에 결론이 나야 한다.
1. 사용자가 지목한 문제 (실재함)
A ──> B ──┐
└──> C ──┴──> D
A:Set() 한 번에 대해 지금 모델(push-invalidate + pull-recompute)에서:
A가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라B쪽 가지가 먼저 끝까지 내려간다 —D가B를 통해 신호를 받고, 그 아래 Observer가 발화한다.- 그 Observer가
D:Get()을 부른다.D는 상류를Get()하는데,C는 아직 신호를 못 받아invalid가 아니다 →C는 옛 캐시를 그대로 반환한다. D는(B_new, C_old)라는 섞인 값을 계산해 캐시하고invalid를 끈다.- 뒤늦게
C가지의 전파가 도착해D가 다시 무효화되고, Observer가 또 울고, 그때서야(B_new, C_new)로 교정된다.
즉 (a) 한 사이클 안에서 잘못된 값이 한 번 관측되고(그 값으로 이미 프로퍼티가
써지는 등 부작용이 나간다), (b) 같은 계산이 두 번 돈다. 리액티브 문헌에서
말하는 전형적인 glitch이고, 지금 quad 문서 어디에도 이 현상이 서술돼 있지
않다. base/source-state-plan.md의 "다이아몬드 의존성은 무엇이 푸는가" 절은
중복 재계산이 없다고만 말하는데, 그 논증은 "누군가 d:Get()을 부르는
시점"이 전파 파동이 끝난 뒤라고 암묵적으로 가정한다 — Observer가 전파 도중에
발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다.
2. 사용자 제안 (최종 정리형) — [2026-08-21 정정본]
각 State가 자기에게 영향을 주는 루트 Source들의 에포크(count) 를 들고
있다가, 그것과 실제 Source의 현재 count를 비교해 재계산/전파 여부를 정한다.
State
sourceList : { [source (weak key)] : count }
-- "이 소스의 이 에포크까지는 이미 봤다". 상류에서 복사, :With에서 합침
rawInvalid : boolean
-- "재계산이 필요하다"는 확정 플래그. emit이 실제로 통과하면 켜진다
Source:Set()/:Emit()은 자기 count를 증가시킨다.emit은 발행한 source만 전달한다(값도, count도 안 싣는다) — 받는 쪽이 그 source의 count 필드를 그냥 읽으면 된다. [2026-08-21 사용자 정정] 앞선 정리는(source, count)쌍을 실어 보낸다고 적었는데 불필요하다.- emit을 받았을 때 — 판정은 O(1)이다, 그 소스 항목 하나만 본다:
sourceList[source] == source.count→ 삼킨다. 이 에포크는 이미 다른 경로로 봤다는 뜻이므로 뒤로 안 내려보낸다.- 다르면 →
sourceList[source]를 먼저 갱신하고rawInvalid = true를 둔 다음, 그러고 나서 뒤로 emit 한다.
- 다른 소스 항목은 건드리지 않는다 — 그 소스들은 자기가 직접 emit 하므로.
- 재계산 판정:
rawInvalid == true→ 그냥 재계산한다.sourceList를 훑을 이유가 없다 (이미 확정이라 더 알아낼 게 없다).rawInvalid == false→ 그때만sourceList를 훑는다. 목적은 하나뿐 — 못 받은 emit을 여기서 먼저 받아주는 것(중간 게이트에 막혀 있었다거나). 다른 항목이 발견되면 count를 갱신하고rawInvalid = true로 만든다.- [2026-08-21 사용자 정정] 앞선 정리는 이 순회 조건을
rawInvalid == true일 때로 적었는데 반대다.
- 재계산이 끝나면
rawInvalid = false. count는 emit을 통과시킬 때 이미 갱신돼 있으므로 따로 손댈 게 없다. - count가 싫다면
[source] -> {}처럼 유니크 테이블 identity로 같은 판정이 가능하다(사용자 대안).
⭐ [2026-08-21] 그래서 카운트는 하나면 된다 — seen/computedAt 분리는
철회. 앞선 정리는 노드가 전파 dedup용 seen과 캐시 검증용 computedAt을
따로 들어야 한다고 적었으나, 사용자 정정으로 불필요하다: count 갱신과
rawInvalid = true가 같은 스텝에서 같이 일어나기 때문에 "전파 시점에
갱신된 count가 캐시를 신선한 것으로 오인하게 만드는" 실패 모드 자체가
생기지 않는다. 캐시 유효성은 count가 아니라 rawInvalid가 들고, count는
순수하게 "이 에포크를 봤는가" 만 답한다. 사용자 정리: "그냥 지금 순수
count 와 source 를 ref해두는 구현은 문제가 없어보인다. 단순 rawInvalid 가
약간 이상하게 사용/기제된 부분만 보일 뿐임."
3. 에이전트 분석 — 이게 실제로 무엇을 고치는가
결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라
Get()의 의미론을 바꾸는 결정이다.
- 고쳐진다 — 섞인 값. 위 3단계에서
D가C:Get()을 부르면,C도 자기sourceList에서A의 count가 자기가 기록한 것보다 앞선 걸 보고 신호가 아직 안 왔어도 스스로 재계산한다. 그래서D는 항상(B_new, C_new)를 얻는다.Get()이 "지금 이 순간의 일관된 값"을 반환한다는 보장이 처음으로 성립한다. - 고쳐진다 — 중복 재계산. 뒤늦게
C쪽 신호가 도착해rawInvalid가 켜져도, count 비교가 "이미 최신"이라 재계산이 안 일어난다. - ⭐ [2026-08-21 후속 확정] 중복 통지도 같이 접는다. 처음엔 "값만
고쳐지고 통지는 두 번 그대로"로 정리했는데, 사용자 판정으로 통지도
같은 장치로 접기로 했다: "중복 통지는 단순히, count 목록을 순회해보고
이미 모든 소스가 최신이면 무시하는게 맞아보인다. 특히 emit 은 자신 소스를
주게 되므로, 자신 소스 카운트만 빠르게 비교가 쉽다. 우리에게 중요한 점은,
앞단의 변경이 뒤로 흘렀냐일 뿐이라서 해당 방식을 택하는데 있어 문제가 없다."
- 판정은 O(1)이다 — emit이 어느 source가 발행했는지를 실어 오므로 그 소스 하나만 비교하면 된다(전체 목록 순회가 아님).
- ⚠️ 이건 2026-08-14에 뒤집힌 옛 dedup과 다른 장치다. 그때 폐기된 건
*"이미
invalid면 더 안 내려보낸다"*로,:Get()을 안 부르는 Observer가 한 번 울고 영구히 침묵하는 실패 모드가 있었다 (archive/invalidate-dedup-propagation-reversed.md). 에포크 비교는 그 모드가 없다 — 매Set마다 카운트가 새 값이라 항상 통과하고, 접히는 건 같은 에포크가 두 경로로 도착한 두 번째뿐이다. - ⚠️ [2026-08-21 철회] 여기 있던 "카운트를
seen/computedAt둘로 분리하라"는 요구는 사용자 정정으로 없어졌다 — §2 마지막 문단이 소스. 캐시 유효성을rawInvalid가 들고 있고 count 갱신이rawInvalid = true와 같은 스텝이라, 하나로 합쳐도 캐시를 신선한 것으로 오인하는 경로가 없다. - 비교는
sourceList[source] == source.count면 삼킨다(단조 증가라 안전).
- 선례가 있다 — 값 자체를 비교하는 게 아니라 버전/에포크를 비교해 lazy하게 검증하는 건 MobX(global state version + observing 검사), Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준 기법이다. Fusion처럼 그래프를 위상정렬해 eager로 미는 방식(quad가 이미 기각한 것)의 대안으로 자주 쓰인다 — 즉 이 제안은 quad가 이미 택한 pull 모델과 결이 같다.
4. 비용
사용자 추산("해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해 인덱싱 하는 정도")에 동의한다. 덧붙일 것 둘:
sourceList의 크기는 그 노드 상류에 있는 서로 다른 루트 Source의 수다. 체인이 길어져도 안 늘고,:With로 합류할 때만 는다. UI 파생값에서 이 수가 큰 경우는 드물다.- [2026-08-21 정정] 순회 조건이 뒤집혔으므로 비용 구도도 뒤집힌다 —
훑는 쪽이 흔한 경로(
rawInvalid == false, 즉 "안 바뀐 것 같다")다. 그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 재계산은 안 도는 경로다. 반대로rawInvalid == true면 순회를 아예 건너뛰고 바로 재계산한다.
5. 열린 질문 (채택 전에 답이 필요)
-
[해소, 2026-08-21] 선언 안 된 의존성 — 별도 UB 조항을 만들지 않는다. 에이전트가 "새 모델은 더 강한 약속을 하니 예외를 UB로 못 박아야 한다"고 제안했으나 사용자가 기각: "그건 아니다. 이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을 던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고 생각한다." — 선언 안 한 Source를 클로저로 읽는 건 지금 모델에서도 똑같이 stale이고 이 변경이 악화시키는 게 없으므로, 새 조항 없이 기존 "의존성은 선언한다"는 관례 그대로 둔다.
-
동적 의존성. 조건에 따라 다른 상류를 읽는 계산이면
sourceList가 보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다. 그대로 감수할지 확인. -
⭐ [2026-08-21 확장] 순회로 발견한 변경을 뒤로 emit 할 것인가 — 그리고
Blocker/Gate와의 상호작용.rawInvalid == false에서 순회를 돌다 "못 받은 emit"을 발견해rawInvalid = true가 됐을 때, 그 자리에서 뒤로 emit 하는가가 열려 있다. 두 안:- (a) 지연 — 아무것도 안 내려보내고, 나중에 그 소스의 진짜 emit이 도착할 때 정상 경로로 처리한다.
- (b) 즉시 — 재계산하라는 지시를 받았고 invalid로 판정됐으니 그때 emit.
사용자가 (b)의 위험을 먼저 의심했다가 스스로 안전하다고 정정했다:
A → {B, C} → D에서B가C를 요구하다C의 변경분이 흘러D가 다시 깨어나는 것처럼 보이지만,D도 count를 갱신하므로 중복을 삼킨다 — "안전히 C가 emit 되어도 되는 상황".남는 건 게이트 쪽이다. 게이트가 앞에서 막아둔 emit을, 뒤에서
Get()이 촉발한 순회가 그대로 흘려보낼 수 있다("앞에서 막아뒀는데, 뒤에서 필요하다고 해서 그대로 흘러버릴 가능성"). 그래서 (b)를 택하더라도 실제 block이 풀린 뒤의 emit까지는 기다려야 한다. 그리고 게이트가 풀 때 내보내는 emit은 여러 소스가 섞인 것이라 어느 소스가 바뀌었는지 지목할 수 없다 — 사용자 제안은 그 emit이source = nil을 싣고,nil이면 받는 쪽이 전체 목록을 확인하는 것. 게이트는Get()은 막지 않고 emit만 유보하므로 이게 성립한다.**다만 사용자 판단은 "중간에 게이트가 끼는 건 원래 있던 문제이고, 게이트는 보통 최종단에 쓰므로 실질적으로 의미 없어 보인다"**로 정리됐다 — 즉 이 항목은 채택을 막는 요소가 아니고,
nilemit 규약만Gate설계 (research/gate-primitive.md)와 같이 확정하면 된다. -
:Emit()(값을 제자리에서 mutate하고 알리는 경로) — count만 올리면 되므로 그대로 동작한다. 확인만. -
weak 키.
[source] -> count를 weak-key로 두면 source가 죽을 때 항목이 사라진다. 다만base/relate-plan.md가 경고하듯 값이 키를 되참조하면 안 된다 — count는 숫자라 문제없다(테이블 identity 방식을 택하면 그 테이블이 source를 참조하지 않게 할 것). -
Get의 계약 문구. 사용자 지적("Get 이 항상 최신 상태를 가져온다라는 말이 여기서 무력화되는 부분")의 방향은 사실 반대다 — 지금 모델이 "최신"을 못 지키고 있었고, 이 제안이 그걸 지키게 만든다. 채택하면base/source-state-plan.md의 전파 모델 절을 그렇게 다시 써야 한다.
6. 곁가지 — 폴링용 sugar
사용자 제안: "폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다.
옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게
Ref 로 변환해주는 등". 즉 "항상 최신 값을 필드로 들고 있는 박스"를 만드는
순수 슈가. 이건 이 문서의 결정과 독립이고(에포크를 채택하든 안 하든 쓸 수
있다), research/operator-sugar-plan.md의 콤비네이터 계열에 더 가깝다 —
채택되면 그쪽으로 옮길 것.
7. 권고
- 채택 방향에 찬성. "선제 최적화가 아니라 확정 동작으로의 승격"이라는 사용자 판단에 동의한다 — 이건 성능이 아니라 정확성 결정이고, State 내부 구조를 정하는 M3보다 뒤에 하면 되돌리는 비용이 크다.
- [2026-08-21] 에이전트가 붙였던 조건(선언 안 된 의존성을 UB로 명문화)은 사용자 기각으로 빠졌다 — §5의 1번 참고.
- 채택 시 같이 확정되는 것: 중복 통지도 접는다(§3) — 그러면 다이아몬드에서
값도 통지도 한 번씩만 간다. [2026-08-21 정정] 여기 있던 "노드가
seen/computedAt두 카운트를 들어야 한다"는 추가 요구는 철회됐고, 대신 확정할 것은 게이트 해제 emit의source = nil규약(§5의 3번)이다. - 실측은
luau-test에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서 (a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 사라지는지 대조.