(1) research/state-epoch-validation.md §5-3 재작성. 사용자 지적대로 순회가 count를 올리면 뒤늦게 온 진짜 emit이 삼켜져 하류 통지가 영구히 죽는다 (2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열). 두 해법을 대조로 남김 — (b) 순회도 emit(사용자 제안, 게이트 누출이 남고 그 누출을 막을 기제가 둘 다 대가가 큼: 에포크 경계는 blocker의 ":Get()엔 영향 없음" 확정 계약을 뒤집고, source = nil 규약은 사후 정합성만 맞춤) vs (c) 순회는 rawInvalid만 세우고 count는 안 올림(에이전트 권고 — 원인만 제거하므로 누출도 nil 규약도 계약 반전도 안 생김, 대가는 emit 도착 전까지 Get마다 재계산과 OffWithoutEmit 캐비엇). 미결로 남김. (2) research/gate-primitive.md 2번 해소. 사용자 확정으로 Gate는 state:Gate(setup) 메소드다. 경계는 "Apply가 노드를 못 만든다"가 아니라 "프리미티브는 메소드 / 유저랜드 조합 팩토리는 :Apply". 그래서 Debounce/Throttle의 Apply 관용구는 유지되고, Blocker 배선은 이미 확정된 state:Block(blocker)가 내부에서 :Gate를 부르는 것으로 자동 해소되며, __call은 쓰지 않는다. question.md/ROADMAP 인덱스 동기화. doc-check.py ERROR 0. Co-authored-by: qwreey <me@qwreey.moe> Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
17 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을 여기서 먼저 받아주는 것(중간 게이트에 막혀 있었다거나). 다른 항목이 발견되면rawInvalid = true로 만든다. ⚠️ 이때 count를 갱신할지는 열려 있다 — §5의 3번이 소스(갱신하면 나중에 도착하는 진짜 emit이 삼켜져 하류가 영영 통지를 못 받는다).- [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 재확장] 순회가 발견한 변경을 어떻게 처분하는가 — 이 안에서 유일하게 안 닫힌 자리.
먼저 확정된 것: 순회가 count를 갱신하면 안 된다(또는 갱신하면 반드시 emit해야 한다). 사용자 지적 — "순회가 발견한거면, count 를 최신으로 올리기에 나중에 emit 와도 전파가 안 되는 이슈가 생기긴 함." 실제로 그렇다:
C가 순회로A의 변경을 발견해 count를 최신으로 올려두면, 뒤늦게 도착한A의 진짜 emit이C에서 삼켜지고(count가 같으므로)C의 하류는 그 에포크를 영영 못 받는다. 값은 맞지만 통지가 죽는, 2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열의 실패 모드다 (archive/invalidate-dedup-propagation-reversed.md).두 해법이 있다:
- (b) 순회가 count를 올리고, 그 자리에서 뒤로 emit 한다 — 사용자 제안.
통지가 죽지 않는다. 다이아몬드에서 새는 것도 없다(사용자가 스스로 정정한
대로
D도 count를 갱신해 중복을 삼킨다). 남는 문제는 게이트뿐 — 아래. - (c) 순회는
rawInvalid = true만 세우고 count는 그대로 둔다 — 에이전트 제안. count의 역할을 "이 에포크를 내가 실제로 전파했는가" 하나로 유지한다.Get()은rawInvalid덕에 신선하고, 나중에 진짜 emit이 오면 count가 다르므로 정상 경로로 하류까지 전파된다. 게이트를 새게 하는 경로가 아예 생기지 않는다(순회는 절대 emit하지 않으므로).- 대가: emit이 도착하기 전까지
Get()마다 다시 재계산한다(매번 순회가 같은 차이를 재발견하므로). 일반 경로에선 파동이 같은 틱에 도착하니 창이 아주 좁고, 게이트 경로에선 "묶어놨다가 한 번에 푼다"가 목적이라 그 구간에Get()이 잦을 이유가 없다. - ⚠️ 단
blocker:OffWithoutEmit()처럼 emit 없이 푸는 경로에선 count가 영원히 안 따라잡아 그 상태가 무기한 남는다 — 그 경로가 count를 밀어주게 할지 같이 정해야 한다.
- 대가: emit이 도착하기 전까지
게이트 문제(=(b)를 택할 때만 남는다). 게이트가 앞에서 막아둔 emit을, 뒤의
Get()이 촉발한 순회가 그대로 흘려보낸다. 사용자 판단은 *"gate 의 경우 이것 또한 gate 가 emit 하는, 즉 blocker off 되는 시점까지 밀리면 된다"*인데, 그걸 실행할 기제가 간단하지 않다 — 순회하는 노드C는 자기 상류 어딘가에 게이트가 걸려 있다는 걸 알 방법이 없다(sourceList는 루트 Source로 평탄화돼 있어 중간 노드가 안 보인다). 기제 후보는 둘이고 둘 다 대가가 있다:- 게이트를 에포크 경계로 만든다 — 게이트 하류의
sourceList가 루트 Source 대신 게이트 자신을 (자기 count와 함께) 들게 한다. 그러면 막혀 있는 동안 하류 순회가 변경을 발견 자체를 못 하므로 새지 않고, 해제 emit도source = nil이 필요 없어진다(게이트가 곧 그 소스다). 대가가 크다 — 하류Get()이 막힌 값을 못 보게 되어base/blocker-plan.md의 확정 계약 (:Get()엔 영향 없음)이 뒤집힌다. - 해제 emit이
source = nil을 싣는다 — 게이트가 풀 때 내보내는 emit은 여러 소스가 섞여 있어 지목이 안 되므로,nil을 싣고 받는 쪽이 전체 목록을 확인한다. 새는 것 자체는 못 막고, 새고 난 뒤 정합성만 맞춘다.
에이전트 권고: (c). 사용자가 발견한 문제의 원인이 정확히 "순회가 count를 올리는 것"이므로 그것만 안 하면 통지 죽음도, 게이트 누출도,
nilemit 규약도, 계약 반전도 전부 안 생긴다. 가장 적게 건드리는 해법이다.OffWithoutEmit캐비엇 하나만 같이 정하면 된다.**다만 사용자 판단은 "중간에 게이트가 끼는 건 원래 있던 문제이고, 게이트는 보통 최종단에 쓰므로 실질적으로 의미 없어 보인다"**로 이미 나와 있다 — 즉 (b)를 택해도 채택을 막는 요소는 아니다.
- (b) 순회가 count를 올리고, 그 자리에서 뒤로 emit 한다 — 사용자 제안.
통지가 죽지 않는다. 다이아몬드에서 새는 것도 없다(사용자가 스스로 정정한
대로
-
: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두 카운트를 들어야 한다"는 추가 요구는 철회됐고, 대신 확정할 것은 순회가 발견한 변경의 처분(§5의 3번) — 에이전트 권고는 "순회는 count를 안 올리고rawInvalid만 세운다"이고, 그러면nilemit 규약도 계약 반전도 필요 없어진다. - 실측은
luau-test에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서 (a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 사라지는지 대조.