사용자 제기 — doc-check.py가 정규식으로 결정론적 판정을 하는데 표기가 흔들리면 문제가 커지니, 정규식을 늘리기보다 "예상 가능 범위"를 컨벤션으로 좁히는 게 싸지 않냐. 실측해보니 날짜 표기는 이미 100% 균일해서 고칠 게 없었고(강제 장치 없이), 드리프트는 절 인용 쪽이었다 — WARN 86건 중 78건(91%)이 절 참조 불일치. 핵심은 그 78건이 코퍼스가 지저분한 게 아니라 **검사기가 못 읽는 것**이었다는 점이다. 이 코퍼스는 `**볼드**` 줄을 하위 절로 쓰는데 headings()가 `#`만 봤다. ## 동작 변화 (문서 정정으로만 보이지만 게이트가 바뀐다) - 절 참조 불일치가 **WARN → ERROR**. 이제 절 인용 오류가 커밋을 막는다. - 절 인식이 `#` 헤딩 + `**볼드**` 절로 확장. 단 볼드는 **빈 줄 다음이나 리스트 항목 머리**만 인정 — 문단이 줄바꿈되며 우연히 줄머리에 걸린 강조를 절로 오인하던 걸 커밋 전 감사가 잡아 조였다. - 인용 길이 상한 60→160자. 60자를 넘으면 매칭 자체가 안 걸려 검사에서 **조용히** 빠져나갔음(위양성보다 나쁜 구멍). - 비교를 공백 무시로(줄바꿈 인용 대응), 선두 장식·상태/날짜 태그 정규화, `initreq/` 대상 인용은 절 검사 면제(읽기 전용 외부 원본). ## 규약 `conventions.md`에 "문서 표기 규약" 절 신설 — 절 인용 규약(의역 금지, 헤딩은 부분문자열/볼드는 앞부분일치, 태그로 닫히는 볼드 캐비엇, blockquote 함정), 세션은 산문 서수 말고 파일 ID로 지칭. 날짜 마커 라벨 어휘 닫기는 사용자 판단 으로 기각(기계 검사 대상이 아니라 읽는 쪽 판단 재료). ## 결과 절 참조 불일치 78 → 0. 36건은 검사기 수정으로 사라졌고(애초에 위양성), 42건은 인용을 실제 절 제목으로 손으로 고쳤다. 마지막 15건은 서브에이전트 3개에 병렬 위임해 추적 — **설계 서술이 유실된 건은 0건**, 대부분 코드 주석·본문 산문· 주제명처럼 애초에 절이 아닌 걸 절로 인용해온 것이었다. 부수로 드러나 같이 고친 것: onchange-plan이 9차 분할 때 일부러 안 옮긴 절을 잘못된 파일로 가리키던 것, brand-plan이 이미 이행된 정정을 "정정 대상"이라 부르던 것, ROADMAP의 blockquote가 인용 줄바꿈 때문에 깨져 있던 것, pre-implementation-audit의 해소된 항목이 "아직 안 고침" 절에 남아 있던 것 (사용자 결정으로 "이미 고침"으로 이동). 커밋 전 감사 4라운드(에이전트 8개)를 돌렸고, 발견 추이는 2→2→1→0이다. 매 라운드 발견이 "직전 라운드 수정이 만든 새 결함"이었던 게 특징 — 규약을 세우는 커밋이 그 규약의 첫 위반자가 된다는 걸 실측으로 확인했다. 상세는 .claude/session/2026-08-16-03-doc-check-section-convention.md. 부수: __pycache__를 .gitignore에 추가하고 추적 해제(32e9db0에 실수로 딸려 들어가 있었음). quad-doc-auditor에 작업 트리를 바꾸는 git 명령 금지 규약 추가 — 감사자가 git stash를 걸어 메인 세션 스테이지가 반복적으로 풀렸다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zk7XHkSfiBfdPLZUQHdZf
9.9 KiB
Brand — 런타임 nominal 타입 판별 통합 메커니즘
[2026-08-13 아홉 번째 세션]
bind-system-plan.md에서 분리됨. 자기 완결적인 유틸이라 디스패치 코어와 같은 파일에 있을 이유가 없었음. 내용은 옮기기만 했고 결정은 하나도 안 바뀜.
상태: base — 동작/구현 방식은 확정, 이름 Brand 자체만 용어 정리
대기(question.md 1번).
Brand — 런타임 nominal 타입 판별 통합 메커니즘, isState를 일반화 (2026-08-07 여덟 번째 세션)
배경: isState(2026-08-07 다섯 번째 세션 확정, :Peek<<T>>(key): T|State<T>|nil가 돌려주는 raw union을 사용자 코드가 분기하려면 판별
수단이 필요했음)와 똑같은 필요가 quad의 다른 branded 타입에도 전부
적용됨 — Observer/Effect/Tag/Attribute/Tween/Blocker/Store/
Source/Slot/None까지, Handler 구현(isHandlable에서 "이 값이
Store인가/Tag인가" 판별, 또는 PropertyHandler의 process 내부에서
"이 값이 Tween인가" 판별 — 2026-08-10 세션부터 isTween은 isHandlable이
아니라 값-레벨 분기에서만 쓰임, base/tween-plan.md 참고)과 사용자
코드 양쪽에서 반복적으로 필요해질 수단이라 isState 하나만 만들고
끝내지 않고 전체를 일관된 메커니즘으로
통합(component-composition-plan.md 4번 절이 이미 "isSource류 판별자로
(isObserver와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐).
구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 사용(문자열 아님).
local Brand = {}
local registry = setmetatable({}, {__mode = "k"})
function Brand.set(x, tag) registry[x] = tag end
function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값
-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님
local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag,
StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, PostRefTag,
ModifierTag =
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서:
Brand.set(newHandle, ObserverTag)
문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안) — Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는 무시할 만하지만, 오타 안전성이 실질적 이득: 태그가 오타난 문자열 리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블 레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이 되어 헷갈릴 여지가 없음.
isX는 Brand를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈 —
단순 항등인 경우(isObserver(x) = Brand.get(x) == ObserverTag)와, 상위
관계(subtype)가 있어 더 구체적인 브랜드 체크 위에 OR로 얹는 경우
(isState/isRef)로 갈림. [정정, 2026-08-09 열한 번째 세션] 후자를
"집합 멤버십"(t == A or t == B, 플랫한 셋 체크)으로 구현하던 방식을
"더 구체적인 predicate를 먼저 정의하고 그 위에 얹는" 합성 방식으로
재정리 — 동작은 동일하지만, 어느 predicate가 다른 predicate를 내포하는지
(포함 관계의 방향)가 코드 모양 자체에 드러나게 함:
local function isSource(x)
return Brand.get(x) == SourceTag
end
local function isState(x)
return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족
end
local function isPreRef(x)
return Brand.get(x) == PreRefTag
end
local function isPostRef(x) -- [2026-08-14 아홉 번째 세션] PostRef 확정
return Brand.get(x) == PostRefTag
end
local function isRef(x)
-- PreRef/PostRef가 Ref 런타임을 재사용 = 둘 다 Ref의 한 종류
return isPreRef(x) or isPostRef(x) or Brand.get(x) == RefTag
end
정정 — isSource는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을
뒤집음(2026-08-07 여덟 번째 세션). 그때는 "State면 충분한 용도"만
염두에 뒀지만, Source는 State보다 진짜로 더 많은 능력(:Set/:Emit)을
가진 진짜 서브타입이라 "이 값이 (읽기 전용이 아니라) 쓰기도 되는
원천인가"를 알아야 하는 코드는 isState만으론 부족함 — isSource를
별도로 제공, isState는 여전히 {State, Source} 둘 다 통과시킴(상위
개념이니까 당연히). component-composition-plan.md 4번 절이 이미
isSource가 존재한다고 가정하고 있었던 것과도 이걸로 정합됨(그동안 두
문서가 서로 모순돼 있었음). base/modifier-plan.md의 "isState(x): boolean 필요" 절에 있던 "별도 isSource 불필요" 서술은
session/2026-08-07-08-none-sentinel-dispatch-brand.md에서 이미 정정됨.
갭 보강 — isRef/isPreRef/isModifier가 태그 목록에서 빠져있던 것
추가(2026-08-07 열 번째 세션), 이후 isRef/isPreRef 관계 자체가
재정정됨(2026-08-09 열한 번째 세션). 처음엔 isRef/isPreRef를
isObserver와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로
취급(isRef(preRefInstance)가 false)했으나, 이건 isState/isSource
쌍과 비일관적이었음 — Source가 State를 구조적으로 만족하듯,
PreRef도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함
방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적이라는 지적으로 뒤집힘.
isPreRef(x)가 가장 구체적인 항등 체크(Brand.get(x) == PreRefTag),isRef(x)는 그 위에Brand.get(x)==RefTag를 OR로 얹은 상위 개념 — 즉 이제isRef(preRefInstance)는true.(v=Ref)children 배열 leaf 매치 핸들러(Dispatch/Leaf.luau)는 이제isHandlable을isRef(v) and not isPreRef(v) and not isPostRef(v)로 명시적으로 좁혀야 함([2026-08-14 아홉 번째 세션]PostRef확정으로 제외 항이 하나 늘어남) — 예전처럼isRef자체가 배타적이라 저절로 걸러지는 게 아니라, "Ref이긴 한데 그 중 Pre/Post는 아니다"를 호출부가 명시적으로 말해야 하는 모양으로 바뀜(두 pre-pass 소진이 이미 걸러줘 정상 경로에선 거의 안 걸리지만,base/ref-plan.md의 두 동적 경로 가드 Handler와 이 조합이 같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장).isModifier도 같은 단순 항등(Brand.get(x) == ModifierTag, 상위 개념 없음).PostRef도PreRef와 완전히 같은 포함 방향 —Ref런타임을 그대로 재사용하고 브랜드 태그만 다르므로isRef(postRefInstance)도true. 즉isRef는 이제{Ref, PreRef, PostRef}셋을 통과시키는 상위 개념이고,isPreRef/isPostRef가 각각 가장 구체적인 항등 —PreRef/PostRef사이엔 포함 관계가 없음(서로 배타적인 형제).
같은 이유로 isSlot/isEffect도 명시(2026-08-09 세션) —
Brand.get(x) == SlotTag/Brand.get(x) == EffectTag인 단순 항등
predicate, 태그 자체는 원래부터 목록에 있었지만(SlotTag) isX
wrapper로 명시적으로 안 적혀 있던 것을 base/modifier-plan.md의
"Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이
들어오면 즉시 error" 절이 필요로 해서 이번에
같이 적음.
None은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.
Observer/Store처럼 인스턴스가 여러 개 생기는 타입과 달리 None은
quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 x == None
레퍼런스 비교가 더 싸고 정확함. 다만 Brand.get(x)가 "quad가 아는 모든
값의 태그를 답해주는 범용 introspection 창구"(quad-debug 같은 도구가
"이 값이 뭐냐"를 물어볼 단일 창구) 역할까지 겸하게 하려면 None도
빠지면 안 되므로, Brand.get이 내부적으로 x == None을 먼저 확인하는
특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — isNone은
바로 이 특수 분기의 실제 구현체가 됨(별도로 새로 만들 것 없음).
duck-typing(예: type(x) == "table" and x.Compute ~= nil)을 쓰지 않는
이유: Peek가 돌려주는 T는 Modifier 필드에 들어갈 수 있는 임의의
값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를
가진 T에 false positive가 나거나, 일부 Roblox userdata는 정의 안 된 키
인덱싱 자체에서 에러를 던지므로 duck-typing이 pcall로 감싸야 하는 지저분한
엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음.
weak-key 레지스트리는 rbvm 네임스페이스 추적(base/lifecycle-pattern.md)과
같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이
GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조
레지스트리인 Observer의 :Subscribe 레지스트리와는 반대 성격).
Luau 타입 narrowing은 자동으로 안 됨 — 명시적 :: 캐스팅 필요(사용자
확인, Luau가 원래 그렇게 동작함). isX(v)가 참이어도 Luau 컴파일러가
v의 정적 타입을 알아서 좁혀주진 않음(TypeScript의 x is T류 사용자
정의 타입 가드를 Luau가 지원 안 함) — if isState(v) then local s = v :: State<any> ... end처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 게 실제
패턴. 여전히 duck-typing/pcall보다 훨씬 안전하니 가치는 있음, 다만
"자동 narrowing"을 기대하면 안 됨.
이름은 전부 가칭 — Brand/ObserverTag류 포함 용어 정리 대상,
.claude/question.md에 반영.