quad/.claude/base/state-epoch-plan.md
qwreey cb838d3172
fix: /code-review high 12건 — 9건 수정, 3건은 열린 설계 항목으로 승격
O절 커밋(c58c97a) 직후 돌린 리뷰에서 12건이 나왔고 전부 유효했다.

열린 항목으로 승격(임의로 정하지 않음):
- [M2 착수 전] 게이트가 유보했다 내보내는 emit이 어느 source를 싣는가.
  확정된 setup은 (emit: () -> ()) -> (() -> ())라 양쪽 다 source를 안 받는데
  에포크 수신 규칙은 전부 [source] 키로 판정한다 — 그대로면 blocker:Off()가
  묶어둔 배치 emit이 하류에서 규칙 3으로 삼켜져 통지가 통째로 사라진다.
  5라운드 M절이 이미 짚었는데 표면 확정 때 같이 안 닫힌 것. 후보 (a) emit(nil)
  전체 확인 / (b) 유보 소스마다 emit(Blocker의 "정확히 1회"가 깨짐) /
  (c) GateNode 자신을 source처럼 취급 — 권고는 (c). gate-plan.md 4번.
- [M3 착수 전] 두 맵의 초기값·:With 병합·재계산 시 갱신 범위.
  규칙 1이 발행 소스 항목만 건드려 다중 소스 배치에서 같은 값을 두 번
  계산하고, "상류에서 복사"는 순회가 앞당긴 지연분 상속 여부가 미정이라
  새 노드가 통지를 삼킬 수 있다. state-epoch-plan.md §5 7번.
  이에 따라 todos.md 00번의 "M2를 막는 설계 항목 없음"도 정정.

그 자리에서 수정:
- state-epoch-plan.md: §4/§5-2의 sourceList 잔재(코퍼스에 bk.sourceList라는
  무관한 동명 식별자가 있어 오독 위험), §3의 "rawInvalid가 켜져도" 정정
- gate-plan.md: 배너가 부정하는 본문 두 문장을 같이 수정
- question.md 1번: Gate 이름 항목이 열린 채였던 것 해소로 갱신
- luau-test/STATUS.md: 05 이동이 반영 안 된 개수 3곳 + 같은 파일 안의
  모순 문장("05가 다시 돌아왔다")
- comparison-fusion-vide.md: 배너 바로 위 본문이 배너와 어긋나던 것
- N절/README가 가리키던 research/ 옛 경로

처리 전량은 round5-followup.md의 P절. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 21:21:44 +09:00

20 KiB

State 재계산/전파 판정 — 소스 에포크 비교 (2026-08-21 확정)

상태: 확정. 사용자 제안으로 시작해 같은 날 네 라운드에 걸쳐 다듬은 뒤 채택 확정됨 — "gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다. 채택하면 될것 같아요." 구현은 M3(Source/State).

⚠️ 이 문서는 base/source-state-plan.md의 "전파 모델 확정" 절을 대체하는 게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 판정 규칙을 얹는다. 그 절이 원래 갖고 있던 두 서술은 이 채택으로 뒤집혔고(아래 §3), 역전 원문은 archive/always-propagate-no-dedup-superseded.md에 있다.

읽는 순서: 규칙만 필요하면 §2만 보면 된다. §1은 왜 이걸 하는가(실재하는 glitch), §3은 무엇이 고쳐지는가, §5는 세부 계약, §7은 구현 시 확인할 것.

히스토리: 구현 전 QA 5라운드(SS-2/SS-3)에서 나왔고, 회신 원문은 qa-request/pre-implementation-qa-round5-response.md, 네 라운드의 정정 경위는 qa-request/pre-implementation-qa-round5-followup.md의 M·N절이 소스.

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 3차 정정본]

각 State가 자기에게 영향을 주는 루트 Source들의 에포크(count) 를 들고 있다가, 그것과 실제 Source의 현재 count를 비교해 재계산/전파 여부를 정한다. [2026-08-21] 마지막 정정으로 테이블이 둘로 갈렸다 — 아래 "왜 둘인가" 참고.

State
    sourceCountMap : { [source (weak key)] : count }
        -- "내 값이 이 소스에 대해 최신인가" (값 유효성)
    sourceEmitMap  : { [source (weak key)] : count }
        -- "이 소스의 이 에포크를 내가 하류로 이미 던졌는가" (전파 dedup)
    rawInvalid     : boolean
        -- "재계산이 필요하다"는 확정 플래그

둘 다 상류에서 복사되고 :With에서 합쳐진다.

  • Source:Set()/:Emit()은 자기 count를 증가시킨다.
  • emit은 발행한 source만 전달한다(값도, count도 안 싣는다) — 받는 쪽이 그 source의 count 필드를 그때그때 라이브로 읽는다. 그래서 "에포크 5의 emit" 같은 건 없고 emit은 그냥 "이 소스를 확인해봐" 라는 신호다.
  • emit을 받았을 때 — 판정은 O(1)이다, 그 소스 항목 하나만 본다:
    1. sourceCountMap[source] ~= source.count → 둘 다 source.count로 갱신하고 rawInvalid = true를 세운 다음 뒤로 emit 한다.
    2. 같은데 sourceEmitMap[source] ~= source.count값은 이미 최신이지만 통지는 아직 안 나갔다(아래 순회가 앞질러 흡수한 경우). sourceEmitMap만 갱신하고 뒤로 emit 한다 — rawInvalid는 안 건드린다.
    3. 둘 다 같으면 → 삼킨다.
    • 다른 소스 항목은 건드리지 않는다 — 그 소스들은 자기가 직접 emit 하므로.
  • 재계산 판정:
    • rawInvalid == true → 그냥 재계산한다. 순회할 이유가 없다(이미 확정).
    • rawInvalid == false그때만 sourceCountMap을 훑는다. 목적은 하나뿐 — 못 받은 emit을 여기서 먼저 받아주는 것(중간 게이트에 막혀 있었다거나 전파 파동이 아직 이 가지에 안 닿았다거나). 다른 항목이 발견되면 sourceCountMap 갱신하고 rawInvalid = true로 만든다.
    • 순회는 sourceEmitMap을 건드리지 않고, 뒤로 emit 하지도 않는다. 그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 하류까지 정상적으로 전파된다(사용자: "emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다").
  • 재계산이 끝나면 rawInvalid = false.
  • count가 싫다면 [source] -> {} 처럼 유니크 테이블 identity로 같은 판정이 가능하다(사용자 대안).

왜 테이블이 둘인가 — 순회 때문이다. 사용자 정리: "'내 값이, 상류의 상태로 하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시 던져야하는지 봐야하나' 를 보는걸 나누는거죠." 순회가 없다면 "값을 최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서 카운트 하나로 충분하다 — 실제로 2026-08-21 2차 정정에서 그렇게 정리했었다. 순회를 넣는 순간 그 둘이 갈라진다(순회는 값만 앞당기고 통지는 안 한다). 그래서 이 분리는 한 번 철회됐던 seen/computedAt 분리가, 다른 이유로 되살아난 것이다 — 옛 근거("전파 시점에 갱신된 count가 캐시를 신선한 것으로 오인시킨다")는 여전히 틀렸고, 지금 근거는 순회가 값과 통지를 비대칭으로 앞당긴다는 것이다.

⚠️ [2026-08-21 /code-review high] 아래 희소 구현 메모는 "둘 다 상류에서 복사"와 그대로는 안 맞는다 — 아래 §5의 7번이 소스. 두 맵을 명시적으로 다 들고 시작하는 게 안전하고, 희소화는 그 규칙이 정해진 뒤 얹을 것.

구현 메모 — sourceEmitMap은 희소 테이블로 두면 된다. emit이 count를 안 싣고 받는 쪽이 라이브로 읽으므로, 이 테이블에 실제로 필요한 정보는 "순회가 앞질러 흡수해서 아직 안 던진 소스가 무엇인가" 뿐이다. 평상시엔 비어 있고 순회가 발견했을 때만 채워지는 pending 집합으로 구현해도 위 세 규칙이 그대로 성립한다(1번에서 지우고, 2번에서 있으면 던지고 지운다).

3. 에이전트 분석 — 이게 실제로 무엇을 고치는가

결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라 Get()의 의미론을 바꾸는 결정이다.

  • 고쳐진다 — 섞인 값. 위 3단계에서 DC:Get()을 부르면, C도 자기 sourceCountMap에서 A의 count가 자기가 기록한 것보다 앞선 걸 보고 신호가 아직 안 왔어도 스스로 재계산한다. 그래서 D는 항상 (B_new, C_new)를 얻는다. Get()이 "지금 이 순간의 일관된 값"을 반환한다는 보장이 처음으로 성립한다.
  • 고쳐진다 — 중복 재계산. 뒤늦게 C 쪽 신호가 도착해도 sourceCountMap이 "이미 최신"이라 rawInvalid가 켜지지 않고, 따라서 재계산도 안 일어난다 (§2의 2번/3번 규칙 — [2026-08-21 정정] 여기 "rawInvalid가 켜져도"라고 적혀 있었으나 §2 규칙상 켜지지 않는다).
  • [2026-08-21 확정] 중복 통지도 같이 접는다 — source-state-plan.md가 "접지 않는다"고 확정해뒀던 것의 역전이다(역전 원문은 archive/always-propagate-no-dedup-superseded.md). 처음엔 "값만 고쳐지고 통지는 두 번 그대로"로 정리했는데, 사용자 판정으로 통지도 같은 장치로 접기로 했다: "중복 통지는 단순히, 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 둘로 분리하라"는 요구는 한 번 철회됐다가 다른 근거로 되살아났다 — 지금 형태는 sourceCountMap/sourceEmitMap이고, 근거는 원래 적었던 "전파 시점 갱신이 캐시를 오인시킨다"(틀림)가 아니라 순회가 값만 앞당기고 통지는 안 한다는 비대칭이다. §2의 "왜 테이블이 둘인가" 문단이 소스.
    • 비교는 두 맵 모두 source.count와 같으면 삼킨다(단조 증가라 안전).
  • 선례가 있다 — 값 자체를 비교하는 게 아니라 버전/에포크를 비교해 lazy하게 검증하는 건 MobX(global state version + observing 검사), Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준 기법이다. Fusion처럼 그래프를 위상정렬해 eager로 미는 방식(quad가 이미 기각한 것)의 대안으로 자주 쓰인다 — 즉 이 제안은 quad가 이미 택한 pull 모델과 결이 같다.

4. 비용

사용자 추산("해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해 인덱싱 하는 정도")에 동의한다. 덧붙일 것 둘:

  • 두 맵의 크기는 그 노드 상류에 있는 서로 다른 루트 Source의 수다. 체인이 길어져도 안 늘고, :With로 합류할 때만 는다. UI 파생값에서 이 수가 큰 경우는 드물다.
  • [2026-08-21 정정] 순회 조건이 뒤집혔으므로 비용 구도도 뒤집힌다 — 훑는 쪽이 흔한 경로(rawInvalid == false, 즉 "안 바뀐 것 같다")다. 그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 재계산은 안 도는 경로다. 반대로 rawInvalid == true면 순회를 아예 건너뛰고 바로 재계산한다.

5. 열린 질문 (채택 전에 답이 필요)

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

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

  3. [2026-08-21 해소] 순회가 발견한 변경을 어떻게 처분하는가 — 두 테이블로 나눠 "값은 앞당기고 통지는 기다린다"로 확정.

    문제(사용자 지적): 순회가 count를 최신으로 올려두면 뒤늦게 도착한 진짜 emit이 삼켜져 하류가 그 에포크를 영영 못 받는다. 값은 맞는데 통지가 죽는, 2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열의 실패 모드다 (archive/invalidate-dedup-propagation-reversed.md).

    확정 해법(사용자 제안, §2가 소스): 판정 기준을 둘로 나눈다"'내 값이, 상류의 상태로 하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시 던져야하는지 봐야하나' 를 보는걸 나누는거죠." 순회는 sourceCountMap만 올리고 emit은 안 한다. 나중에 진짜 emit이 오면 count는 같지만 sourceEmitMap이 달라 그때 정상적으로 전파된다 ("emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"). 결과:

    • 통지가 죽지 않는다.
    • 순회가 emit을 안 하므로 게이트를 새게 하는 경로가 아예 없다source = nil 규약도, "게이트를 에포크 경계로" 같은 계약 반전도 필요 없다.
    • Get()은 순회 덕에 항상 신선하고, 순회가 sourceCountMap을 실제로 올리므로 다음 Get()이 같은 차이를 재발견해 또 재계산하는 낭비도 없다 (에이전트가 냈던 대안 (c)의 유일한 약점이 여기서 사라진다).

    같이 검토된 대안 — rawEmit을 태우고 nil을 던지는 안(사용자 제안 1안): 순회가 만든 emit을 상류가 부르는 것과 같은 내부 진입점(rawEmit) 으로 흘려서, 그 노드에 Gate가 껴 있으면 자연히 게이트를 타게 하고, 순회가 찾은 변경은 여러 소스일 수 있으니 하류엔 source = nil을 던진다는 안. 구조 위생 쪽은 채택할 만하다 — 상류 emit과 내부 발생 emit이 같은 진입점을 쓰면 게이트가 붙은 노드에서 두 경로가 갈리지 않는다. 다만 이 문제의 해법으로는 위 2안이 더 낫다:

    • 막고 있는 게이트는 보통 순회하는 노드 자신이 아니라 상류에 있다. 자기 rawEmit을 태워봐야 상류 게이트는 안 물어보므로 누출이 그대로 남는다.
    • nil emit은 받는 쪽마다 전체 순회를 강제하고, 그 순회가 다시 count를 올리면 같은 "영구 침묵" 문제가 하류에서 재발한다(연쇄).
    • 2안은 순회가 애초에 emit을 안 하므로 두 문제가 생기지 않는다.

    부수 확인: blocker:OffWithoutEmit()처럼 emit 없이 푸는 경로에서 sourceEmitMap이 뒤에 남아도, 그 소스의 다음 진짜 emit이 위 1번(둘 다 다름)으로 걸려 정상화된다 — 별도 조치가 필요 없다.

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

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

  6. [2026-08-21 반영 완료] Get의 계약 문구. 사용자 지적("Get 이 항상 최신 상태를 가져온다라는 말이 여기서 무력화되는 부분")의 방향은 사실 반대였다 — 옛 모델이 "최신"을 못 지키고 있었고 이 안이 그걸 지키게 만든다. base/source-state-plan.md의 전파 모델 절은 채택과 함께 그렇게 다시 썼다.

  7. [2026-08-21 신설, /code-review high] 두 맵의 초기값·병합·재계산 시 갱신 범위 — M3 착수 전 필요. §2가 "둘 다 상류에서 복사되고 :With에서 합쳐진다"고만 적고 세 자리를 안 정했다:

    • (a) 재계산이 끝났을 때 sourceCountMap을 어디까지 갱신하나. §2는 rawInvalid = false만 말한다. 그런데 규칙 1은 발행 소스 항목만 건드리므로, DA,Z에 의존하고 A:Set(); Z:Set()이 연달아 오면 — A의 emit으로 재계산된 D의 값은 이미 Z의 새 값을 포함하는데 sourceCountMap[Z]는 옛 값이라, Z의 emit이 규칙 1에 걸려 같은 값을 또 계산한다. 에이전트 권고: 재계산은 자기가 실제로 읽은 상류 전부에 대해 sourceCountMap을 갱신한다(맵의 뜻이 "내 값이 이 소스에 대해 최신인가"이므로 이게 직독이다). 그러면 Z의 emit은 규칙 2로 떨어져 통지는 나가되 재계산은 안 한다 — 통지가 나가는 건 옳다, 하류는 아직 Z의 에포크를 못 봤으므로.
    • (b) 새 노드가 생길 때 두 맵의 초기값. "상류에서 복사"를 문자 그대로 하면, 순회로 sourceCountMap만 앞당겨진 상류에서 파생된 새 노드가 그 지연분까지 상속해야 규칙 2가 성립한다. 에이전트 권고: 복사가 아니라 "첫 재계산 때 자기가 읽은 상류들의 맵을 합쳐 구성" — 그러면 새 노드는 sourceEmitMap == sourceCountMap인 깨끗한 상태로 시작하고 (a)와도 맞물린다. 다만 이건 사용자 원안의 "상류에서 복사" 표현을 바꾸는 것이라 확인 필요.
    • (c) :With 병합에서 두 상류가 같은 소스에 다른 count를 들고 있을 때. (b)를 택하면 자동으로 "그때의 라이브 count"로 통일돼 문제가 사라진다. (b)를 안 택하면 명시 규칙이 필요하다(더 큰 쪽? 더 작은 쪽?).

6. 곁가지 — 폴링용 sugar

사용자 제안: "폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게 Ref 로 변환해주는 등". 즉 "항상 최신 값을 필드로 들고 있는 박스"를 만드는 순수 슈가. 이건 이 문서의 결정과 독립이고(에포크를 채택하든 안 하든 쓸 수 있다), research/operator-sugar-plan.md의 콤비네이터 계열에 더 가깝다 — 채택되면 그쪽으로 옮길 것.

7. 구현 시 확인할 것 (채택 확정 후 남은 실무 항목)

  • 역전된 두 서술은 archive/always-propagate-no-dedup-superseded.md에 있다 — "emit은 자기 invalid와 무관하게 항상 전파된다"와 "quad가 접지 않는 것은 중복 통지뿐이다". 지금 계약은 **"invalid로는 절대 안 접고, 같은 소스의 같은 에포크가 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면 2026-08-14에 폐기된 "영구 침묵" 버그로 되돌아간다(§3).
  • 선언 안 된 의존성에 대한 UB 조항은 안 만든다 — §5의 1번(사용자 기각).
  • luau-test 스파이크 하나: §1의 다이아몬드를 그대로 짜서 (a) 에포크 없이는 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 사라지는지 대조.
  • sourceEmitMap은 희소 pending 구현으로 시작해도 된다 — §2의 구현 메모.
  • 곁가지였던 폴링 슈가는 §6 그대로 — 이 결정과 독립이고 operator-sugar-plan.md 계열로 남는다.