Haskell Monad/Applicative 비교 리서치 중 사용자가 retractUnder의 꼬리부터 cutoff 로직을 재검토하다 제기한 의심을 pseudocode 손 트레이싱으로 확인 — store가 emit하는 값 자체가 또 State/Source면 같은 (inst,k)에 같은 핸들러가 중복 push되어 안쪽 구독이 등록 직후 스스로 끊기는 실제 체인 파손 버그였음. Dispatch.process에 중복 핸들러 즉시 error 가드를 추가하고, 같은 시나리오를 이미 다루고 있었지만 no-op retract 스텁 때문에 증상을 못 잡던 luau-test/04의 사각지대도 재작성. operator-sugar-plan.md에 Alternative(nil 대체값) 후보 신설. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.8 KiB
2026-08-13 두 번째 세션 — Haskell Monad/Applicative 비교 리서치, State<State<T>> 재진입 디스패치 버그 발견·수정
배경
사용자가 "커링/레이지 이벨루에이션 말고 Haskell에서 가져올 만한 것(모나드,
애플리커티브 펑터 등)이 있는지"를 물으며 시작. 순수 탐색 질문이라 먼저
Explore 에이전트로 quad의 현재 반응형 코어(State/Source, :Compute,
:With, :Apply, Operator.* 카탈로그, Tag/Attribute/Modifier의
Merged/Overridden 병합, Slot의 store-bind 재구독, HKT 언급 여부,
framework-comparison-findings.md의 Haskell 비교 여부)를 그라운딩된 사실로
먼저 조사시킴.
1부 — Haskell 비교 조사 결과
- Functor(
fmap)/Applicative(pure+<*>)는 이미 사실상 가져와 있음 —:Compute(fn)이 fmap,:With(...).Compute(fn)+ trailing-args sugar가 applicative 결합.pure에 해당하는 값 승격도T | State<T>유니온 패턴(CanAnimate등)으로 이미 코퍼스 전반에 자연스럽게 녹아있음. 진행 중인Operator.*콤비네이터 카탈로그도 사실상 애플리커티브 스타일 결합자 모음이라 별도로 가져올 게 아니라 이미 백로그. - Semigroup/Monoid도 이미 있음 —
Tag.Merged(가환)/Modifier.Overridden(비가환)/Attribute.Merged가 정적 결합 함수로 이미 구현됨, Haskell 용어만 안 썼을 뿐. - Monad bind/join이 진짜 흥미로운 후보 — "State 값 자체가 또 다른
State/컬렉션을 가리키고 그게 바뀌면 재구독"하는 패턴이
StoreBind,Slot:Single,NoneHandler의 재귀 재디스패치(process(inst,k,nil)) 세 군데서 각자 따로 재구현돼 있음 — 이름도 없고 일반화도 안 됨. 중요한 점: 이건 "동적:With의도적 비지원"과 충돌하지 않음 — 그 금지 대상은 "의존성 집합이 런타임에 바뀌는 것"이고, join/bind는 의존성이 항상 1개(바깥 State)인 채로 그 값의 정체성만 바뀌는 것이라 별개 축. 일반화 후보로 남겨둠(착수는 안 함, 사용자가 원하면 research/로 승격 가능). - Traversable/sequence는 빈 자리 —
Store.Combine(array, fn)이 positional 정적 인자 버전으로 기각된 적은 있지만, 런타임 길이 배열을 하나의 State로 합치는 Promise.all류 유틸은 논의 자체가 없었음. Slot이 이미 배열 다루는 자체 메커니즘(:List)을 갖고 있어 실제 필요성은 불확실 — 실사용 사례 나오면 재검토할 후보. - Alternative/
<|>(fallback)는 가치 낮다고 처음엔 판단 —if a then a else b end로 충분하다고 봤으나, 후속 대화에서 사용자가 이의 제기(아래 2부). - do-notation/모나드 트랜스포머/Arrow는 스킵 권장 — Luau가 higher-kinded
type(타입 생성자 다형성)을 지원 안 함(코퍼스 전체 확인 결과 이 개념
자체가 등장한 적 없음), 그래서 Haskell처럼 하나의
Functor f => f a인터페이스로 State/Slot/Ref를 통일 못 하고 지금처럼 타입별 개별 구현 (구조적 타이핑,store.key의 type function 합성과 같은 접근) 유지가 맞음.
2부 — Alternative 재검토, State<State<T>> 재진입 버그 발견
사용자가 Alternative를 다시 꺼냄: State<number|nil>:Apply(Alternative(1))
꼴로 nil 대체값을 표현하면 편할 것 같다는 지적 + 두 가지 확인 요청:
Operator카탈로그에 이미 있는지State<State<T>>가 UB로 명시돼 있는지, 그리고retractUnder가 꼬리부터 정리하는 구조인데 같은 키에서 "이미 사용된 핸들러가 재사용"되면 문제 아닌지 — Attribute의rawNew(name)위임은 별도 키로 가니 괜찮을 것 같지만, 순수State<State<T>>는 문제가 실제로 날 것 같다는 가설. "이거 에러 안 나면 치명적일 수 있는데, 해시맵으로 막는 비용은 낮다"는 결론까지 제시.
Explore(opus) 에이전트로 bind-system-plan.md의 Dispatch/retractUnder/
StoreBind pseudocode를 정확히 추적해 검증:
질문 1 답: operator-sugar-plan.md 카탈로그에 nil 대체 콤비네이터
없음 확인. Clamp/Min/Max 항목과 같은 형식으로 Alternative(default)
후보를 신설(카탈로그 확정 규칙 factory(self)->State<U>+:Apply에 그대로
맞음, 업계 선례로 RxJS defaultIfEmpty/Kotlin 엘비스 연산자 등 근거 있음).
질문 2 답 — 사용자 가설 전부 확인됨, 실제 버그로 재현:
store.key = a(State), a:Get() = b(State)일 때 pseudocode를 손으로
대입:
Dispatch.process(inst,k,a)→ StoreBind 매치 →list={SB}→ Observer 즉시 실행 →Dispatch.process(inst,k,b)재귀.b도 State라 같은 StoreBind 싱글톤 객체가 또 매치 →list={SB,SB}— 같은 핸들러가 한 체인에 두 번. 안쪽 Observer가relate:SetStrong(inst,k,·)로 바깥 Observer 참조를 덮어써 바깥 것이 유령 구독으로 샘.- 안쪽 Observer 즉시 실행이 부르는
retractUnder(inst,k,SB,·)는 첫 매치를 찾는 루프(457행) 때문에 항상 바깥쪽 인덱스를 cutoff로 잡음 → 실제 retract 대상은 다음 인덱스(안쪽 자기 자신) → 안쪽 State 구독이 등록 직후 스스로 끊김 → 이후b:Set(...)이 조용히 무시되는 정지 버그. - 코퍼스 어디에도 UB로 명시된 적 없었고(오히려
bind-system-plan.md: 811-813이 "다른 store여도 상관없이 처리 가능"이라 낙관적으로 틀리게 서술), 막는 가드도 전혀 없었음(순환 UB 방어의 전제 — "각 핸들러는 최대 한 번씩만 그 키에서 호출됨" — 를 스택오버플로 없이 조용히 깨는 경로였음). - Attribute의
rawNew(name)위임은 별개(inst,key)체인으로 옮겨가는 것뿐이라 이 버그를 원천적으로 피하지 못함 — 위임된 값 자체가State<State<T>>면AttributeKey체인 안에서 동일 재현.
3부 — 수정 반영 (같은 세션에 즉시)
Dispatch.processpseudocode(bind-system-plan.md)에 "같은(inst,k)에 같은 핸들러 객체가 이미 체인에 있으면 즉시 error" 가드 추가(push 직전, 가장 저비용 지점).- "확정된 디스패치 모델" 절의 "순환은 UB" 불릿 바로 뒤에 이 발견 전문을 신규 불릿으로 추가.
- 811-813행("심지어 다른 store"까지 낙관적으로 커버 가능하다던 서술)과 "Store가 Store를 저장 가능한가" 절(Store 필드 얘기와 State가 emit하는 값 얘기가 다른 축이라는 스코프 혼동이 있었음)을 정정.
operator-sugar-plan.md에Alternative후보 신설 (2부 질문1 답).
4부 — 기존 스파이크 04의 사각지대 발견
luau-test/04-dispatch-chain-retractUnder.luau를 다시 읽어보니, 이 파일이
이미 정확히 이 시나리오(storeA에 storeB를 다시 대입하는 "다단 체인
스트레스 테스트")를 다루고 있었음을 발견. 그런데 손으로 재트레이싱해보니
이 스파이크의 retract가 print만 하는 완전 no-op이라(실제 구독 해제
로직이 없음), 안쪽 State가 "자기 자신을 스스로 retract"하는 버그가 실제로
일어나도 아무 부작용 없이 넘어가버려 겉보기엔(체인 길이만 보면) 3~4단계가
정상으로 통과하는 것처럼 보였음 — 즉 이 스파이크는 정확한 시나리오를
갖고 있었지만 검증 방식의 결함으로 이 버그를 절대 못 잡는 구조였음.
파일 자신의 주석("retract가 할 일이 '구독 해제'라는 본질은 같아서 로직
검증엔 영향 없음")이 바로 이 지점에서 틀렸던 것.
수정: 3단계를 "가드가 실제로 error하는가"(pcall로 감싸 ok==false
확인) + 4단계를 "가드 발동 후에도 같은 자리에 정상 재바인드가 되는가"(체인
길이 2로 복구)로 재작성. Dispatch.process에도 같은 중복 핸들러 가드를
이식. 파일 상단 주석과 확인 포인트 목록도 이 정정을 반영해 다시 씀.
luau-test/README.md의 04 행 요약, 갱신 이력(10차), "결과 확인 후 할 일"
체크리스트에도 추가 — 만약 실측 시 3단계가 error 없이 통과하면(ok==true)
가드 pseudocode 자체의 회귀이니 바로 알리라고 명시.
luau CLI가 이 환경엔 없어 실제 실행 검증은 못 함 — 손 트레이싱만 완료,
사용자가 실측할 때 확인 필요.
핸드오버 시점 코퍼스 동기화
.claude/README.md의bind-system-plan.md/operator-sugar-plan.md행에 이 세션 요약 추가..claude/question.md의Operator항목에Alternative후보 언급 추가.State<State<T>>건은 같은 세션에 발견+수정까지 끝나 "열린 질문"이 아니므로 question.md에 별도 항목 안 만듦.- 코퍼스 전체 grep으로 "심지어 다른 store" 류 낙관적 서술의 다른 사본,
Dispatch.processpseudocode의 다른 인라인 사본이 더 있는지 확인 —bind-system-plan.md와luau-test/03/04뿐이었고,03은 스스로 "다단 체인(retractUnder)까지는 다루지 않음"이라고 이미 스코프를 좁혀둔 파일이라 영향 없음(가드 이식 불필요, 확인만).
남은 것
- Monad bind/join 일반화(위 1부)는 착수 안 함 — 원하면
research/에 정식 후보로 새로 남길 것. Alternative/Clamp/Min/Max등Operator카탈로그 최종 이름·포함 여부는 여전히 사용자 몫, 우선순위 최하(순수 슈가)..claude/luau-test/전체 스파이크(20개,04는 이번에 시나리오 재작성됨)는 여전히 사용자가luau로 안 돌려봄 — M0 착수 전 최우선 게이트 그대로 유지.