quad/.claude/research/state-epoch-validation.md
qwreey 4c395cd383
design: State 에포크 안 3차 정정 — rawInvalid 기제 반전, seen/computedAt 분리 철회
사용자가 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
2026-08-21 20:15:28 +09:00

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)에서:

  1. A가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라 B 쪽 가지가 먼저 끝까지 내려간다DB를 통해 신호를 받고, 그 아래 Observer가 발화한다.
  2. 그 Observer가 D:Get()을 부른다. D는 상류를 Get()하는데, C는 아직 신호를 못 받아 invalid가 아니다C옛 캐시를 그대로 반환한다.
  3. D(B_new, C_old)라는 섞인 값을 계산해 캐시하고 invalid를 끈다.
  4. 뒤늦게 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)이다, 그 소스 항목 하나만 본다:
    1. sourceList[source] == source.count삼킨다. 이 에포크는 이미 다른 경로로 봤다는 뜻이므로 뒤로 안 내려보낸다.
    2. 다르면 → 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단계에서 DC: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. 열린 질문 (채택 전에 답이 필요)

  1. [해소, 2026-08-21] 선언 안 된 의존성 — 별도 UB 조항을 만들지 않는다. 에이전트가 "새 모델은 더 강한 약속을 하니 예외를 UB로 못 박아야 한다"고 제안했으나 사용자가 기각: "그건 아니다. 이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을 던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고 생각한다." — 선언 안 한 Source를 클로저로 읽는 건 지금 모델에서도 똑같이 stale이고 이 변경이 악화시키는 게 없으므로, 새 조항 없이 기존 "의존성은 선언한다"는 관례 그대로 둔다.

  2. 동적 의존성. 조건에 따라 다른 상류를 읽는 계산이면 sourceList가 보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다. 그대로 감수할지 확인.

  3. [2026-08-21 확장] 순회로 발견한 변경을 뒤로 emit 할 것인가 — 그리고 Blocker/Gate와의 상호작용. rawInvalid == false에서 순회를 돌다 "못 받은 emit"을 발견해 rawInvalid = true가 됐을 때, 그 자리에서 뒤로 emit 하는가가 열려 있다. 두 안:

    • (a) 지연 — 아무것도 안 내려보내고, 나중에 그 소스의 진짜 emit이 도착할 때 정상 경로로 처리한다.
    • (b) 즉시 — 재계산하라는 지시를 받았고 invalid로 판정됐으니 그때 emit.

    사용자가 (b)의 위험을 먼저 의심했다가 스스로 안전하다고 정정했다: A → {B, C} → D에서 BC를 요구하다 C의 변경분이 흘러 D가 다시 깨어나는 것처럼 보이지만, D도 count를 갱신하므로 중복을 삼킨다"안전히 C가 emit 되어도 되는 상황".

    남는 건 게이트 쪽이다. 게이트가 앞에서 막아둔 emit을, 뒤에서 Get()이 촉발한 순회가 그대로 흘려보낼 수 있다("앞에서 막아뒀는데, 뒤에서 필요하다고 해서 그대로 흘러버릴 가능성"). 그래서 (b)를 택하더라도 실제 block이 풀린 뒤의 emit까지는 기다려야 한다. 그리고 게이트가 풀 때 내보내는 emit은 여러 소스가 섞인 것이라 어느 소스가 바뀌었는지 지목할 수 없다 — 사용자 제안은 그 emit이 source = nil을 싣고, nil이면 받는 쪽이 전체 목록을 확인하는 것. 게이트는 Get()은 막지 않고 emit만 유보하므로 이게 성립한다.

    **다만 사용자 판단은 "중간에 게이트가 끼는 건 원래 있던 문제이고, 게이트는 보통 최종단에 쓰므로 실질적으로 의미 없어 보인다"**로 정리됐다 — 즉 이 항목은 채택을 막는 요소가 아니고, nil emit 규약만 Gate 설계 (research/gate-primitive.md)와 같이 확정하면 된다.

  4. :Emit()(값을 제자리에서 mutate하고 알리는 경로) — count만 올리면 되므로 그대로 동작한다. 확인만.

  5. weak 키. [source] -> count를 weak-key로 두면 source가 죽을 때 항목이 사라진다. 다만 base/relate-plan.md가 경고하듯 값이 키를 되참조하면 안 된다 — count는 숫자라 문제없다(테이블 identity 방식을 택하면 그 테이블이 source를 참조하지 않게 할 것).

  6. 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) 에포크 비교를 넣으면 사라지는지 대조.