diff --git a/.claude/base/gate-plan.md b/.claude/base/gate-plan.md index 31c2f2b..3b0a3ae 100644 --- a/.claude/base/gate-plan.md +++ b/.claude/base/gate-plan.md @@ -126,12 +126,18 @@ end) **확정 기제**(사용자 안, 에이전트가 냈던 (a) `nil` 전체 확인 / (b) 소스마다 개별 emit은 기각 — 각각 O(1) 판정을 깨거나 `Blocker`의 "정확히 1회"를 깬다): - - `GateNode`가 **자기가 흡수한 소스 집합**을 들고 있는다 — - `withheld : { [source] : true }`. 정책이 통과시키지 **않은** 상류 emit의 - 출처가 여기 쌓인다. - - 유보를 풀 때 게이트는 **자기 자신을 출처로 실어** 하류에 emit 한다. - 전파는 **동기**이므로, 그 emit이 반환된 **뒤에** `table.clear(withheld)` - 하면 하류 전원이 집합을 온전히 본다. + - `GateNode`가 **자기를 거쳐간 소스 집합**을 들고 있는다 — + `withheld : { [source] : true }`. + - **⭐ [2026-08-21 단순화] 통과와 유보를 구분하지 않는다.** 상류 emit이 + 오면 **정책을 실행하기 전에 무조건** 그 출처를 `withheld`에 넣고, 그 + 다음 정책을 실행한다. 정책이 `emit()`을 부르면 게이트가 **자기 자신을 + 출처로** 하류에 전파하고, 전파는 **동기**이므로 반환된 **뒤에** + `table.clear(withheld)` 한다. 정책이 안 부르면 집합에 그대로 쌓인다. + - **그래서 게이트는 언제나 자기를 출처로 낸다** — 그냥 통과시킬 때도 상류 + 출처를 그대로 넘기지 않는다. 사용자: *"후행 노드들은 한개가 지연된거로 + 생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가 + 없습니다."* 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정 + 규칙은 완전히 같다. - 하류는 출처가 `GateNode`면 그 집합을 순회하며 각 소스에 평소 규칙(1~3)을 적용하고, 하나라도 걸리면 **받은 출처를 그대로** 더 아래로 넘긴다 (`state-epoch-plan.md` §2). @@ -139,11 +145,10 @@ end) `sourceEmitMap`이 뒤에 남지만, 그 소스의 다음 진짜 emit이 규칙 1/2로 걸려 스스로 낫는다. - **⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 흡수 집합을 채우는 건 정책이 - 아니라 **노드**다 — 노드가 `onUpstreamEmit()`을 부르기 전후로 "정책이 그 - 자리에서 `emit()`을 불렀는가"만 보면 통과/유보를 알 수 있고, 유보였으면 - 그 출처를 `withheld`에 넣는다. 정책은 소스를 몰라도 되고, `Throttle`처럼 - 나중에 타이머에서 `emit()`을 부르는 경우도 그대로 동작한다. + **⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라 + **노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화). + 정책은 소스를 몰라도 되고, `Throttle`처럼 나중에 타이머에서 `emit()`을 + 부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다. 5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 — 지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적, diff --git a/.claude/base/state-epoch-plan.md b/.claude/base/state-epoch-plan.md index e2f77ba..8d809ba 100644 --- a/.claude/base/state-epoch-plan.md +++ b/.claude/base/state-epoch-plan.md @@ -57,8 +57,20 @@ State -- "재계산이 필요하다"는 확정 플래그 ``` -노드가 이 두 맵을 **언제 어떻게 갖게 되는지**(생성 시 상류에서 복사 vs 첫 -재계산 때 구성)는 §5의 7번 — 관측 가능한 차이는 거의 없지만 아직 안 못박았다. +**⭐ [2026-08-21 확정] 노드가 생길 때의 초기값은 두 맵이 서로 다르다.** + +- **`sourceEmitMap`은 비운 채로 시작한다.** `nil ~= source.count`라 어떤 emit이 + 와도 "처음 보는 것"으로 걸린다 — 그리고 그게 맞다. 사용자: *"새로 생성된 + 노드에서 들어온 emit 은, 개념적으로 해당 노드가 한번도 받아본적 없는 + emit 입니다."* +- **`sourceCountMap`은 반대로 상류에서 전부 끌어와 실제 count로 채우고, + `rawInvalid = true`로 시작한다.** 이쪽은 비워두면 안 된다 — 순회가 훑을 + 대상 목록이 곧 이 맵이라, 비어 있으면 **"훑을 게 없으니 유효하다"**로 + 오판한다. 사용자: *"이건 상류가 주는대로 lazy 할 순 없는게, '내가 뭘 + 추적하고 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제 + count 로 둡니다. 그러고 `rawInvalid` 를 true 로 두세요."* +- 그래서 **`:With`에서 두 상류가 같은 소스에 다른 count를 들고 있을 때의 병합 + 규칙도 필요 없다** — 어차피 생성 시점의 라이브 count로 통일된다. **⭐ 대원칙 — 무효화를 결정하는 건 언제나 count 비교지 emit의 도착이 아니다.** emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도 @@ -76,8 +88,9 @@ count가 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2 - **`GateNode`** — 그 게이트가 **흡수해뒀던 소스 집합**을 확인하라 (`base/gate-plan.md`의 4번). 게이트가 `blocker:Off()` 등으로 유보를 풀 때 쓴다. - **출처는 전파 도중 바뀌지 않는다** — 중간 노드는 자기를 끼워넣지 않고 받은 - 출처를 그대로 아래로 넘긴다(소스 emit이 원래 그러듯이). + **평범한 노드는 출처를 안 바꾼다** — 자기를 끼워넣지 않고 받은 출처를 그대로 + 아래로 넘긴다. **`GateNode`만 예외로 언제나 자기 자신을 출처로 새로 낸다** + (`base/gate-plan.md`의 4번). - **emit을 받았을 때** — 출처가 `Source`면 판정은 O(1)이다, 그 항목 하나만 본다: 1. `sourceCountMap[source] ~= source.count` → 둘 다 `source.count`로 갱신하고 `rawInvalid = true`를 세운 다음 **뒤로 emit** 한다. @@ -251,31 +264,21 @@ count가 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2 `base/source-state-plan.md`의 전파 모델 절은 **채택과 함께 그렇게 다시 썼다.** -7. **[2026-08-21 — (a)는 해소, (b)/(c)만 남음] 두 맵의 초기값·병합·재계산 시 - 갱신 범위.** - - **(a) 재계산 후 `sourceCountMap` 갱신 범위 — 해소.** **자기가 읽은 상류 - 전부를 갱신한다**(사용자: *"invalid 에 대한 계산을 위한 count 테이블은 - 단순히 전부 업데이트 하는건 맞아보입니다"*). §2에 반영 완료. +7. **[2026-08-21 전량 해소] 두 맵의 초기값·병합·재계산 시 갱신 범위.** + - **(a) 재계산 후 `sourceCountMap` 갱신 범위** — **자기가 읽은 상류 전부를 + 갱신한다**(사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 + 전부 업데이트 하는건 맞아보입니다"*). §2에 반영. - ⚠️ 이 항목을 제기할 때 든 근거("`A:Set(); Z:Set()`이면 같은 값을 두 번 계산한다")는 **틀렸었다.** 전파는 동기라 `A:Set()`의 파동이 **완전히 끝난 뒤에** `Z:Set()`이 시작되므로, 그 사이 재계산은 `Z`의 옛 값을 읽는 게 맞고 통지가 두 번 나는 것도 맞다(사용자 정정: *"싱크라 set 의 emit 이 전부 전파 된 다음 z:set 이라 두번 나는게 맞긴 해요"*). 전부 갱신이 실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가 `Get()`으로 - 앞당겨 읽는 경우**뿐이다 — §2의 재계산 항목이 소스. - - **(b) 새 노드의 두 맵 초기값 — 열림(낮은 위험).** 원안은 "상류에서 복사". - 대안은 "첫 재계산 때 자기가 읽은 상류들의 맵을 합쳐 구성". **동기 전파 - 덕분에 둘의 차이가 나타나는 경우는 하나뿐**이다 — 게이트가 붙들고 있는 - 동안 파생 노드가 새로 생기는 경우. 그때 복사본은 상류의 "아직 안 던진" - 지연분을 물려받아 해제 시 통지를 한 번 더 받고(무해), 첫-재계산본은 이미 - 최신이라 삼킨다(이것도 무해 — 새 노드의 Observer는 등록 즉시 1회 관측으로 - 현재 값을 이미 봤다). **에이전트 권고: 첫 재계산 때 구성** — (c)가 자동으로 - 사라지고 "값을 계산한 시점의 스냅샷"이라는 맵의 뜻과 일치한다. - - **(c) `:With` 병합에서 두 상류가 같은 소스에 다른 count를 들 때 — (b)에 - 종속.** (b)에서 "첫 재계산 때 구성"을 택하면 그 시점 라이브 count로 - 통일돼 병합 규칙 자체가 필요 없다. "복사"를 택하면 명시 규칙이 필요하다 - (`sourceCountMap`은 더 큰 쪽, `sourceEmitMap`은 **더 작은 쪽** — 안 던진 - 게 하나라도 있으면 안 던진 것으로 봐야 통지가 안 죽는다). + 앞당겨 읽는 경우**뿐이다. + - **(b) 새 노드의 두 맵 초기값** — `sourceEmitMap`은 **비우고**, + `sourceCountMap`은 **전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`**. + §2의 "노드가 생길 때의 초기값" 문단이 소스. + - **(c) `:With` 병합 규칙** — (b)로 인해 **필요 없어졌다.** ## 6. 곁가지 — 폴링용 sugar diff --git a/.claude/qa-request/pre-implementation-qa-round5-followup.md b/.claude/qa-request/pre-implementation-qa-round5-followup.md index 73c264d..18adbca 100644 --- a/.claude/qa-request/pre-implementation-qa-round5-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round5-followup.md @@ -1077,3 +1077,40 @@ Observer가 이제 변경당 **1회**만 울어야 하므로 핵심 assert가 재계산 때 구성)과 그에 종속된 `:With` 병합 규칙. 동기 전파 덕에 둘의 차이가 나는 경우는 **게이트 유보 중 파생 노드가 새로 생길 때** 하나뿐이고 어느 쪽이든 무해하다. §5 7번. + +--- + +# R절 — 두 건 더 단순해짐 (2026-08-21, Q절 직후) + +## R-1. 게이트는 통과/유보를 구분하지 않는다 + +Q절은 "노드가 정책이 그 자리에서 `emit()`을 불렀는지 되짚어 유보만 +`withheld`에 넣는다"였는데, 사용자가 그 감지 자체를 없앴다: + +> *"사실 gate 는 상태가 어떻든 항상 유보그룹에 우선 넣고, (정책이 emit 을 +> 부르든 말든, 정책 실행 전에 그냥 넣음) 그런 다음 정책을 실행하면 emit 이 +> 그냥 뒤로 gate 자신을 전파해버리면 됩니다. 후행 노드들은 한개가 지연된거로 +> 생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가 없습니다."* + +- 상류 emit → **정책 실행 전에 무조건** `withheld[source] = true` → 정책 실행. +- 정책이 `emit()`을 부르면 게이트가 **자기를 출처로** 전파하고, 동기 전파라 + 반환 뒤 `table.clear(withheld)`. +- **게이트는 언제나 자기를 출처로 낸다** — 그냥 통과시킬 때도 상류 출처를 + 넘기지 않는다. 하류가 보는 차이는 집합 원소가 하나냐 여럿이냐뿐. + +## R-2. 새 노드의 두 맵 — 비대칭 초기화 (P절 Med-3 해소) + +> *"emit 은 그냥 비어있어도 됩니다. nil 자체도 변경으로 보아도 돼요. … +> 새로 생성된 노드에서 들어온 emit 은, 개념적으로 해당 노드가 한번도 받아본적 +> 없는 emit 입니다. … 이건 상류가 주는대로 lazy 할 순 없는게, '내가 뭘 추적하고 +> 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제 count 로 +> 둡니다. 그러고 rawInvalid 를 true 로 두세요."* + +| 맵 | 초기값 | 왜 | +|---|---|---| +| `sourceEmitMap` | **비움** | `nil ~= source.count`라 어떤 emit도 "처음 보는 것"으로 걸린다 — 새 노드는 실제로 받아본 적이 없다 | +| `sourceCountMap` | **전부 끌어와 실제 count**, + `rawInvalid = true` | 순회가 훑을 목록이 곧 이 맵이라, 비워두면 "훑을 게 없으니 유효하다"로 오판한다 | + +그래서 **`:With` 병합 규칙은 필요 없어졌다** — 생성 시점 라이브 count로 +통일되므로 두 상류가 다른 count를 들 일이 없다. P절 Med-3이 열어둔 (b)/(c)가 +이걸로 전부 닫혔다. diff --git a/.claude/question.md b/.claude/question.md index 311d0fc..12cedce 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -213,13 +213,13 @@ `table.clear`한다. 하류는 출처가 게이트면 그 집합의 소스들에 평소 규칙을 적용한다. **`setup` 시그니처는 안 바뀐다** — 집합을 채우는 건 정책이 아니라 노드이기 때문. `base/gate-plan.md`의 4번, `base/state-epoch-plan.md` §2. -- **[낮은 위험, 2026-08-21] State 에포크 — 새 노드의 두 맵 초기값(복사 vs 첫 - 재계산 때 구성)과 그에 종속된 `:With` 병합 규칙.** 재계산 시 갱신 범위(전부 - 갱신)는 같은 날 **확정**됐고, 그걸 제기할 때 든 근거는 **동기 전파를 놓친 - 것이라 사용자가 정정**했다. 남은 (b)/(c)는 **동기 전파 덕에 차이가 나는 - 경우가 게이트 유보 중 새 노드가 생길 때 하나뿐**이고 어느 쪽이든 무해하다 — - 권고는 "첫 재계산 때 구성". M3 구현 시 정하면 된다. - `base/state-epoch-plan.md`의 §5 7번. +- **[해소, 2026-08-21 같은 날] State 에포크 — 새 노드의 두 맵 초기값과 + `:With` 병합.** `sourceEmitMap`은 **비우고**(어떤 emit이 와도 "처음 보는 + 것"으로 걸리는 게 맞다 — 새 노드는 개념적으로 emit을 받아본 적이 없다), + `sourceCountMap`은 **전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`** + (비워두면 순회가 훑을 목록 자체가 없어 "유효하다"로 오판한다 — *"'내가 뭘 + 추적하고 있나' 가 필요하죠"*). 그래서 `:With` 병합 규칙은 **필요 없어졌다.** + 재계산 시 갱신 범위(전부 갱신)도 확정. `base/state-epoch-plan.md`의 §2·§5 7번. - **[해소, 2026-08-21] 공용 게이트 노드의 이름과 표면 — `state:Gate(setup)` 메소드 + `GateNode`로 확정.** 탑레벨 프리미티브는 안 만들고, `Blocker`는 `state:Block(blocker)` 안에서 그 배선을 쓴다(사용자: *"Gate 는 따로 diff --git a/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md b/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md index 0baa063..e20dba7 100644 --- a/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md +++ b/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md @@ -309,3 +309,17 @@ O절 커밋 직후 사용자가 돌린 리뷰에서 12건이 나왔고 전부 `Z:Set()`이 시작되므로 통지가 두 번 나는 건 중복이 아니라 맞는 동작이다. 그 과정에서 대원칙 하나가 명문화됐다: **무효화를 결정하는 건 언제나 count 비교지 emit의 도착이 아니다.** 전량은 followup의 Q절. + +## 14. 마지막 단순화 두 건 — 게이트 통과/유보 미구분, 새 노드 맵 비대칭 초기화 + +사용자가 Q절 기제를 한 번 더 줄였다. 게이트는 **정책 실행 전에 무조건** +출처를 `withheld`에 넣고, 통과시킬 때조차 **자기를 출처로** 전파한다 — +"지연과 비지연을 구분할 이유가 없다". 그래서 Q절이 넣었던 "정책이 emit을 +불렀는지 노드가 되짚는다"는 감지 로직이 통째로 사라졌다. + +새 노드의 두 맵은 **비대칭**으로 초기화한다: `sourceEmitMap`은 비우고 +(새 노드는 개념적으로 emit을 받아본 적이 없으므로 `nil`이 곧 "변경"), +`sourceCountMap`은 상류에서 전부 끌어와 실제 count로 채운 뒤 +`rawInvalid = true`(순회가 훑을 목록이 곧 이 맵이라 비워두면 "유효"로 +오판). 이걸로 `:With` 병합 규칙이 필요 없어지며 `/code-review` Med-3이 +닫혔다. 전량은 followup의 R절. diff --git a/.claude/todos.md b/.claude/todos.md index b890709..76ce353 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -15,9 +15,10 @@ 내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로 되돌아갔으나, **사용자가 그 자리에서 `emit(self)` + 흡수 집합으로 확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번). 같이 - 제기됐던 에포크 쪽 세 자리 중 "재계산 시 count 전부 갱신"도 확정, - 나머지 둘(새 노드 맵 초기값 / `:With` 병합)은 **M3 구현 시 정하면 되는 - 낮은 위험 항목**으로 남았다(`state-epoch-plan.md` §5 7번). + 제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 count 전부 갱신 / + 새 노드는 `sourceEmitMap`은 비우고 `sourceCountMap`은 실제 count로 채운 뒤 + `rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) — + `state-epoch-plan.md` §5 7번. 그 외 남은 것은 판단이 아니라 구현 시 정할 것들 — `Gate`의 생명주기·재진입 계약, M2에 `Blocker`까지 넣을지, 스파이크 `05` 재작성 (`luau-test/STATUS.md`).