design: 게이트 통과/유보 미구분으로 단순화 + 새 노드 두 맵 비대칭 초기화 확정

1) 게이트는 통과와 유보를 구분하지 않는다. 상류 emit이 오면 정책 실행 전에
   무조건 withheld[source] = true 로 넣고, 정책이 emit()을 부르면 게이트가
   자기를 출처로 전파한 뒤(동기) table.clear 한다. 그냥 통과시킬 때도 상류
   출처를 넘기지 않고 언제나 자기를 낸다 — 하류가 보는 차이는 집합 원소가
   하나냐 여럿이냐뿐이고 판정 규칙은 같다. 그래서 직전 라운드에 넣었던
   "정책이 그 자리에서 emit()을 불렀는지 노드가 되짚는다"는 감지 로직이
   통째로 불필요해졌다.

2) 새 노드의 두 맵은 비대칭으로 초기화한다.
   - sourceEmitMap: 비운다. nil ~= source.count 라 어떤 emit도 "처음 보는
     것"으로 걸리고, 새 노드는 실제로 emit을 받아본 적이 없으므로 그게 맞다.
   - sourceCountMap: 상류에서 전부 끌어와 실제 count로 채우고 rawInvalid를
     true로 둔다. 순회가 훑을 목록이 곧 이 맵이라 비워두면 "훑을 게 없으니
     유효하다"로 오판한다 — 여기는 lazy할 수 없고 "내가 뭘 추적하는가"가
     필요하다.
   그래서 :With 병합 규칙은 필요 없어졌다(생성 시점 라이브 count로 통일되므로
   두 상류가 같은 소스에 다른 count를 들 일이 없다). /code-review Med-3이
   열어둔 (b)/(c)가 이걸로 전부 닫혔다.

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

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
This commit is contained in:
qwreey 2026-08-21 21:57:45 +09:00
parent 356a308ce0
commit 930e45bfad
Signed by: qwreey
GPG key ID: D28DB79297A214BD
6 changed files with 104 additions and 44 deletions

View file

@ -126,12 +126,18 @@ end)
**확정 기제**(사용자 안, 에이전트가 냈던 (a) `nil` 전체 확인 / (b) 소스마다 **확정 기제**(사용자 안, 에이전트가 냈던 (a) `nil` 전체 확인 / (b) 소스마다
개별 emit은 기각 — 각각 O(1) 판정을 깨거나 `Blocker`의 "정확히 1회"를 깬다): 개별 emit은 기각 — 각각 O(1) 판정을 깨거나 `Blocker`의 "정확히 1회"를 깬다):
- `GateNode`가 **자기가 흡수한 소스 집합**을 들고 있는다 — - `GateNode`가 **자기를 거쳐간 소스 집합**을 들고 있는다 —
`withheld : { [source] : true }`. 정책이 통과시키지 **않은** 상류 emit의 `withheld : { [source] : true }`.
출처가 여기 쌓인다. - **⭐ [2026-08-21 단순화] 통과와 유보를 구분하지 않는다.** 상류 emit이
- 유보를 풀 때 게이트는 **자기 자신을 출처로 실어** 하류에 emit 한다. 오면 **정책을 실행하기 전에 무조건** 그 출처를 `withheld`에 넣고, 그
전파는 **동기**이므로, 그 emit이 반환된 **뒤에** `table.clear(withheld)` 다음 정책을 실행한다. 정책이 `emit()`을 부르면 게이트가 **자기 자신을
하면 하류 전원이 집합을 온전히 본다. 출처로** 하류에 전파하고, 전파는 **동기**이므로 반환된 **뒤에**
`table.clear(withheld)` 한다. 정책이 안 부르면 집합에 그대로 쌓인다.
- **그래서 게이트는 언제나 자기를 출처로 낸다** — 그냥 통과시킬 때도 상류
출처를 그대로 넘기지 않는다. 사용자: *"후행 노드들은 한개가 지연된거로
생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가
없습니다."* 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정
규칙은 완전히 같다.
- 하류는 출처가 `GateNode`면 그 집합을 순회하며 각 소스에 평소 규칙(1~3)을 - 하류는 출처가 `GateNode`면 그 집합을 순회하며 각 소스에 평소 규칙(1~3)을
적용하고, 하나라도 걸리면 **받은 출처를 그대로** 더 아래로 넘긴다 적용하고, 하나라도 걸리면 **받은 출처를 그대로** 더 아래로 넘긴다
(`state-epoch-plan.md` §2). (`state-epoch-plan.md` §2).
@ -139,11 +145,10 @@ end)
`sourceEmitMap`이 뒤에 남지만, 그 소스의 다음 진짜 emit이 규칙 1/2로 `sourceEmitMap`이 뒤에 남지만, 그 소스의 다음 진짜 emit이 규칙 1/2로
걸려 스스로 낫는다. 걸려 스스로 낫는다.
**⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 흡수 집합을 채우는 건 정책이 **⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라
아니라 **노드**다 — 노드가 `onUpstreamEmit()`을 부르기 전후로 "정책이 그 **노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화).
자리에서 `emit()`을 불렀는가"만 보면 통과/유보를 알 수 있고, 유보였으면 정책은 소스를 몰라도 되고, `Throttle`처럼 나중에 타이머에서 `emit()`
그 출처를 `withheld`에 넣는다. 정책은 소스를 몰라도 되고, `Throttle`처럼 부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다.
나중에 타이머에서 `emit()`을 부르는 경우도 그대로 동작한다.
5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 — 5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 —
지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적, 지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적,

View file

@ -57,8 +57,20 @@ State
-- "재계산이 필요하다"는 확정 플래그 -- "재계산이 필요하다"는 확정 플래그
``` ```
노드가 이 두 맵을 **언제 어떻게 갖게 되는지**(생성 시 상류에서 복사 vs 첫 **⭐ [2026-08-21 확정] 노드가 생길 때의 초기값은 두 맵이 서로 다르다.**
재계산 때 구성)는 §5의 7번 — 관측 가능한 차이는 거의 없지만 아직 안 못박았다.
- **`sourceEmitMap`은 비운 채로 시작한다.** `nil ~= source.count`라 어떤 emit이
와도 "처음 보는 것"으로 걸린다 — 그리고 그게 맞다. 사용자: *"새로 생성된
노드에서 들어온 emit 은, 개념적으로 해당 노드가 한번도 받아본적 없는
emit 입니다."*
- **`sourceCountMap`은 반대로 상류에서 전부 끌어와 실제 count로 채우고,
`rawInvalid = true`로 시작한다.** 이쪽은 비워두면 안 된다 — 순회가 훑을
대상 목록이 곧 이 맵이라, 비어 있으면 **"훑을 게 없으니 유효하다"**로
오판한다. 사용자: *"이건 상류가 주는대로 lazy 할 순 없는게, '내가 뭘
추적하고 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제
count 로 둡니다. 그러고 `rawInvalid` 를 true 로 두세요."*
- 그래서 **`:With`에서 두 상류가 같은 소스에 다른 count를 들고 있을 때의 병합
규칙도 필요 없다** — 어차피 생성 시점의 라이브 count로 통일된다.
**⭐ 대원칙 — 무효화를 결정하는 건 언제나 count 비교지 emit의 도착이 아니다.** **⭐ 대원칙 — 무효화를 결정하는 건 언제나 count 비교지 emit의 도착이 아니다.**
emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도 emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도
@ -76,8 +88,9 @@ count가 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2
- **`GateNode`** — 그 게이트가 **흡수해뒀던 소스 집합**을 확인하라 - **`GateNode`** — 그 게이트가 **흡수해뒀던 소스 집합**을 확인하라
(`base/gate-plan.md`의 4번). 게이트가 `blocker:Off()` 등으로 유보를 풀 때 (`base/gate-plan.md`의 4번). 게이트가 `blocker:Off()` 등으로 유보를 풀 때
쓴다. 쓴다.
**출처는 전파 도중 바뀌지 않는다** — 중간 노드는 자기를 끼워넣지 않고 받은 **평범한 노드는 출처를 안 바꾼다** — 자기를 끼워넣지 않고 받은 출처를 그대로
출처를 그대로 아래로 넘긴다(소스 emit이 원래 그러듯이). 아래로 넘긴다. **`GateNode`만 예외로 언제나 자기 자신을 출처로 새로 낸다**
(`base/gate-plan.md`의 4번).
- **emit을 받았을 때** — 출처가 `Source`면 판정은 O(1)이다, 그 항목 하나만 본다: - **emit을 받았을 때** — 출처가 `Source`면 판정은 O(1)이다, 그 항목 하나만 본다:
1. `sourceCountMap[source] ~= source.count` → 둘 다 `source.count`로 갱신하고 1. `sourceCountMap[source] ~= source.count` → 둘 다 `source.count`로 갱신하고
`rawInvalid = true`를 세운 다음 **뒤로 emit** 한다. `rawInvalid = true`를 세운 다음 **뒤로 emit** 한다.
@ -251,31 +264,21 @@ count가 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2
`base/source-state-plan.md`의 전파 모델 절은 **채택과 함께 그렇게 다시 `base/source-state-plan.md`의 전파 모델 절은 **채택과 함께 그렇게 다시
썼다.** 썼다.**
7. **[2026-08-21 — (a)는 해소, (b)/(c)만 남음] 두 맵의 초기값·병합·재계산 시 7. **[2026-08-21 전량 해소] 두 맵의 초기값·병합·재계산 시 갱신 범위.**
갱신 범위.** - **(a) 재계산 후 `sourceCountMap` 갱신 범위** — **자기가 읽은 상류 전부를
- **(a) 재계산 후 `sourceCountMap` 갱신 범위 — 해소.** **자기가 읽은 상류 갱신한다**(사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히
전부를 갱신한다**(사용자: *"invalid 에 대한 계산을 위한 count 테이블은 전부 업데이트 하는건 맞아보입니다"*). §2에 반영.
단순히 전부 업데이트 하는건 맞아보입니다"*). §2에 반영 완료.
- ⚠️ 이 항목을 제기할 때 든 근거("`A:Set(); Z:Set()`이면 같은 값을 두 번 - ⚠️ 이 항목을 제기할 때 든 근거("`A:Set(); Z:Set()`이면 같은 값을 두 번
계산한다")는 **틀렸었다.** 전파는 동기라 `A:Set()`의 파동이 **완전히 계산한다")는 **틀렸었다.** 전파는 동기라 `A:Set()`의 파동이 **완전히
끝난 뒤에** `Z:Set()`이 시작되므로, 그 사이 재계산은 `Z`의 옛 값을 읽는 끝난 뒤에** `Z:Set()`이 시작되므로, 그 사이 재계산은 `Z`의 옛 값을 읽는
게 맞고 통지가 두 번 나는 것도 맞다(사용자 정정: *"싱크라 set 의 emit 이 게 맞고 통지가 두 번 나는 것도 맞다(사용자 정정: *"싱크라 set 의 emit 이
전부 전파 된 다음 z:set 이라 두번 나는게 맞긴 해요"*). 전부 갱신이 전부 전파 된 다음 z:set 이라 두번 나는게 맞긴 해요"*). 전부 갱신이
실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가 `Get()`으로 실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가 `Get()`으로
앞당겨 읽는 경우**뿐이다 — §2의 재계산 항목이 소스. 앞당겨 읽는 경우**뿐이다.
- **(b) 새 노드의 두 맵 초기값 — 열림(낮은 위험).** 원안은 "상류에서 복사". - **(b) 새 노드의 두 맵 초기값** — `sourceEmitMap`**비우고**,
대안은 "첫 재계산 때 자기가 읽은 상류들의 맵을 합쳐 구성". **동기 전파 `sourceCountMap`**전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`**.
덕분에 둘의 차이가 나타나는 경우는 하나뿐**이다 — 게이트가 붙들고 있는 §2의 "노드가 생길 때의 초기값" 문단이 소스.
동안 파생 노드가 새로 생기는 경우. 그때 복사본은 상류의 "아직 안 던진" - **(c) `:With` 병합 규칙** — (b)로 인해 **필요 없어졌다.**
지연분을 물려받아 해제 시 통지를 한 번 더 받고(무해), 첫-재계산본은 이미
최신이라 삼킨다(이것도 무해 — 새 노드의 Observer는 등록 즉시 1회 관측으로
현재 값을 이미 봤다). **에이전트 권고: 첫 재계산 때 구성** — (c)가 자동으로
사라지고 "값을 계산한 시점의 스냅샷"이라는 맵의 뜻과 일치한다.
- **(c) `:With` 병합에서 두 상류가 같은 소스에 다른 count를 들 때 — (b)에
종속.** (b)에서 "첫 재계산 때 구성"을 택하면 그 시점 라이브 count로
통일돼 병합 규칙 자체가 필요 없다. "복사"를 택하면 명시 규칙이 필요하다
(`sourceCountMap`은 더 큰 쪽, `sourceEmitMap`**더 작은 쪽** — 안 던진
게 하나라도 있으면 안 던진 것으로 봐야 통지가 안 죽는다).
## 6. 곁가지 — 폴링용 sugar ## 6. 곁가지 — 폴링용 sugar

View file

@ -1077,3 +1077,40 @@ Observer가 이제 변경당 **1회**만 울어야 하므로 핵심 assert가
재계산 때 구성)과 그에 종속된 `:With` 병합 규칙. 동기 전파 덕에 둘의 차이가 재계산 때 구성)과 그에 종속된 `:With` 병합 규칙. 동기 전파 덕에 둘의 차이가
나는 경우는 **게이트 유보 중 파생 노드가 새로 생길 때** 하나뿐이고 어느 나는 경우는 **게이트 유보 중 파생 노드가 새로 생길 때** 하나뿐이고 어느
쪽이든 무해하다. §5 7번. 쪽이든 무해하다. §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)가
이걸로 전부 닫혔다.

View file

@ -213,13 +213,13 @@
`table.clear`한다. 하류는 출처가 게이트면 그 집합의 소스들에 평소 규칙을 `table.clear`한다. 하류는 출처가 게이트면 그 집합의 소스들에 평소 규칙을
적용한다. **`setup` 시그니처는 안 바뀐다** — 집합을 채우는 건 정책이 아니라 적용한다. **`setup` 시그니처는 안 바뀐다** — 집합을 채우는 건 정책이 아니라
노드이기 때문. `base/gate-plan.md`의 4번, `base/state-epoch-plan.md` §2. 노드이기 때문. `base/gate-plan.md`의 4번, `base/state-epoch-plan.md` §2.
- **[낮은 위험, 2026-08-21] State 에포크 — 새 노드의 두 맵 초기값(복사 vs 첫 - **[해소, 2026-08-21 같은 날] State 에포크 — 새 노드의 두 맵 초기값과
재계산 때 구성)과 그에 종속된 `:With` 병합 규칙.** 재계산 시 갱신 범위(전부 `:With` 병합.** `sourceEmitMap`**비우고**(어떤 emit이 와도 "처음 보는
갱신)는 같은 날 **확정**됐고, 그걸 제기할 때 든 근거는 **동기 전파를 놓친 것"으로 걸리는 게 맞다 — 새 노드는 개념적으로 emit을 받아본 적이 없다),
것이라 사용자가 정정**했다. 남은 (b)/(c)는 **동기 전파 덕에 차이가 나는 `sourceCountMap`은 **전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`**
경우가 게이트 유보 중 새 노드가 생길 때 하나뿐**이고 어느 쪽이든 무해하다 — (비워두면 순회가 훑을 목록 자체가 없어 "유효하다"로 오판한다 — *"'내가 뭘
권고는 "첫 재계산 때 구성". M3 구현 시 정하면 된다. 추적하고 있나' 가 필요하죠"*). 그래서 `:With` 병합 규칙은 **필요 없어졌다.**
`base/state-epoch-plan.md`의 §5 7번. 재계산 시 갱신 범위(전부 갱신)도 확정. `base/state-epoch-plan.md`§2·§5 7번.
- **[해소, 2026-08-21] 공용 게이트 노드의 이름과 표면 — `state:Gate(setup)` - **[해소, 2026-08-21] 공용 게이트 노드의 이름과 표면 — `state:Gate(setup)`
메소드 + `GateNode`로 확정.** 탑레벨 프리미티브는 안 만들고, `Blocker` 메소드 + `GateNode`로 확정.** 탑레벨 프리미티브는 안 만들고, `Blocker`
`state:Block(blocker)` 안에서 그 배선을 쓴다(사용자: *"Gate 는 따로 `state:Block(blocker)` 안에서 그 배선을 쓴다(사용자: *"Gate 는 따로

View file

@ -309,3 +309,17 @@ O절 커밋 직후 사용자가 돌린 리뷰에서 12건이 나왔고 전부
`Z:Set()`이 시작되므로 통지가 두 번 나는 건 중복이 아니라 맞는 동작이다. `Z:Set()`이 시작되므로 통지가 두 번 나는 건 중복이 아니라 맞는 동작이다.
그 과정에서 대원칙 하나가 명문화됐다: **무효화를 결정하는 건 언제나 count 그 과정에서 대원칙 하나가 명문화됐다: **무효화를 결정하는 건 언제나 count
비교지 emit의 도착이 아니다.** 전량은 followup의 Q절. 비교지 emit의 도착이 아니다.** 전량은 followup의 Q절.
## 14. 마지막 단순화 두 건 — 게이트 통과/유보 미구분, 새 노드 맵 비대칭 초기화
사용자가 Q절 기제를 한 번 더 줄였다. 게이트는 **정책 실행 전에 무조건**
출처를 `withheld`에 넣고, 통과시킬 때조차 **자기를 출처로** 전파한다 —
"지연과 비지연을 구분할 이유가 없다". 그래서 Q절이 넣었던 "정책이 emit을
불렀는지 노드가 되짚는다"는 감지 로직이 통째로 사라졌다.
새 노드의 두 맵은 **비대칭**으로 초기화한다: `sourceEmitMap`은 비우고
(새 노드는 개념적으로 emit을 받아본 적이 없으므로 `nil`이 곧 "변경"),
`sourceCountMap`은 상류에서 전부 끌어와 실제 count로 채운 뒤
`rawInvalid = true`(순회가 훑을 목록이 곧 이 맵이라 비워두면 "유효"로
오판). 이걸로 `:With` 병합 규칙이 필요 없어지며 `/code-review` Med-3이
닫혔다. 전량은 followup의 R절.

View file

@ -15,9 +15,10 @@
내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로 내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로
되돌아갔으나, **사용자가 그 자리에서 `emit(self)` + 흡수 집합으로 되돌아갔으나, **사용자가 그 자리에서 `emit(self)` + 흡수 집합으로
확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번). 같이 확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번). 같이
제기됐던 에포크 쪽 세 자리 중 "재계산 시 count 전부 갱신"도 확정, 제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 count 전부 갱신 /
나머지 둘(새 노드 맵 초기값 / `:With` 병합)은 **M3 구현 시 정하면 되는 새 노드는 `sourceEmitMap`은 비우고 `sourceCountMap`은 실제 count로 채운 뒤
낮은 위험 항목**으로 남았다(`state-epoch-plan.md` §5 7번). `rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) —
`state-epoch-plan.md` §5 7번.
그 외 남은 것은 판단이 아니라 구현 시 정할 것들 — `Gate` 그 외 남은 것은 판단이 아니라 구현 시 정할 것들 — `Gate`
생명주기·재진입 계약, M2에 `Blocker`까지 넣을지, 스파이크 `05` 재작성 생명주기·재진입 계약, M2에 `Blocker`까지 넣을지, 스파이크 `05` 재작성
(`luau-test/STATUS.md`). (`luau-test/STATUS.md`).