책임 배치 재설계

누가 무엇을 책임지는가

도메인을 여덟 구획으로 나누고, 객체 53개가 각각 무엇을 알고 · 어떤 질문에 답하고 · 무엇을 일부러 모르는지 적었다. 구획은 수명으로 나뉜다 — 오래 사는 지식 / 쌓이는 사실 / 요청 한 번 / 남는 답. 객체를 누르면 상세가 열린다.

8 구획 53 객체 20 책임 이동 14 개명 8 신설 · 19 재배치

그림 1 — 구획

여덟 구획, 네 수명

구획을 가르는 기준은 기능이 아니라 수명이다 — 얼마나 오래 사는가가 다르면 같은 상자에 둘 수 없다. 카드를 누르면 그 구획의 객체만 아래에 남는다.

오래 사는 지식 노선·규칙 — 잘 안 변하고, 변하면 버전이 붙는다 쌓이는 사실 관측·채점 — 고치지 않고 덧붙이기만 한다 요청 한 번 증거·선택·라인 — 답 하나 만들고 사라진다 남는 답 판정과 계보 — 언제든 같은 재료로 다시 만든다

그림 2 — 객체 카탈로그

객체 53개와 그 책임

굵은 남색 테두리는 규칙을 소유한 객체 — 이 여섯이 판정의 모든 판단을 나눠 갖는다. 은 이번에 책임이 옮겨 들어온 자리, 신설 은 새로 생긴 개념이다.

노선 지식

오래 사는 지식

노선·정류장·차량이 무엇인지, 순번 축이 무슨 뜻인지 안다

관측 사실

쌓이는 사실

어느 시각에 어느 차가 어디에 어떤 좌석으로 있었는지 안다

증거

요청 한 번

이 판정에 쓸 재료를 한 덩어리로 갖고, 재료가 갖춰졌는지 스스로 답한다

판정 규칙

오래 사는 지식

만석의 정의·확률의 산출·재료 요구·수용 조건을 소유한다

직전 버스 선택

요청 한 번

내 정류장을 지난 차들의 줄과 그 줄에 관한 모든 질문을 소유한다

답과 계보

남는 답

무엇을 언제 물었고 무엇을 답했는지, 그 근거가 무엇인지 안다

채점

쌓이는 사실

그 시각 실제로 만석이었는지를 서빙과 같은 라벨 함수로 판정한다

파이프라인

요청 한 번

순서만 안다. 판정 규칙을 하나도 갖지 않는다

그림 3 — 협력

질문 하나가 답이 되기까지, 열 번의 대화

실측 질문 — “18:40, 농수산물시장(순번 61), 탈 수 있어?” — 이 열 번의 메시지로 답이 된다. 파이프라인이 보내는 화살표 중 판정 문장은 하나도 없다. 묻기만 하고, 판단은 전부 답하는 쪽이 한다.

  1. 1

    애플리케이션RouteStops

    “이 노선 축에서 농수산물시장은 몇 번째 점인가?”

    anchorAt(StopRef) → StopAnchor(61)

  2. 2

    AssessmentPipelineBoardingChanceModel

    “이 스택이 3330에 답할 수 있는가?”

    covers(RouteRef) → true (게이트 1.5 통과, 일 1,000콜 쿼터 방어)

  3. 3

    AssessmentPipelineSnapshotSource

    “3330 노선 전체 사진 한 장 다오”

    fetch(RouteRef, 창 경계) → RouteSnapshot(차량 20대, 18:40:25)

  4. 4

    ObservationWindowFullnessPolicy

    “잔여 0석은 만석인가?”

    judge(SeatReading) → Full (창 전체 20대에 각각) → JudgedWindow

  5. 5

    AssessmentPipelineJudgedWindow

    “순번 61을 이미 지난 차들을 나에게 가까운 순으로 세워다오”

    precedingOrderFor(query, trips) → PrecedingOrder

  6. 6

    PrecedingOrderPrecedingTieBreaker

    “같은 순번을 지난 두 대 중 누가 나에게서 덜 멀어졌나?”

    compare(a, b) → TrackPhase 서열 → TieBreak.Resolved(2, LEAST_ADVANCED)

  7. 7

    AssessmentPipelineEvidenceRequirement

    “이 모델이 요구한 재료가 이 증거에 있는가?”

    admits(Evidence) → Admitted (사진 0장이면 Rejected(NO_OBSERVATION))

  8. 8

    AssessmentPipelineAdmissionSpec

    “이 상황이 모델이 요구한 조건을 갖췄는가?”

    admits(BoardingSituation) → Admitted (앞차 있음·라벨 알려짐·임의 아님·앵커 신선)

  9. 9

    BoardingChanceModelPrecedingOrder

    “직전 버스는 만석인가?”

    head().fullness() → Full → Probability(5340).complement() = 4660 (46.6%)

  10. 10

    Assessment.ofChanceState

    “이 확률 칸을 사용자 어휘로 뭐라 하나?”

    from(chance) → OK (몇 대·몇 분이 MODEL_ABSENT여도 OK다)

무엇이 바뀌었나

책임 이동 20건 · 개명 14건

경계(5 애그리거트·슬롯 2계약·순수 구역·금지 관계 4)는 그대로다. 바뀐 것은 규칙이 사는 자리와 이름뿐이다.

책임 이동 20건 — 무엇이 어디서 어디로
옮긴 지식·규칙어디서어디로왜 그쪽이 자연스러운가
만석 라벨 정의(잔여 판독 → 라벨) 채점 리졸버의 remainSeatCnt ≤ 0 하드코딩(design_backtest §4.2-3) FullnessPolicy.judge 단독 상수 4개가 바로 이 정의로 과거 관측을 라벨링해 뽑은 값이다. 만석 기저율 9.90%라 라벨이 갈리면 1순위 지표인 보정도가 조용히 무의미해진다. 우테코 대응 — 로또 Rank가 등수 판정을 독점하는 것
라벨링 슬롯의 입력 폭 judge(VehicleObservation, VehicleSpec) judge(SeatReading) → FullnessLabel design_acl §2.3이 「만석 판정은 정원을 필요로 하지 않는다」로 못 박고 v1은 정원을 읽지 않는다. 정원을 인자로 남기면 라벨 하나를 내려고 학습·채점 리졸버가 정원 사전을 조회해야 해서, 슬롯 2계약이 지키려던 재사용성을 계약 자신이 깎는다. 임계 변형이 실제로 오면 인터페이스 한 줄이 넓어지고 봉투는 안 바뀐다
라벨링의 수신자 JudgedWindow.label(policy) — 산출물이 자기를 만든다 ObservationWindow.labelWith(FullnessPolicy) → JudgedWindow roles §1.1 4단계 입력이 ObservationWindow다. 정보 전문가로도 라벨 없는 창이 라벨을 받는 쪽이다
채점 라벨 타입 OutcomeLabel 4값(만석 축과 채점가능 축을 한 enum에) sealed Outcome { Resolved(FullnessLabel, OutcomeBasis) | NotObserved | GapCovered } 라벨 타입이 하나뿐이면 정책을 부르지 않고 라벨을 만드는 길이 표현 불가능해진다. 두 record 컴포넌트로 쪼개면 NotObserved + Full 같은 불가능 조합이 생기므로 sealed다. 승계 건은 폐기하지 않고 OutcomeBasis로 층화한다
접기 4종과 그 사유 4종(앞차 없음·라벨 미상·임의 해소·앵커 낡음) AssessmentPipeline이 봉투를 뒤져 대조 AdmissionSpec.admits(BoardingSituation) → AdmissionVerdict 요구는 spec이, 재료는 봉투가 갖는데 라인은 둘 다 안 갖고 꺼내 비교했다(생활체조 9). 사유는 접기 판정의 산출물이라 같은 객체가 답해야 접기 조건을 고칠 때 사유가 따라온다. 우테코 대응 — 블랙잭 State.isFinished()
NO_OBSERVATION 접기(요구 ∋ 관측창 && 빈 창) AssessmentPipeline EvidenceRequirement.admits(Evidence) → AdmissionVerdict J6이 확정한 접기 근거를 그대로 두고 주인만 준다. EvidenceItem을 지우지 않으므로 관측 없이 답하는 chance/base-rate 경로가 닫히지 않는다. 「사진이 없다」와 「차가 0대다」는 Evidence의 두 술어가 가른다
사유 우선순위(빈 창은 앞차 부재보다 먼저) 산문(verdict J1) 단계 순서 — 재료 축(EvidenceRequirement) 먼저, 상황 축(AdmissionSpec) 다음 우선순위가 아니라 발생 단계 차이다(3 성형 vs 5 선택). 라인이 아는 것은 순서뿐이라는 계약과 정확히 일치하므로 타입을 새로 만들 필요가 없다
동률 임의성 판단(해시로 갈렸는가 ∧ 동률 후보 라벨 불일치) AssessmentPipeline의 AMBIGUOUS_PRECEDING 가드 PrecedingOrder.isAmbiguous() 두 정보를 동시에 가진 객체는 대열 하나다. 선택은 이미 끝났고 라벨은 4단계에서 붙어 있으므로(J6) 「선택은 좌석을 보지 않는다」는 깨지지 않는다 — 순서 결정과 사후 질의는 다른 메시지다. 우테코 대응 — Cars.findWinners()
임의 해소의 정의 rule == VEHICLE_HASH TieBreakRule.isPrincipled()의 여집합 — LEAST_ADVANCED만 근거 있는 규칙 LATEST_TRIP_SEGMENT는 「더 최근 바퀴가 왜 나에게 가까운가」의 근거 문장이 어느 문서에도 없다. 근거 없는 규칙이 해시 앞에서 갈라 버리면 가드가 우회되고 잘못된 앞차가 조용히 나간다. v1은 차량당 관측 1건이라 이 항이 도달 불가이므로 커버리지 비용 변화는 0이다
노선 지원 여부(게이트 1.5의 판정) 「활성 스택 선언에서 유도」 — 주인 없음 BoardingChanceModel.covers(RouteRef), 실제 소유는 ParameterSet 노선 커버리지를 아는 것은 파라미터다. ModelStack.supports는 성립하지 않는다 — v1 episode·wait가 unavailable@builtin이라 전 슬롯 합의면 전건 차단되고, 확률 슬롯만 보게 정의하면 이름이 거짓말한다
미추정 사유 → 사용자 어휘 사상표 단계 7과 enum에 이중 선언 ChanceState.from(chance) 단독. 단계 7은 부르기만 한다 단일 인자 시그니처가 「확률 칸에만 의존한다」를 강제한다. 소유자가 둘이면 한쪽을 고칠 때 다른 쪽이 남는다
통과 국면 3값과 그 서열 어댑터가 boolean dwelling으로 접음 + TieBreakRule.STATE_PROGRESS 이름 안의 2>0>1 TrackPhase(DEPARTED|IN_TRANSIT|ARRIVING)를 TrackPosition이 소유 확정 규칙이 확정 타입 위에서 실행 불가능하다 — 정규화가 stateCd 0과 2를 같은 값으로 접는다. 실측 201건에서 두 상태가 관측의 81.1%를 차지하고 정규화 후 동률군 22개 중 2개가 이 구분을 실제로 요구한다. 개명으로 끝나지 않는다. 우테코 대응 — 원시값 포장, boolean 하나가 세 국면을 겸하지 않게
첫 정류장 이전의 착지점 TrackPosition.lastPassedStop : StopSequence(≥1) — 정규화가 0을 만들어 생성 불가 TrackPosition(PassedStopMark mark, TrackPhase phase), mark = Passed | BeforeFirstStop 첫 정류장 도착 관측이 타입으로 생성 불가인 크래시 축이다. J8-9가 답 축에서 도달 불가로 만든 BeforeFirstStop의 진짜 자리가 관측 축이다. 통합이 아니라 합성이므로 답 record는 국면을 싣지 않는다
축 거리 계산(beyond) PrecedingBusView.Found의 저장 필드 + 불변식 B2 PassedStopMark.Passed.beyond(StopSequence mine) → SequenceGap 파생을 필드로 두고 불변식으로 정합을 지키는 형태다. direction을 「저장하면 진실이 둘이 된다」로 뺀 잣대와 같다. 필드 하나와 불변식 하나가 동시에 사라진다
순번 해소와 1..N 상한 검증 「애플리케이션 계층의 책임」(object §3.1) RouteStops.anchorAt(StopRef) → StopAnchor roles §1.1이 이미 단계 1 소유자를 RouteStops로 못 박았고 두 문서가 어긋나 있었다. staOrder 1..N 완전연속 3,139/3,139로 상한 검증이 실제로 가능하다. 축 간 비교 금지는 옮기지 않는다 — A12가 이미 구조로 막는다
신선도 계산 Freshness.between 정적 팩토리 + EvidenceTiming.freshness 필드 AssessmentAnchor.freshnessOf(Instant observedAt) → Freshness 판정 시각의 주인이 재는 것이 맞고 필드는 파생이다. 다만 앵커가 관측창의 상한까지 흡수하지는 않는다 — 흡수하면 증거가 질의에 묶여 1콜 재사용과 A/B 조인 전제가 동시에 무너진다
좌석 근거의 국면 SeatBasis { AFTER_DEPARTURE, ARRIVAL_ONLY } EvidenceTiming.seatPhase : TrackPhase 삭제하면 1→2 전이 미갱신 30.7%의 유일한 감시 손잡이가 사라지고, 존치하면 국면 축의 2값 접힘이 남는다. 승격이 둘을 동시에 푼다 — 감시는 그대로, 타입 하나 감소, 2값이 3값으로 정밀해진다. TrackPhase는 좌석을 모른 채로 남는다
후보 단위(차량별 최신 바퀴) 소유자 흐림 — 선택 규칙문에만 존재 TripSegments.latestOf(VehicleRef), 선택에 인자로 건넨다 — precedingOrderFor(query, trips) FIT #2가 「차량이 아니라 바퀴가 후보 단위」를 확정했는데 선택 소유자가 그것을 갖고 있지 않았다. 30분 창의 21%에서 같은 차량이 재등장하므로 v1에서도 살아 있는 어긋남이다
차량별 정원 조회 Map<VehicleRef, VehicleSpec>을 꺼내 밖에서 조회 VehicleSpecs.capacityOf(VehicleRef) 일급 컬렉션이 자기 조회를 소유한다(디미터). 다만 라벨링은 더 이상 정원을 묻지 않으므로 남는 독자는 부가 표현·재조립뿐이다
추정 슬롯이 받는 것 JudgedInput 8칸 BoardingSituation(AssessmentAnchor, PrecedingOrder) 2칸 8칸 중 4칸에 독자가 없고, window+vehicleSpecs는 모델 안에서 만석 정책을, window+query는 직전 버스 선택을 재구현할 재료다. 하중을 받는 논거는 계보의 일의성(roles §1.3-2 ②)이며 선택 축과 라벨 축에 똑같이 적용된다. 잣대는 「v1에 독자가 있는가」 하나만 쓴다 — 그래서 배차·조립 각인도 남기지 않고, 새 재료는 변경축 9(봉투 컴포넌트 추가)가 흡수한다
개명 14건 — 이름이 실체를 말하도록
이유
DataState ChanceState 확률 칸에서만 파생하는데 이름이 판정 전체의 자료 상태로 읽힌다. v1에서 몇 대·몇 분이 비어도 OK인 이유가 이름으로 설명돼야 한다
JudgedInput BoardingSituation 창이 빠지면서 「Judged」가 가리키던 것이 사라졌다. 내용(2칸)과 이름은 별개 결정으로 올린다
TieBreakRule.STATE_PROGRESS TieBreakRule.LEAST_ADVANCED 순서 근거가 소스 코드값의 크기임이 이름에 새어 있다. 실제 규칙 문장은 「내 정류장에서 덜 멀어진 순」이다
TrackPosition.dwelling(boolean) TrackPosition.phase(TrackPhase) boolean 하나가 세 국면을 겸해 확정 타이브레이커가 계산 불가능했다
SeatState / Occupancy FullnessLabel 세계의 상태가 아니라 정책의 산출물이다. 폐기 확정 후에도 원본 문서에 잔존
SeatCount SeatReading 확정 명칭. design_acl 잔재 정리
VehicleSighting / SightingBatch / SightingSource / SightingArchive VehicleObservation / RouteSnapshot / SnapshotSource 관측·묶음·포트의 어휘가 두 벌로 살아 있다. 같은 것에 이름이 둘인 것이 소스 어휘 누수보다 잦다
VehicleId / VehicleKey / vehicleHash VehicleRef 차량 불투명 ID의 이름이 넷이다
busesToSend / SkipCount · expectedWait busesToSkip · waitTime 답 두 칸의 이름이 각각 넷·둘이다(사이트 필드표와 도메인이 어긋나 있다)
JudgedInput.fullnessRef LabeledEvidence.labeledBy 등호로 비교되던 두 값(fullnessRef == labeledBy)이 한 이름으로 합쳐지고, 봉투에서 빠져 라벨과 함께 다닌다
Lineage.observedAt Lineage.latestObservedAt 관측 축의 observedAt과 이름이 같은데 뜻은 「창 전체의 최신 관측」(소스 정지 감지)이다
EvidenceRef.Ephemeral.fetchedAt EvidenceRef.Ephemeral.collectedAt 시각 3종 확정(observedAt / collectedAt / judgedAt)과 어긋나는 네 번째 이름이었다
enum Freshness { FRESH, STALE, NONE } record Freshness(Duration) 하나 같은 이름 다른 타입이 두 문서에 공존한다. 판정 술어는 exceeds(threshold)가 갖는다
PreviousBus / PrecedingBus PrecedingBusView(답 축) · PrecedingOrder(입력 축) 앞차 개념의 영문 이름이 셋이었다. 두 축은 J4가 확정한 분리이므로 유지하고 이름만 둘로 고정한다. PrecedingOrder는 개명하지 않는다 — 「Order」가 index 0 = 직전과 v2 FULL 접두의 의미를 지고 있다