무엇이 참이어야 하는가
이 문서는 코드를 주지 않는다. 정의 · 불변식 · 경계 · 아직 안 정한 것을 준다. 클래스 골격과 메서드 시그니처는 객체 구조에, 표와 열은 파이프라인 계약 v4에, 조립 절차는 백엔드 API에 이미 있다. 여기 적는 것은 그 문서들을 읽고도 두 사람이 다르게 만들 수 있는 자리다.
2026-08-19 작성 · 클라이언트 계약 2.0.0 · 파이프라인 계약 v4 · DDL V4__a18_seat_forecast.sql 기준
목차
| 절 | 무엇에 답하나 | 읽는 때 |
|---|---|---|
| 1. 한 장 요약 | 무엇을 만드나 · 무엇이 아직 안 정해졌나 | 처음 한 번 |
| 2. 용어 사전 | 이 말이 정확히 무슨 뜻인가. 무엇이 아닌가 | 계약 문서를 읽다 막힐 때마다 |
| 3. 구현 포인트 | 어기면 무슨 일이 나는가 · 어떻게 확인하나 | 짜기 전 · PR 올리기 전 |
| 4. 결정이 필요한 자리 | 구현자가 혼자 정할 수 없는 것 | 착수 전 |
| 5. 경계 조건 | 이 상황에서 서버는 무엇을 하나 | 분기 짤 때 |
| 6. 검수 기준 | "다 됐다"고 말할 수 있는 조건 | 끝내기 전 |
| 7. 작업 순서 | 무엇부터 만들어야 다음이 안 막히나 | 일정 짤 때 |
§2의 "이것이 아니다" 열과 §3의 "어기면" 열, 그리고 §4 전체가 이 문서의 값어치다 — 나머지는 다른 문서에도 있다.
1. 한 장 요약 먼저 이것만
정류장마다 "곧 오는 버스"와 그 버스가 그 정류장에 닿을 때의 좌석 분포를 낸다. 만석 확률은 그 분포의 0 지점이다.
프로세스 둘 · 패키지 셋
| 패키지 | 프로세스 | 하는 일 · 안 하는 일 |
|---|---|---|
collector | A · 배치 | GBIS 응답을 받는 유일한 창구다. 라벨·예측·평가를 만들지 않는다 |
processor | A · 배치 | 학습하지 않는다. 승인된 번들을 검증하고 점수만 낸다. 다만 집계는 한다 — demand_profile은 채점 대상이 아니라 모델이 읽는 입력이다 |
api | B · 요청 | 읽어서 내보낸다. 예측을 계산하지 않고 수집도 하지 않는다 |
두 프로세스는 PostgreSQL을 사이에 두고 만난다. 패키지끼리 직접 못 부르고 ArchUnit 세 규칙이 CI에서 막는다.
같은 이유로 관측 한 행을 읽는 형이 패키지마다 따로 있다(VehicleObservation / ObservedVehicle) — 형을 공유하면 그 순간 규칙이 깨진다.
하루가 어떻게 도나
| 언제 | 무엇이 도나 | 남는 것 | 확정 여부 |
|---|---|---|---|
| KST 03:30 · 노선당 1회 | 노선정보 수집(정류장 목록 · 시간표) | 판본 신설 또는 시간표만 UPDATE | 주기 확정 요일 세 벌 불일치 시 실패 처리 미결 |
| 15~600초 · 적응형 | 차량위치 poll → 관측 저장 → commit | location_poll · vehicle_observation | 단계표 정본 갈림 6단계표 대 3단계표 |
| poll commit 직후 · 별도 트랜잭션 | 추론 → 예보 행 → forecastCompletedAt 찍기 | vehicle_stop_prediction | 확정 |
| 주기 미결 | 라벨 회수 배치 | settlement · arrivedSeats | 주기·창 둘 다 미결 |
| 주기 미결 | 셀 통계 재계산 | demand_profile · revision +1 | 주기·주체 미결 |
| 요청마다 | 최신 스냅샷 하나를 골라 조립 | /board 응답 | 확정 |
셋째 줄이 이 파이프라인의 심장이다 — 관측 commit과 예보 쓰기를 같은 트랜잭션에 묶지 않는다. 수집의 4단계 차감 사다리에 모델 추론 시간이 섞이면 안 되기 때문이다. 넷째·다섯째 줄은 주기가 안 정해져 있고, 그 둘은 서로 묶여 있다 — 회수가 느리면 온라인 이동 보정이 하루 종일 안 걸린다(B7).
이미 정해진 것과 아직 아닌 것
확정 그대로 만들면 된다
- 응답
- 클라이언트 계약 2.0.0 — 필드 이름 · 상한 ·
required·additionalProperties: false - DDL
- 마이그레이션 한 장. 제약 시험 10건을 PostgreSQL 16에서 돌려 전부 거절 확인
- 경계
- 패키지 셋 · ArchUnit 세 규칙 · 조립 절차
- 모델
- 0~5단계 구조와 지표(지평 12 종합 70.3 · 브라이어 0.00820 · PR-AUC 0.3729 · 커버율 85.5%)
- 제거
- 다섯 —
ForecastPublication·StopPrediction·PublicationRepository·PredictionResult의NotEstimated갈래 ·NotEstimatedReason.BOARDING_NOT_ALLOWED
미확정 정하고 나서 짠다
- 0순위
- 적재할 계수 번들이 아직 없다. 외부 환경에서 승인 자료로 한 번 더 학습해야 processor가 뜬다
- 큰 것
- 여정 분할 문턱 둘 —
vehicleTripKey적재 전체가 걸린다 - 큰 것
- 시간대 축이 학습(도착 시각)과 서빙(예측 시각)에서 다르다
- 장부
- 착수를 막는 결정 20 · 나중에 정해도 되는 것 22(§4)
- 장부
- 그중 계약이 이미 세어 둔 미결 16 · v3에서 넘어온 3
- 장부
- 원천끼리 다르게 말하는 자리 11(C1~C11)
장부의 두 축은 다른 것이다. "미결 16"은 계약이 스스로 세어 둔 목록이고, "막는 것 19"는 이 문서가 착수를 막느냐로 다시 가른 수다. 둘은 겹치되 같지 않다 — 계약 미결 중 아홉은 4-3에서 "나중"으로 내려갔고, 계약에 없던 자리 열셋이 "막는 것"으로 올라왔다.
몇몇 실측 수치가 원천마다 다르다. 이 문서는 양쪽을 다 적고 어느 쪽도 고르지 않았다 — 고르는 것이 §4-4의 일이다.
2026-08-19 에 다섯 건(C3~C7)을 원자료로 판정해 해소했다 — 좌석 결측 비율은 1.07% 로, 정원 말뭉치 두 수는 단위를 붙여(관측 행 대 지평 확장 행), 수집 단계 표는 수집기 설정을 정본으로, 경계는 반열린 구간으로, queryTime 두 벌은 애초에 모순이 아니었음을(중앙 +17.265초가 2.2~20.5초 범위 안이다) 각각 못박았다. 남은 C6·C11 계열은 백엔드가 정할 자리다.
구현에 앞서 한 벌을 정본으로 못박아야 한다. 그때까지 이 문서의 수치는 방향만 참이고 자릿수는 참이 아니다.
2. 용어 사전 뜻이 흔들리는 말
근거가 없는 것은 원천 없음이라고 적었다. 그 줄은 지어내지 말고 §4에서 정해야 한다.
2-1. 시각 · 판 · 지평
| 말 | 정확한 뜻 | 이것이 아니다 | 근거 |
|---|---|---|---|
관측 시각observedAtresponseReceivedAt |
우리 수집기가 상류 응답을 받은 시각. 두 열의 값은 같지만 권위는 poll 쪽이다 — SUCCESS_EMPTY 판에는 관측 행이 없는데 Board.observedAt은 그때도 필수다 |
GBIS msgHeader.queryTime이 아니다 — 1,294건 전부에서 우리 수신 시각보다 2.2~20.5초 뒤다(별도 92호출 검증: 중앙 +17.265초 · 인접 호출 시각 되감김 16건).HTTP Date 헤더도 아니다 — 초 절삭까지 92/92 일치해 독립 시계가 아니다.도착 예정 시각이 아니다. generatedAt(예보 계산 시각)도 아니다 |
계약 observedAt · models/protocol |
| 한 판 (스냅샷) |
poll 하나. 그 판의 모든 approachingVehicles가 같은 순간을 가리킨다. 고르는 규칙 = outcome IN (SUCCESS_ROWS, SUCCESS_EMPTY) AND forecast_completed_at IS NOT NULL 중 response_received_at 최신 1건.response_received_at에 UNIQUE가 없다 — 동률일 때 무엇을 고르는지는 정해져 있지 않다(B13) |
차량별 최신 관측을 긁어모은 것이 아니다 — 그러면 observedAt 하나가 거짓말이 된다.한 판의 예보 행은 한 트랜잭션 · 한 배포 · 같은 generatedAt이다 |
조립 절차 · ix_poll_forecast_ready |
forecastCompletedAt |
"예보 단계를 지났다"는 표시. 예보 대상이 0건이어도 찍는다 | "예보 행이 있다"가 아니다. 이것을 행 유무로 읽으면 SUCCESS_EMPTY poll이 영구히 후보에서 빠진다 |
ck_poll_forecast |
지평horizonStops |
targetStopOrder − 관측의 stopOrder. 몇 정류장 전에서 예측했는가. 범위 1~12.모델 키다 — 노선별·지평별로 따로 적합하고 채점도 지평별로 가른다. 번들은 노선 2 × 지평 12 = 블록 24 |
시간이 아니다. 분으로 환산하지 마라 — 같은 8정류장이 10%분위 9.1분에서 90%분위 23.0분으로 흩어지고, "5~10분" 구간에는 2·3·4·6·8정류장이 섞인다. 유도값이지만 "그때 계산하면 되는 값"도 아니다 — 모델 키라서 열로 둔다 |
Horizon.MAX · ck_vsp_horizon · 계약 maximum: 12 · ApprovedModelRef.covers() |
시간대timeCellId |
KST 3구간 — morning(07~09) · evening(17~20) · other.서빙은 예측 시각(스냅샷 poll의 responseReceivedAt)으로 정한다. 도착 시각을 모르기 때문이다. 한 판에 한 번 정한다 |
도착 시각 기준이 아니다 — 그런데 학습 라벨의 시간대는 도착 관측 시각에서 왔다. 지평 12 중앙 소요가 24.7분이라 경계(07·09·17·20)를 넘는다. enum이 아니다 — DDL은 varchar(40) NOT NULL뿐이고 CHECK도 없어 오타가 적재된다.정의의 소유자는 이 표가 아니라 featureContractVersion이다 |
미결 · 경계 포함 규칙은 원천 없음 |
경계 포함 규칙이 어디에도 없다. 07~09가 07:00:00 이상 09:00:00 미만인지 이하인지, 20시가 evening인지 other인지 — 원천에 없다. TimeBand.of()도 골격뿐이다.
2-2. 노선 · 정류장 · 판본
| 말 | 정확한 뜻 | 이것이 아니다 | 근거 |
|---|---|---|---|
판본routeReferenceVersionId |
정류장 목록 내용의 판본. contentDigest(char(64))로 식별하고 UNIQUE (publicRouteId, contentDigest). 유효기간은 range exclusion으로 겹치지 못한다.순번의 뜻이 판본에 매인다 — 모델의 축이 station_id가 아니라 stopOrder라서, 판본이 다르면 같은 순번이 다른 정류장이다.지문을 무엇으로 만드는지는 정해져 있지 않다 — 원천은 "네 열은 뺀다"까지만 말한다(B17) |
시간표는 판본 대상이 아니다 — 방향별 첫차·막차 4열만 바뀌면 판본을 새로 끊지 않고 같은 행을 UPDATE한다. referenceVersionId도 validFrom·validTo도 안 움직인다. 그 ID가 클라이언트의 "화면 다시 그려라" 신호이기 때문.stationId에 UNIQUE를 걸지 않는다 — 같은 정류장을 두 번 지나는 노선이 들어오면 그 제약이 적재를 막는다 |
계약 route_reference_version |
| 노선 ID (네 갈래) |
sourceRouteId 공급자 원문(204000057·234000050) — 상류 호출 · API 경로 파라미터 · route.idpublicRouteId 제품 공개 ID(gg-3330) — 도입 여부 미결displayName 표시명(3330) — 화면 노선 번호모델 노선 키( 3330) — 계수 이름의 범주 키 |
문자열이 같아도 같은 개념이 아니다. 표시명과 모델 키가 둘 다 3330인 것은 우연이다 — 늘 같다고 가정하면 안 된다.publicRouteId가 확정되기 전까지 API 경로는 원문 ID다 |
계약 · 클라이언트 계약 |
통과 순번stopOrder |
마지막으로 지나간 정류장 순번. stateCd = 1(도착)이면 stationSeq − 1, stateCd = 0(교차로)·2(출발)이면 stationSeq. phase enum(IN_TRANSIT·ARRIVING·DEPARTED)이 이에 대응한다.stationSeq = 1이고 stateCd = 1이면 이 규칙이 0을 낸다 — route_stop.stopOrder는 1 이상이라 그 행은 복합 FK에 걸려 적재가 실패한다(B19) |
응답의 busLocationList[].stationId를 목표 정류장 ID로 복사하면 안 된다 — 그 값은 응답 시점 차량 위치다."도착 시점 고정"도 아니다 — 도착→출발 전이의 68.8%에서 좌석값이 갱신된다. remainSeatCnt는 정류소 통과 시점의 승차·하차 순변화 값이다(전이 5,352건: 감소 37.3% · 동일 31.2% · 증가 31.5%) |
v3 계약 · 상류 계약 |
승차 가능boardingAllowed |
NOT station_id LIKE '277%'. 적재 시점 한 번 판정하고 하류는 열을 조인해 읽는다. DEFAULT 없음.승차 불가 = 1650 24곳 · 3330 7곳. 승차 가능 = 65 + 78 = 143 |
상류 응답에서 읽는 값이 아니다 — 정류장 원소에 승차 가능 필드도, 그것으로 유도되는 코드값도 없다(원소는 열둘이 전부).mobileNo 없음으로 판정하면 틀린다 — 1650의 서울 중앙차로 19곳도 mobileNo가 없지만 진짜 승차 정류장이고 centerYn: Y로 구분된다.판본 정류장 수(89+85=174)가 아니다 |
참고 구현의 assert · 좌석변화율 중앙 경유 0.000(31곳) 대 일반 0.053(138곳) |
회차turnSequence |
노선 순번 축 위의 한 점. 이하가 UP, 초과가 DOWN. 상류 turnSeq·turnYn에서 온다. null = 단방향.실측 확정값 — 1650 44 · 3330 43 |
여정의 경계가 아니다. 순번의 리셋 지점도 아니다 — 순번은 회차를 넘어 계속 증가한다. 모델 입력이 아니다 — 설계행렬 31열 어디에도 "회차를 넘는가"가 없다. v3 예시의 31은 틀린 값이다. 회차 정류장 자체(안양역 208000069)는 저장하지 않는다 |
상류 getBusRouteStationListv2 |
stopId 대 station_id |
값은 같고 이름만 다르다. DB 열·수집기·추론기는 station_id(상류 GBIS와 맞춘다), 클라이언트 계약은 stopId. 개명 지점은 api 조립 두 곳뿐이다 |
sequence(= stopOrder)는 식별자가 아니다 — 정렬·위치 계산용이다. 노선이 개편되면 순번이 밀린다. 식별은 stopId로 한다 |
조립 절차 |
| 안 쓰는 상류 필드 | 정류장 원소의 x·y(경도·위도)는 받되 저장도 서빙도 하지 않는다. 클라이언트 계약의 stops[]에 좌표 필드가 없다 |
"나중에 쓸 테니 열을 만들어 두자"가 아니다 — 열을 만들면 판본 지문 대상 여부가 또 결정거리가 된다 | 상류 GbisRouteStation · 클라이언트 계약 BoardStop |
directions[]기·종점 |
새 열 없이 유도한다. UP = (stopOrder 1의 이름 → turnSequence의 이름), DOWN = (turnSequence의 이름 → 마지막 stopOrder의 이름). name은 "<종점명> 방면" |
DOWN의 originStopName은 그 방향 첫 정류장(turnSequence+1)이 아니다 — 회차 정류장이다. 타는 사람에게 "안양역에서 출발한다"가 맞는 말이라서.기점과 종점은 이름이 같아도 station_id가 다르다(1650 221000046/221000047, 3330 205000227/205000226) — 이름만 읽는다 |
참고 구현 · 클라이언트 계약 |
2-3. 차량 · 여정 · 좌석
| 말 | 정확한 뜻 | 이것이 아니다 | 근거 |
|---|---|---|---|
여정vehicleTripKey |
같은 차량의 한 운행. 경계는 순번 되돌림 — 종점을 지나 순번 1부터 다시 시작하는 지점. 값 = ( sourceId, vehicleId, routeReferenceVersionId, 여정 시작 관측의 observedAt), 열 텍스트는 |로 이어 붙인 varchar(120).적재 시점 한 번 정한다 — 조회 때 판정하지 않는다. 규칙 소유자는 normalizationVersion |
회차가 아니다 — 회차를 이어서 지난 운행이 1650 94.0% · 3330 72.4%이고 회차 전후 관측 간격이 중앙 0.3분이다. 회차로 끊으면 재구성 운행이 한가운데서 잘린다.null이 "거르라"는 뜻이 아니다 — 서빙에서 거르지 않는다(설계행렬에 slope·prev_seat 결측 지시자가 있다). 못 하는 것은 라벨 회수뿐이고 그 예보 행은 settlement = LOST로 닫힌다 |
계약 · 객체 골격 분할 문턱 둘 미결 |
| 만석 | remainingSeats == 0. 직행좌석형이라 입석이 없어 0석 = 물리적 승차 불가. 좌석 감소량이 곧 순 승차 인원이다 |
-1은 만석이 아니다 — 좌석 미제공(결측)이다. 프로토타입에서 -1을 만석으로 센 실제 버그가 아래 장치의 근거다 |
클라이언트 계약 UnknownSeat |
좌석 미제공seatUnavailableReason |
값은 MINUS_ONE · FIELD_ABSENT 둘뿐. remainingSeats와 XOR(CHECK).규모 — 차량행 314,688 중 237행(0.075%), 연속 18회까지 이어진 사례(9대) |
lowPlateCode = 2(2층버스)는 결측 사유가 아니다 — 차량 유형이다. DOUBLE_DECK을 사유 enum에 넣지 마라 |
계약 · DDL CHECK |
혼잡도crowded |
유효값 1~4. 0은 미산출이라 NULL로 저장한다.실측 규칙 — 2층버스 전 관측 ∪ remainSeatCnt = -1 관측(위반 0건) |
crowded = 0은 "여유"가 아니다.만석 이후 절단을 되돌리는 재료도 아니다 — 만석 구간 169개 중 혼잡도 값이 변한 것은 1개(0.6%)뿐이다 |
상류 계약 실측 |
정원cap |
그 차량이 보여 준 최대 잔여석 그대로(remainSeatCnt의 차량별 최대). 2026-08-19 교정.DB 열을 만들지 않는다 — 유도 규칙과 상수는 번들의 특징 계약이 소유한다. 모델 내부 세 갈래 — cap(원값) · max(cap,1)(앵커·셀 통계에서만 바닥) · cap_eff = min(max(cap,v), 70)(격자). 여기서 v는 그 행의 현재 관측 잔여석이고 70은 좌석 격자의 절대 상한이다 — v > cap인 행이 실제로 있어 max를 씌운다. 설계행렬 5·12번 열은 바닥 없이 그대로 나눈다 |
형식승인 좌석수가 아니다 — 다른 양이라 합치지 않는다. lowPlateCode에서 유도하지 않는다. 45/68 구간화를 없앴으므로 계열 판정이 없다.종전 47석 고정은 어느 근거에도 없던 수였다. DB에 없으니 잔여석/정원 > 1을 CHECK로 막을 수 없다 |
모델 내부 · 특징 계약 |
vehiclesInService |
스냅샷 poll의 storedRows. 새 열이 아니다. 같은 poll 안 차량 중복을 부분 unique index가 막으므로 저장 행 수가 곧 distinct 차량 수다 |
지평 안에 든 차량 수가 아니다 — 그 poll에 나온 차량 전부다. 운행 종료의 단정도 아니다 — 미매핑 관측이 excludedRows로 빠져 작아질 수 있고, 상류가 빈 응답을 주면 운행 중에도 0이 된다(22,010 스냅샷 중 1건, 0.005%) |
ux_obs_vehicle_per_poll · providerRows = storedRows + excludedRows |
라벨 회수settlement |
다섯 값 — PENDING(도착 전·배치 미실행) · SETTLED(도착 관측 있고 좌석 유효) · SEAT_MISSING(도착 관측은 있는데 좌석 결측) · SKIPPED(여정이 대상 순번을 건너뜀) · LOST(대상 순번 전에 여정이 끊김 · 여정 키 없는 행도 여기).채점 · demand_profile · L_p0shift는 SETTLED만 쓴다 |
한 값으로 뭉개면 안 된다 — 모델이 못 맞힌 것과 자료가 아예 없는 것이 같아 보인다.SETTLED만 arrivedSeats가 있다. 나머지 넷은 전부 NULL이다.다섯 값이 서로 배타적이지 않다 — 대상 순번을 건너뛴 뒤 여정이 끊기면 SKIPPED·LOST 둘 다 해당한다. 우선순위가 원천에 없다(B20).arrivedSeats를 어느 관측에서 읽는지도 없다 — 같은 stopOrder의 관측이 여러 건 나오고 도착→출발 전이의 68.8%에서 좌석이 갱신되므로 고르는 관측이 곧 라벨이다 |
계약 settlement |
2-4. 셀과 통계
| 말 | 정확한 뜻 | 이것이 아니다 | 근거 |
|---|---|---|---|
셀demand_profile |
(노선 판본, 정류장 순번, 시간대) 한 조합 = 한 행. featureContractVersion까지가 PK.한 노선·한 시간대의 행 수 = 승차 가능 정류장 수(65 / 78) |
경유 정류장에는 셀을 만들지 않는다 — 그래서 stopOrder가 연속하지 않는다. 순번을 0부터 끝까지 도는 코드를 쓰면 틀린다.노선 ID에 매다는 것이 아니라 판본에 매단다 — v3이 경계한 "현재 활성 판본을 과거 사건에 사후 부착"을 피하기 위해서다 |
계약 · DDL |
| z화 | (노선 판본, 시간대) 안에서, 같은 세대의 행들에서 그때 유도한다. σ + 1e-9, 모집단 표준편차(ddof = 0) |
표준화 상수를 저장하지 않는다 — 저장하면 표가 갱신될 때 상수가 뒤처져 z가 어긋난다. 세대에 걸쳐 안정된 값이 아니다 — 세대가 바뀌면 같은 입력이 다른 z를 낸다. "노선 단위"가 아니다 — 셀 행이 판본에 매여 있으므로 개편 전후 두 판본의 행을 한 통에 넣고 z를 내면 안 된다 |
연구 구현 zmap · demand_profile PK |
| 이웃 폴백 | 같은 (노선 판본, 시간대)에서 0 < |ΔstopOrder| ≤ 4(정류장 순번 차, 승차 가능 정류장만 세므로 물리 거리와 다르다), 가중 1/d², 자기 자신 제외.이웃도 없으면 z값 0을 쓴다 — 원값 0이 아니라 그 그룹 평균에 해당하는 값이다 |
폴백 지시자 miss(31번 열)는 저장 열이 아니다 — 그 셀의 행이 있는지 없는지가 곧 지시자다.miss는 29번(occ_z)의 폴백만 가리킨다. 30번(board_z)의 폴백은 표시하지 않는다 |
연구 구현 neighbor_map |
| 구간합 L_segsum |
무엇의 합인가 — 그 창에 든 셀들의 netDemandMean(순수요)다. 창은 [targetStopOrder − 지평 + 1, targetStopOrder]. 합을 √seen으로 나눈다 |
seen은 창 길이가 아니다 — 창 안에 셀 행이 실제로 있었던 정류장 수다. 경유 정류장이 섞이면 둘이 갈린다.seen = 0이면 이웃 폴백, 그것도 없으면 0.점유율의 합이 아니다 — 점유율은 29번 열( occ_z)로 따로 들어간다 |
연구 구현 cell_cols |
점유율occupancyMean |
그 정류장 도착 시 1 − 잔여석/max(정원,1)의 행별 평균 → 날짜 균등가중 평균. 범위 [0,1]. 원자료는 지평 1 행만 쓴다 |
표본 균등가중이 아니다 — 수집이 적응형이라 날짜별 관측 수가 726~6,206으로 벌어진다. 그래서 sampleCount와 dayCount를 둘 다 둔다(표본 수만으로는 평균을 다시 합칠 수 없다).모든 지평의 행을 쓰는 것이 아니다 — 같은 통과 사건을 지평마다 다시 세면 안 된다 |
연구 구현 cellday_stats |
순수요netDemandMean |
mean(예측시점 좌석 − 도착시 좌석) ÷ mean(max(정원,1)) — 평균의 비율. 범위 [-1,1] |
비율의 평균이 아니다. 음수가 정상이다(하차 우세 정류장) — 범위를 0 이상으로 잡으면 정상 행이 막힌다 |
연구 구현 cellday_stats |
| 과거 만석률 | 셀에 담지 않는다 | 수요 신호가 아니다 — 셀의 87%가 만석 무경험이라 그 값이 사실상 "몇 번 관측했나"의 함수가 되고(만석 0건 셀에서 상관계수 1.000), 관측 횟수는 수요가 아니라 수집 밀도다. 휴일→평일 순위상관도 순수요 0.920 · 점유율 0.805 대 만석률 0.302 |
모델 내부 |
2-5. 예보 · 배포
| 말 | 정확한 뜻 | 이것이 아니다 | 근거 |
|---|---|---|---|
pFullRaw |
1단계(허들 회귀 fit_irls)가 낸 만석 확률. 4단계(L_p0shift) 적용 전의 값이다. 모델 입력이다 — 다음 예보의 이동량을 만드는 재료다 |
채점용이 아니다. 이동량 c = logit(pFull) − logit(pFullRaw)(로짓 단위, |c| ≤ 3)로 되찾을 수 있으므로 이동량을 따로 열로 두지 않는다."6층 통과 직후"가 아니다 — 「6」은 재료 묶음 여섯 개를 세는 말이고 L_p0shift는 그중 하나다. 계산 순서를 가리킬 때는 0~5단계로만 말한다 |
모델 내부 online_shift |
pFullseatAvailableProbability |
pFull은 응답에 쓰는 값이자 좌석 분포의 0 지점. seatAvailableProbability = 1 − pFull.뒤집기는 api 서비스 계층 메서드 한 곳에서만 한다 |
SQL 안에서 뒤집지 마라 — 그 규칙을 시험하는 데 DB가 필요해진다. 반올림하지 마라 — 0.996이 1.00이 되면 "반드시 자리가 있다"로 읽힌다. 검증 스크립트의 round((1 - p_full)::numeric, 4)를 운영 질의로 옮기면 규약이 조용히 깨진다 |
계약 · ck_vsp_pfull |
세대revisioncellProfileRevision |
demand_profile.revision = 재계산 세대 번호, 배치가 한 판 돌 때마다 단조 증가.vehicle_stop_prediction.cellProfileRevision = 그 예보가 읽은 세대 |
PK가 아니다 — UPSERT의 결과다. 재현을 보장하지 않는다 — UPSERT라 옛 값이 남지 않는다. 이 열은 채점을 세대별로 가르기 위한 것이다 |
계약 · DDL |
배포model_deployment |
ACTIVE는 부분 unique index로 전역 1개. STAGED → ACTIVE → RETIRED, 승격은 CAS.Board.model은 그 판의 예보 행이 가리키는 배포에서 읽고, 예보 행이 없는 판(차량 0대)에서만 ACTIVE에서 읽는다 |
읽기 필터가 아니다 — modelDeploymentId는 출처 표시다. ACTIVE로 예보 행을 거르면 승격 직후 한 판의 차량이 전부 사라진다 |
계약 · 조립 절차 |
supportedScopeDigest |
축은 (노선 키, 지평). 지원 밖 지평의 대상 정류장에는 예보 행을 만들지 않고, 그 항목은 응답에서 통째로 빠진다 | "예보 없음" 상태를 만드는 장치가 아니다 — 확정 계약에 그런 상태가 없다. 격자에 구멍을 두고 배포하면 안 된다 |
계약 · ApprovedModelRef.covers() |
| 커버율 | 분모 = 승차 가능 정류장 × 스냅샷 판 = 11,005 × 143 = 157만 칸. 분자 = 상류 [S−지평, S−1] 구간에 그 정류장으로 올 버스가 있는가.지평 12 — 하루 전체 85.5% · 운행시간대(06~23시) 89.9% |
모델 추정치가 아니다 — 운행 기하에서 나오는 값이다. 분자 구간에서 경유 지점을 빼면 안 된다 — 거기 있는 버스도 곧 S에 오는 진짜 버스다 |
모델 내부 |
2-6. 판 축 다섯 — 무엇이 바뀌면 어느 축이 움직이나
| 축 | 어느 표에 | 무엇을 소유하나 |
|---|---|---|
normalizationVersion | location_poll | 여정 분할 규칙. 문턱값이 바뀌면 이 축이 움직인다 |
collectionStrategyVersionadaptive-kst-v1.0.1 | location_poll | 관측 밀도를 기록하는 유일한 축. 수집 단계표가 바뀌면 여기서 갈린다 |
featureContractVersion | model_deploymentdemand_profile | 정원 유도 규칙과 상수 · 시간 셀 정의 · 이웃 폴백 사다리. demand_profile PK의 일부다 |
labelPolicyVersion | model_deployment | 계약 문구는 "사건 ID의 구성 요소이고, v4에서는 제품 DB의 라벨 회수 배치도 이 판을 따른다"까지다. 회수 창과 settlement 판정 규칙이 여기 속하는지는 안 적혀 있다 — B7에서 같이 정한다 |
predictionTargetVersionseat_distribution_on_arrival_v1 | model_deployment | 예측 대상의 정의(도착 시 잔여좌석 분포) |
여정 분할 문턱을 시간대별로 두면(4-2 G2) 여정 분할 규칙이 시간 셀 정의를 참조하게 된다 —
normalizationVersion이 소유해야 할 규칙이 featureContractVersion의 정의에 매달린다.
그러면 시간대 경계 하나를 고칠 때 과거 여정 키가 전부 다른 판이 된다.
두 축이 따로인 것이 설계 의도이므로, G2를 고를 때는 그 대가를 함께 적어야 한다.
3. 구현 포인트 어기면 무슨 일이 나는가
각 줄은 한 문장이다. 길어지는 넷은 표 아래에 따로 뗐다. "어기면" 열에 조용히 가 붙은 줄은 오류로 보이지 않고 정상 응답의 모양으로 화면까지 나간다 — 그 줄들을 먼저 읽어라.
3-1. 수집 collector
| 지켜야 할 것 | 왜 | 어기면 | 확인 방법 |
|---|---|---|---|
-1을 그대로 저장하지 마라 | "모름"과 "만석"은 다른 사실이다 | 조용히 모름이 만석으로 세어진다 — 프로토타입에서 실제로 났던 버그 | WHERE remaining_seats < 0이 0건. remaining_seats IS NULL ↔ seat_unavailable_reason IS NOT NULL 동치 |
| 관측 commit 뒤 별도 트랜잭션으로 예보를 써라 | 수집의 4단계 차감 사다리에 모델 추론 시간이 섞이면 안 된다 | 추론이 느려질 때 호출 예산과 표본 시각이 함께 흔들린다 | 예보 쓰기를 지연시켜도 poll 주기가 안 밀리는지 |
| 노선정보 갱신을 판본 적재 때만 하지 마라 | 정류장 목록이 그대로인 채 막차만 바뀌면 판본이 안 생긴다 | 조용히 시간표가 낡는다 | 노선당 하루 1회(KST 03:30) + 판본 신설 시점 둘 다 도는지. 호출은 call_ledger로 예약 |
요일 세 벌(we*·sat*·sun*)을 다 받아 대조하고 평일 한 벌(접두 없는 upFirstTime·upLastTime·downFirstTime·downLastTime)만 담아라 | 상류는 요일패턴 넷(평일 · sat · sun · we 공휴일)을 준다. 공휴일 값은 사실상 일요일의 복제다 — 양쪽 다 값이 있는 1,927개 노선 중 실제로 다른 것은 10개 | 다른데 못 잡으면 잘못된 시간표가 들어온다 | "대조"의 대상이 원천에 없다 — 세 벌끼리인지, 세 벌 각각을 평일 값과 비교하는 것인지(B12). 불일치 시 적재 실패도 미결 |
| 관측 0건 poll을 삭제하지 마라 | 관측 간격을 세는 바닥이 사라진다 — 모델 쪽 라벨 규칙이 간격 90초 초과를 검열로 남기려면 빈 판도 있어야 한다 | 여정 분할과 밀도 계산의 바닥이 사라진다 | 삭제는 불변 연구 release 검증 뒤에만. 이 90초는 라벨 검열 문턱이고 4-2의 여정 공백 문턱과 다른 값이다 |
resultCode = 4를 재시도하지 말고 운행 종료로 단정하지도 마라 | 재시도는 호출 예산과 표본 시각을 왜곡한다 | 심야에 이 응답이 반복된 뒤 다음 poll에서 차량이 다시 나타난 기록이 있다 | HTTP client의 자동 retry·redirect를 끈다. 의도한 재시도는 새 attempt + 새 permit |
vehId를 JSON number로 재출력하지 마라 | 손실 없는 10진 canonical text로 저장하는 opaque 키다. JS 안전정수를 넘는 값이 있다 | 값이 조용히 뭉개진다 | 전역 ID가 아니다 — 키는 (source_id, vehicle_id). plateNo는 저장하지 않고, 증거 payload는 번호판 필드를 구조적으로 제거한 뒤 해시한다 |
serviceKey를 로그·URL 계측·오류 응답에 남기지 마라 | 자격증명이다 | 유출 | GBIS 오류 본문을 클라이언트로 그대로 전달하지 않는다 |
3-2. 처리 processor
| 지켜야 할 것 | 왜 | 어기면 | 확인 방법 |
|---|---|---|---|
예보 대상이 0건이어도 forecastCompletedAt을 찍어라 | 그 값의 뜻은 "행이 있다"가 아니라 "단계를 지났다" | 여정 끝 차량뿐인 poll과 SUCCESS_EMPTY poll이 영구히 후보에서 빠져 판이 계속 낡는다 | SUCCESS_EMPTY poll에 forecast_completed_at이 채워지는지 |
| 예보 쓰기 시작 시점의 ACTIVE를 트랜잭션 내내 써라 | 그래야 한 판의 행이 언제나 한 배포다 | 한 판에 두 배포가 섞인다 | 한 poll 안 count(DISTINCT model_deployment_id)가 1인지. 값이 갈리는 판이 나오면 그 자체가 사고 신호다 |
p_full = p_full CHECK를 빼지 마라 | NaN을 거절하는 유일한 장치다 | 조용히 응답이 1 − NaN이 되는데 오류로 보이지 않고 화면까지 나간다 | NaN 삽입이 거절되는지(제약 시험 2번) |
| 경유 정류장에 예보 행·셀 행을 만들지 마라 | 스키마가 강제하지 않는다 — FK는 순번의 실재만 본다 | 어긋난 행이 들어와도 적재가 성공한다 | 두 표를 route_stop에 조인해 NOT boarding_allowed인 행이 0인지. api의 WHERE s.boarding_allowed는 이중 방어이지 제약이 아니다 |
| 지평·시각 불변식은 감시 질의로 지켜라 | 다른 표의 값이라 CHECK로 표현할 수 없다 | horizonStops ≠ target − stopOrder이거나 generatedAt < observedAt인 행이 쌓인다 | 6-5의 감시 질의 ①. 트리거 대신 쓰기 경로가 processor 한 곳뿐이라는 것에 기댄다 |
| 셀 하나를 집어 오는 메서드를 두지 마라 | DemandProfileRepository의 메서드가 하나뿐인 것이 요점이다 | 셋이 동시에 깨진다 — z화(그때 유도) · 이웃 폴백(반경 4 안이 손에 있어야) · 구간합(구간의 행들을 합쳐 읽음) | 구조 시험으로 "셀 하나 조회" 메서드 부재를 막는다 |
| 셀 통계 재계산과 차량 추론을 한 배치에 묶지 마라 | 지연 예산이 초(추론) 대 분~시간(통계)으로 다르다 | 통계 한 번 돌 때마다 차량 예보가 멈춘다 | 배치 둘이 독립으로 도는지 |
| 좌석 결측 관측으로는 대상을 만들지 마라 | 설계행렬이 잔여석을 그대로 쓰고(5·6·7·12번 열) 앵커도 거기서 출발하는데 그 자리에는 결측 지시자가 없다 | 결측이 0으로 흘러들어 만석에 가깝게 점수가 난다 | VehicleStopTarget 생성자에서 거절. 지평·순번 차 어긋남도 같은 자리에서 |
| 여정 끝을 넘어가는 대상은 만들지 마라 | 종점 다음의 순번 1은 다른 여정이다 | 한 바퀴 돈 차량을 다음 도착 차량으로 낸다 — 실제로 그런 경우는 5.1%(1650)·15.6%(3330)뿐이고 그 앞에 중앙 4~7대가 먼저 지나간다 | 여정 경계에서 대상 생성이 멈추는지 |
| 모델 계수를 노선·지평 사이에 공유하지 마라 | 합쳐 배우면 편향이 남고 그 편향은 자료를 아무리 모아도 줄지 않는다 | 3330에서 합침 대비 격차가 학습 2일 +0.190 → 4일 +0.029로 6.6배 줄었다 — 격차의 정체가 편향이라는 증거 | 19번 열(route 더미)은 노선별 적합에서 한 적합 안의 상수다 — 절편에 흡수되고 열 수 31은 그대로 둔다 |
| 예보 경로에서 난수를 쓰지 마라 | 같은 입력이면 같은 출력이어야 한다 | 재현이 깨지고 golden vector 대조가 무의미해진다 | 겹 나누기·마스킹은 행 식별자 해시(blake2b)로. 학습 행 순서가 바뀌어도 결과가 같은지 |
| z화 상수를 따로 저장하지 마라 | 세대 안에서 닫힌 연산이다 | 표가 갱신될 때 상수가 뒤처져 z가 어긋난다 | cellProfileRevision이 채점을 세대별로 가르는 유일한 근거다 |
3-3. API api
| 지켜야 할 것 | 왜 | 어기면 | 확인 방법 |
|---|---|---|---|
스냅샷 선택에서 forecast_completed_at IS NOT NULL을 빼지 마라 | 예보가 안 붙은 판은 답이 아니다 | 조용히 그 노선의 차량이 통째로 사라진다. 아래 소절에 따로 적었다 | 예보 쓰기를 강제로 실패시킨 뒤 /board가 그 판을 고르지 않는지 |
| 뒤집기는 서비스 계층 메서드 한 곳. 반올림하지 않는다 | SQL에서 뒤집으면 시험에 DB가 필요해진다 | 0.996이 1.00이 되면 "반드시 자리가 있다"로 읽힌다 | 소수 6자리 이상이 그대로 나가는지. ck_vsp_pfull이 이미 [0,1]과 NaN·±Inf를 막으므로 api에서 다시 자르는 코드를 두지 마라 |
정렬은 서버가 고정한다 — 1축 horizonStops ↑, 마지막 축 source_row_no | vehicleId가 null일 수 있어 축 하나로는 순서가 굳지 않는다 | 같은 자료에서 응답이 흔들리고 상한 3에 걸리는 차량이 바뀐다 | 2축(vehicleId)의 정렬 규칙은 아직 미결이다 — B13. 두 번 요청해 바이트 동일. 정렬을 두 곳에 두지 마라 — 질의가 내려준 순서를 groupingBy가 물려받는다 |
maxItems: 3은 정류장마다 건다 | 노선 전체 LIMIT은 뜻이 다르다 | 먼 정류장의 항목이 통째로 잘린다 | PARTITION BY target_stop_order의 row_number() … rn <= 3. 상한은 조회 규칙이지 저장 규칙이 아니다 |
| 직렬화 규칙을 클래스 전체에 걸지 마라 | 한 클래스에 "필수인데 null 허용"과 "없으면 키째 빠져야 하는 필드"가 같이 있다 | 클래스 수준 NON_NULL을 걸면 vehicleId null일 때 키가 사라지는데 계약은 그것을 required로 잠갔다 | expectedSeats 없을 때 키가 없는지, turnSequence null일 때 키가 있는지 |
route_stop 조회를 예보 조회와 한 질의로 합치지 마라 | stops[]는 판본의 모든 정류장이고 예보는 그중 일부에만 붙는다 | LEFT JOIN이 되어 정류장 하나에 빈 행이 섞이고, "항목 없음"과 "항목 하나"를 조립 쪽에서 다시 갈라야 한다 | 질의 둘이 분리돼 있는지 |
NotEstimated 갈래를 만들지 마라 | 확정 계약에 "예보 없는 항목"이라는 상태가 없다 | 계약에 없는 필드가 나간다 | 행을 못 만들면 그냥 안 만든다. NotEstimatedReason.BOARDING_NOT_ALLOWED는 확정 제거 |
| 부분 실패를 503으로 올리지 마라 | v3은 발행이 한 판 단위여서 그랬다 — v4는 예보가 차량에 붙으므로 그 규칙이 성립하지 않는다 | 정류장 하나 때문에 노선 전체가 죽는다 | 503은 노선 자체를 서빙할 수 없을 때만 |
approachingVehicles가 비었다고 RouteStatus를 내리지 마라 | 축이 다르다 — status는 "답을 낼 수 있는가", 목록은 "지금 낼 답이 있는가" | 매일 밤 노선이 PREPARING으로 떨어졌다가 아침에 돌아온다 | 첫차 전·막차 후에 200 + 빈 배열이 나가는지 |
오류 봉투는 정확히 네 필드, Cache-Control: no-store | additionalProperties: false로 잠겨 있다 | 디버깅용 필드를 하나 얹으면 그것만으로 계약 위반이다 | code · message · requestId · retryable. Retry-After는 실제 재시도 시각을 아는 retryable 503에만 — 모르면 붙이지 않는다(숫자를 지어내면 클라이언트가 그 시각에 몰려 온다) |
| 개명은 api 조립 두 곳에서만 | DB·수집기·추론기는 station_id, 계약은 stopId | 이름이 두 벌로 새어 들어간다 | 응답 JSON에 station_id가 안 나오는지 |
Board의 필수 필드가 언제나 채워지는 근거는 이미 제약에 있다 — observedAt은 ck_poll_forecast가, vehiclesInService는 후보 outcome 둘 다 stored_rows를 가진다는 사실이, stops(minItems: 1)는 "판본이 있으면 route_stop 행이 있고, 없는 판본은 애초에 스냅샷을 갖지 못한다"가 받친다. api에서 방어 코드를 다시 두지 마라.
3-4. 경계와 배포 패키지 · 마이그레이션
| 지켜야 할 것 | 왜 | 어기면 | 확인 방법 |
|---|---|---|---|
ux_obs_context를 먼저 만들어라 | PostgreSQL은 참조 열 목록 그대로에 unique를 요구한다 | fk_vsp_obs 생성 자리에서 마이그레이션이 죽는다 | SQL 3절이 5절보다 앞인지. 아래 소절에 따로 적었다 |
| 배포 순서 — ① 마이그레이션 → ② 노선정보 수집 1회 → ③ api 기동 | 첫차·막차 열이 비면 응답을 만들 수 없다 | ②를 건너뛰면 마이그레이션이 아니라 api가 정상 응답을 못 만든다 — 증상이 다르니 헷갈리지 마라 | directions[].firstDepartureTime이 채워진 뒤 기동하는지 |
| ArchUnit 세 규칙을 CI에서 돌려라 | v4에서 api가 processor의 형을 그대로 쓰고 싶은 자리가 늘어난다 | 경계가 무너지고 두 프로세스가 코드로 엮인다 | 세 규칙 중 마지막(api는 읽기만) 하나가 그 유혹을 막는다. processor는 forecastCompletedAt을 찍을 때도 collector 코드를 부르지 않고 자기 포트로 그 표를 읽고 쓴다 |
3-5. 따로 떼는 넷
스냅샷 선택 질의에서 forecast_completed_at IS NOT NULL을 빼면, 예보가 안 붙은 판을 고를 수 있다.
그때 응답은 HTTP 200이고 approachingVehicles만 전부 빈다. 화면에는 "곧 오는 버스 없음"으로 나가고 정상 상태와 구별되지 않는다 — 쓰기 한 번의 실패가 정상 응답의 모양으로 나간다.
이 술어는 ix_poll_forecast_ready의 술어와 글자 그대로 같아야 한다. 다르면 계획기가 인덱스를 안 고른다.
짝이 되는 규칙은 processor 쪽이다 — 예보 대상이 0건이어도 forecastCompletedAt을 찍어야 SUCCESS_EMPTY 판이 후보에서 안 빠진다. 둘 중 하나만 지키면 둘 다 깨진다.
(id)가 유일해도 (id, ...)는 유일하지 않다
fk_vsp_obs는 (id, route_reference_version_id) 복합 FK다. PostgreSQL은 참조 열 목록 그대로에 unique 제약을 요구하고,
(id)가 유일하다는 사실에서 그것을 추론해 주지 않는다.
기존 유니크 셋 중 어느 것도 맞지 않는다 — (id) PK도, ux_obs_row (poll_id, source_row_no)도,
부분 유니크 ux_obs_vehicle_per_poll도(부분 인덱스는 애초에 FK 상대편이 될 수 없다).
그래서 ALTER TABLE vehicle_observation ADD CONSTRAINT ux_obs_context UNIQUE (id, route_reference_version_id)가 vehicle_stop_prediction 생성보다 앞이어야 한다.
같은 자리에 구멍이 하나 남아 있다 — fk_vsp_outcome은 REFERENCES vehicle_observation (id) 하나뿐이라 판본을 보지 않는다.
도착 관측이 다른 판본의 행이어도 DB가 막지 않는다. 문서에 언급이 없고 DDL에서만 드러난다 — N17로 넘긴다.
정류장당 maxItems: 3 컷이 정렬 순서에 얹혀 있다. 그러니 정렬이 안 굳으면 어느 세 대가 남는지가 갈린다.
그런데 두 번째 축 vehicleId의 정렬 규칙이 원천에 없다 — 10진 canonical text인데 문자열 정렬인지 수치 정렬인지, NULL이 앞인지 뒤인지(Postgres 기본 ASC는 NULLS LAST) 어디에도 적혀 있지 않다.
세 번째 축 source_row_no는 api.html의 제안이고 계약에 없다. 셋 다 못박아야 컷이 결정적이 된다 — B13.
같은 종류의 구멍이 스냅샷 선택에도 있다. response_received_at 최신 1건인데 그 열에 UNIQUE가 없어 동률이면 어느 poll을 고르는지 정해져 있지 않다. 정렬을 못박을 때 이것도 같이 못박아라.
ApproachingVehicleView에는 필수인데 null 허용(vehicleId)과 없으면 키째 빠져야 하는 필드(expectedSeats)가 같이 있다.
클래스 수준 NON_NULL을 걸면 vehicleId가 null일 때 키가 사라지는데, 계약은 그것을 required + additionalProperties: false로 잠가 두었다.
RouteView.turnSequence도 같은 자리다 — 단방향 노선에서 null을 내보내야 하고 키를 빼면 안 된다.
직렬화 규칙은 필드 단위로 건다.
4. 결정이 필요한 자리 이 문서의 본체
코드보다 앞에 놓이는 것들이다. 여기서 갈리면 아래 절의 검수 기준도 뜻을 잃는다. 각 줄은 선택지와 대가까지 적었다 — "정해야 한다"만 적힌 줄은 두지 않았다.
| 갈래 | 건수 | 뜻 |
|---|---|---|
| 막는 것 B1~B20 | 20 | 정하기 전에는 그 자리의 코드를 쓸 수 없다. 임시값으로 넘어가면 나중에 자료를 버리거나 마이그레이션을 다시 해야 한다 |
| 나중에 정해도 되는 것 N1~N22 | 22 | 기본 동작이 이미 있거나(계약이 정한 대로 하면 된다) 첫 배포 뒤에 정해도 손해가 없다. 다만 시한이 있는 것이 셋 — 아래 표에 적었다 |
| 합 | 42 | 계약이 세어 둔 미결 16(그중 9는 「나중」) + v3 잔여 3 + 계약에 없어 이 문서가 새로 찾은 자리 13 + 원천끼리 갈리는 자리 11(C1~C11) 을 겹치는 것끼리 묶은 수다 |
적재할 계수 번들이 아직 없다. 외부 ML 환경에서 승인된 자료로 한 번 더 학습해야 한다.
번들이 없으면 processor 가 뜨지 못하고, processor 가 없으면 forecast_completed_at 이 안 찍히고,
그러면 /board 가 고를 스냅샷이 하나도 없다 — 마이그레이션과 api 를 다 만들어도 응답이 비어 나간다.
다른 열아홉 개보다 이것이 먼저다.
4-1. 막는 것 20
「누가 정하나」의 뜻 — 모델 특징 계약·번들을 소유하는 쪽 · BE 백엔드 구현자 · 계약 프론트와 합의해야 하는 것 · 운영 팀 판단.
| 무엇이 | 정해야 하는 이유 · 못 정하면 막히는 것 | 선택지와 대가 | 누가 |
|---|---|---|---|
| B1 계수 번들 v3 잔여 |
processor 가 기동하지 못한다. 예보 행이 없으면 스냅샷 후보도 없다 | 선택지가 아니라 일정이다. 학습을 언제 돌리고 누가 승인하는가. 그때까지 BE 는 번들 로더를 더미 번들로 시험할 수밖에 없는데, 더미로 통과한 golden vector 는 근거가 아니다 — 진짜 번들이 온 뒤 다시 돌려야 한다 | 모델 |
B2 번들 형식bundleDigest |
번들 로더와 봉인 검사를 못 쓴다. 부피만 정해져 있다 — 블록 24개 · 실수 15,696개 · float64 약 123 KiB · JSON 실수 표기 0.3~0.4 MB | ① JSON 한 벌 — 사람이 읽고 diff 가 된다 / 0.3~0.4 MB 이고 실수 표기가 왕복에서 흔들릴 수 있어 digest 가 깨진다 ② float64 이진 + 메타 JSON — 123 KiB, 비트 단위로 재현된다 / 사람이 못 읽고 도구가 하나 는다 ③ 둘 다 담고 digest 는 이진에만 — 안전하지만 두 벌이 어긋날 자리가 생긴다.bundleDigest·completionDigest 산출은 "구현 시 확정"으로만 적혀 있다. digest 알고리즘과 정규화 순서를 같이 못박아야 같은 번들이 두 digest 를 내지 않는다 |
모델 |
| B3 정원 상수 미결 4 |
추론기가 cap 을 못 만든다. "번들의 특징 계약이 소유한다"만 있고 어떤 모양으로 담는지, 번들에 없는 차량을 만나면 무엇을 쓰는지가 없다. DB 열이 아니라 CHECK 로 막을 수도 없다 |
담는 모양 — ① 차량별 표(vehicleId → cap) / 정확하지만 번들이 차량 수만큼 커지고 새 차량마다 재배포 ② 차량 유형별 상수 / 작지만 유형 판정 근거가 없다(lowPlateCode 에서 유도하지 않기로 했다) ③ 차량별 표 + 기본값 / 권장. 미지 차량에 무엇을 쓸지가 곧 다음 결정이다.미지 차량의 기본값 — ⓐ 말뭉치 최빈값 44 / 실제 68석 차량이 오면 점유율이 1을 넘는다 ⓑ 그 판에서 본 최대 잔여석 / 관측이 적으면 정원이 작게 잡혀 점유율이 부풀고, 가장 낮은 값이 36석(관측 138행)이다 ⓒ 예보를 안 낸다 / 그 차량이 보드에서 사라진다. v/cap > 1 행(교정 전 96행)도 같은 자리에서 정한다 — 자를지, 정원을 그 값으로 올릴지, 거를지 |
모델 |
| B4 시간대 축 미결 1 |
셀 조회 자체가 걸린다. 학습 라벨의 시간대는 도착 관측 시각에서 왔고 서빙은 도착 시각을 모른다. 지평 12 중앙 소요 24.7분이라 경계(07·09·17·20)를 넘는다 | ① 지금대로 예측 시각 — 구현이 이미 그렇고 추가 자료가 필요 없다 / 학습과 축이 다른 채로 배포된다 ② 학습을 예측 시각으로 다시 — 축이 맞는다 / 재학습 한 바퀴, 지표를 전부 다시 뽑아야 한다 ③ 도착 시각을 지평별 중앙 소요로 추정해 맞춤 — 축이 근사로 맞는다 / 추정이 틀리면 경계 근처가 더 나빠지고 규칙이 하나 는다. 어느 쪽이든 어긋남의 크기를 먼저 재라 — 지평별로 예측 시각 시간대와 도착 시각 시간대가 갈리는 비율. 그 수 없이는 세 선택지의 대가를 비교할 수 없다 |
모델 |
| B5 시간대 경계 포함 규칙 |
TimeBand.of() 가 골격으로 비어 있다. 07~09 가 07:00:00 이상 09:00:00 미만인지, 20시가 evening 인지 other 인지 원천에 없다 |
① [시작, 끝) 반열림 · KST — 표준이고 셀이 겹치지 않는다 / 없음. ② 닫힌 구간 — 09:00:00 이 두 셀에 든다. 정하고 나면 time_cell_id 에 CHECK 나 enum 을 걸어라. 지금은 varchar(40) NOT NULL 뿐이라 오타가 그대로 적재된다. 학습 쪽 코드가 쓴 경계와 글자 그대로 같은지 대조하는 것이 이 결정의 검수다 |
모델 |
| B6 여정 분할 문턱 미결 3 |
vehicleTripKey 적재 전체. startsNewTrip 이 골격이고 문턱 둘이 다 비어 있다 |
따로 다룬다 — 4-2. 후보 규칙과 각각의 대가, 그리고 결정 전에 재야 할 수치를 그 절에 적었다 | BE |
| B7 온라인 이동 미결 2 |
4단계(L_p0shift)를 못 만든다. 회수 주기와 한 몸인데 그 점이 어디에도 안 적혀 있다 — 이동량은 SETTLED 행에서만 나오고 회수 배치의 주기·창은 따로 미결이다. 회수가 느리면 n_obs < 50 이 하루 종일 이어져 4단계가 아예 안 걸린다 |
집단 정의 — ① (노선 × 지평) / 연구 코드가 사실상 이렇게 돈다(online_shift 는 넘겨받은 te 전체를 누적하고 노선 필터는 그 바깥에 있다). 표본이 갈려 min_n = 50 에 늦게 닿는다 ② 노선별 / 빨리 닿는다, 지평별 편차를 못 잡는다 ③ 통 / 가장 빨리 닿는다, 이동이 뭉개진다.같이 정할 것 넷 — 누적기 리셋 시점(KST 자정인가), 어디에 사는가(프로세스 메모리면 재기동 때 재구성 규칙이 필요하고, 표면 표를 하나 더 만들어야 한다), 회수 주기(poll 마다 · 분 단위 배치 · 일배치 — poll 마다 하면 이동이 제때 걸리지만 배치 하나가 수집 주기에 묶인다), 회수 창. 회수 창이 곧 PENDING의 만료 규칙이다 — 지금은 PENDING을 닫는 조건이 어디에도 없어 여정이 끊긴 줄 모르는 행이 영원히 PENDING으로 남는다. 창을 정하면 "창을 넘긴 PENDING은 LOST로 닫는다"가 따라 나온다. 창이 짧으면 늦게 도착한 차의 라벨을 버리고, 길면 PENDING이 쌓여 N2(보존·파티션)와 부딪힌다 |
모델 |
B8 route.statusC1 · C11 · 원천 갈림 |
/routes 와 /board 의 status, 그리고 MODEL_OUT_OF_SCOPE 판정을 못 만든다. 원천 둘이 다르게 말한다 — 계약은 "ACTIVE 배포가 있으면 FORECAST_READY, 판본 지원 여부로 가르지 않는다", 조립 절차는 "ACTIVE 의 supportedScopeDigest 가 이 판본을 담으면 FORECAST_READY".그 아래 한 층이 더 갈린다(C11) — supportedScopeDigest 의 축은 (노선 키, 지평) 이라고 계약이 못박았는데 MODEL_OUT_OF_SCOPE 는 "이 노선 판본을 담지 않는다"로 정의돼 있다. 축에 없는 것으로 판정할 수 없다 |
① 배포 유무만 본다 — 목록이 싸다(노선마다 digest 를 안 읽는다) / 배포는 있는데 이 노선을 안 담는 경우 FORECAST_READY 라고 해 놓고 /board 가 503 을 낸다. 화면이 두 말을 한다 ② 지원 범위까지 본다 — 두 응답이 같은 말을 한다 / /routes 에는 판본 파라미터가 없어 어느 판본으로 판정하는지를 또 정해야 한다(현재 활성 판본으로 보는 것이 자연스럽다) ③ 상태값을 하나 더 만든다 — 계약이 additionalProperties: false 로 잠겨 있어 불가.②를 고르면 "현재 활성 판본 기준"이라고 계약에 한 줄 박아야 한다. C11 은 여기서 같이 닫는다 — ⓐ 축 그대로 노선 키로 판정하고 오류 설명 문구를 고친다 / 개편이 나도 503 이 안 난다(축에 판본이 없으니 무관하다) ⓑ 축에 판본을 넣는다 / 판정이 정확해지지만 판본이 새로 생길 때마다 번들을 다시 배포해야 503 이 풀린다 |
계약 |
| B9 배포 단위 C2 · 원천 갈림 |
model_deployment 운용과 Board.model.releaseId 가 갈린다. 클라이언트 계약은 "releaseId 는 노선마다 다르다"고 적었는데 ACTIVE 는 부분 unique index 로 전역 1개다. 번들 하나가 두 노선 블록을 담으면 두 노선의 값이 같다 |
① 번들 하나 = 배포 하나(현재 스키마 그대로) — 승격이 한 번, 한 판이 한 배포라는 불변식이 그대로 산다 / 노선 하나만 다시 학습해도 둘 다 승격되고, 프론트가 기대한 "노선마다 다름"이 거짓이 된다 ② 노선별 배포 — releaseId 가 노선마다 다르다 / 부분 unique index 를 (routeKey) 축으로 바꿔야 하고(마이그레이션), 노선마다 승격 시점이 갈려 채점이 복잡해진다 ③ 번들 하나 + releaseId 에 노선 키를 넣어 표기만 다르게 — 싸다 / 거짓말이다. 같은 번들인데 다른 것처럼 보인다.①을 고르면 클라이언트 계약의 그 문장을 고쳐야 한다. 프론트는 "다를 수 있다"로 짜면 ①에서도 안 깨진다 |
계약 |
| B10 낡음 문턱 셋 미결 6·7 |
503 판정과 staleAt 을 못 만든다. NO_RECENT_OBSERVATION 문턱 · 스냅샷 최대 나이 · /vehicles 의 staleAt 셋이 같은 축인지도 안 정해졌다 |
① 하나로 묶는다 — 화면이 한 말만 한다. staleAt = observedAt + T 이고 그 T 를 넘으면 /board 가 503 / 수집 주기가 시간대마다 15초~600초로 달라 고정 T 는 심야에 늘 낡음이 된다 ② 수집 주기의 배수(예: 다음 예정 poll + 여유 2회분) — 시간대에 따라 늘고 준다 / 규칙이 collectionStrategyVersion 에 묶이고 계산이 클라이언트에 안 보인다 ③ 셋을 따로 둔다 — 튜닝이 자유롭다 / 화면이 두 말을 한다. /vehicles 는 정상인데 /board 만 503 이 되는 구간이 생긴다.데모는 10분 고정을 쓴다( STALE_AFTER). 임시값이라고 코드 주석에 적혀 있고 근거가 없다 — 그대로 가져오지 마라 |
계약 |
| B11 요일별 시간표 미결 13 |
첫차·막차 저장 구조가 곧 마이그레이션 모양이다. 지금 DDL 은 방향별 4열이고 평일 한 벌만 담는다 | ① 평일 한 벌 고정(현재 DDL) — 응답이 날짜에 안 매이고 캐시가 단순하다 / 주말 막차가 다른 노선에서 조용히 틀리고 화면이 그 오류를 못 드러낸다 ② 오늘 요일 값 — 화면이 맞는다 / 응답이 날짜에 의존하고 캐시 키에 날짜가 들어가며 공휴일 달력이 필요하다. 자정 직후 어느 날짜를 쓰는지도 정해야 한다(막차가 24시를 넘는다) ③ (판본, 요일) 자식 표 — 확장이 열린다 / 표가 하나 늘고 조립에 조인이 하나 는다 ④ 열 12개로 펴기 — 조인이 없다 / 열이 12개고 요일이 늘면 또 마이그레이션.①로 시작해도 되지만 그 선택을 계약에 적어야 한다 — "첫차·막차는 평일 값이다"가 응답에 안 적히면 프론트가 오늘 값으로 읽는다. 실측 근거: 상류 마스터에서 공휴일( we*) 값은 사실상 일요일(sun*)의 복제 — 양쪽 다 값이 있는 1,927개 노선 중 다른 것은 10개. 두 노선은 네 요일패턴 값이 모두 같다 |
계약 |
| B12 적재 실패 처리 세 벌 대조 · 25:00 |
노선정보 수집이 배포 2단계라 여기서 실패하면 api 가 못 뜬다. 두 자리 다 실패 처리가 없다 — 요일 세 벌이 다를 때, 그리고 상류가 25:00 같은 24시 이후 표기를 줄 때(char(5) CHECK 가 00:00~23:59 만 받는다) |
"대조"의 대상부터 정해야 한다 — 상류는 요일패턴 넷(평일 · sat · sun · we)을 주는데 계약은 "we*·sat*·sun* 세 벌을 다 받아 대조"라고만 적었다. ⓧ 세 벌끼리 비교인가 ⓨ 세 벌 각각을 평일 값과 비교인가. 둘은 결과가 다르다 — 토·일이 서로 다른 노선은 흔하므로 ⓧ면 대부분의 노선이 적재 실패한다.세 벌 대조 처리 — ① 적재 실패(계약 문구) / 안전하지만 상류가 값을 바꾸는 순간 노선 하나가 통째로 멈춘다 ② 평일 값만 담고 어긋남은 경보만 / 서비스가 안 멈춘다, 어긋남이 방치될 수 있다 ③ 세 벌 다 담는다 → B11 ③·④ 와 같은 결정. 24시 이후 표기 — ⓐ 모듈로 24 로 접는다( 25:00 → 01:00) / 값이 들어가지만 "다음 날 1시"라는 뜻이 사라진다 ⓑ CHECK 를 풀고 29:59 까지 받는다 / 뜻이 산다, 클라이언트가 그 표기를 이해해야 한다(계약 변경) ⓒ 적재 실패 / 안전하지만 심야 노선이 들어오면 멈춘다.두 노선에서는 아직 안 났다 — 그러니 "언젠가 난다"에 대비하는 결정이다 |
BE |
| B13 정렬과 동률 | 어느 세 대가 남는지가 갈린다. maxItems: 3 컷이 이 순서에 달려 있는데, vehicleId 는 10진 canonical text 이고 문자열 정렬인지 수치 정렬인지 원천에 없다. NULL 위치도 없다.같은 종류의 구멍이 스냅샷 선택에도 있다 — response_received_at 최신 1건인데 그 열에 UNIQUE 가 없어 동률이면 어느 poll 을 고르는지 정해지지 않는다 |
① 문자열 오름차순 · NULLS LAST — Postgres 기본에 가깝고 열 타입 그대로다 / "10" 이 "9" 앞에 온다 ② 수치 오름차순 — 사람 직관에 맞는다 / JS 안전정수를 넘는 값이 있어 캐스팅이 위험하고, 값을 opaque 키로 다루기로 한 규약과 어긋난다 ③ 정렬 축에서 빼고 source_row_no 를 2축으로 — 결정적이고 캐스팅이 없다 / 순서에 사람이 읽을 뜻이 없다.어느 쪽이든 마지막 축( source_row_no)은 있어야 한다 — vehicleId 가 null 일 수 있어 그 앞의 축들만으로는 순서가 안 굳는다.스냅샷 동률 — ⓐ ORDER BY response_received_at DESC, id DESC / 한 줄이면 되고 인덱스 술어를 안 건드린다 ⓑ response_received_at 에 노선 판본별 UNIQUE / 근본적이지만 재시도로 같은 시각이 두 번 들어오는 경우를 막아 버린다 |
BE |
B14 revision 범위 |
집계 배치가 무엇을 기준으로 +1 하는지 없다. demand_profile 의 PK 에 revision 이 없으므로 배치가 스스로 정해야 한다 |
① 전역 한 벌 — 한 번 돌면 한 세대, 채점이 단순하다 / 노선 하나만 다시 계산해도 전체 세대가 오른다 ② (판본, 시간대, 특징계약)별 — 부분 재계산이 자연스럽다 / 세대 번호로 두 셀을 비교할 수 없다. cellProfileRevision 하나만 보고는 어느 축의 세대인지 모른다 ③ 전역 + 계산 시각 병기 — ①의 단순함에 추적이 붙는다 / 열이 하나 는다(computedAt 은 이미 있다).①을 권한다 — cellProfileRevision 의 존재 이유가 "채점을 세대별로 가르는 것"이고, 그러려면 세대가 한 축이어야 한다 |
BE |
| B15 "여정 끝"의 뜻 | ForecastJob 골격의 poll.lastStopOrder() 가 무엇인지 없다. 이 값이 지평 창을 어디서 자르는지를 정한다 |
① 판본의 마지막 순번(1650 89 · 3330 85) — 계산이 싸고 표 하나만 본다 / 여정이 그보다 앞에서 끝나는 경우를 못 본다(1650 종료 순번이 88 에 299건으로 몰린다) ② 그 차량 여정의 끝 — 정확하다 / 여정이 어디서 끝날지는 미래라서 모른다. 추정하면 규칙이 하나 더 는다 ③ 판본 끝으로 자르고 여정이 먼저 끝나면 LOST 로 닫는다 — 구현이 단순하고 회수 상태가 그 사실을 남긴다 / LOST 가 늘어 채점 표본이 준다.③이 계약과 가장 잘 맞는다 — settlement 다섯 값을 둔 이유가 "왜 못 썼는지를 남기는 것"이다 |
BE |
| B16 실패한 판의 뒤처리 | 예보 쓰기가 실패해 forecastCompletedAt 을 못 찍은 poll 을 나중에 다시 처리하는지 영원히 버리는지 없다. 한도가 소진됐을 때 poll 이 어떤 outcome 으로 닫히는지도 원천에 없다 — reservedCalls CHECK 는 "조건부 UPDATE 가 실패하면 예약이 안 된다"까지만 말한다 |
재처리 — ① 버린다 — 단순하고 시각이 안 뒤틀린다 / 그 판의 차량이 영구히 안 보인다 ② 다음 배치가 밀린 poll 을 훑는다 — 구멍이 메워진다 / 낡은 관측으로 낸 예보가 generatedAt 만 새것이라 채점이 뒤틀린다. 나이 상한을 같이 정해야 한다 ③ 나이 상한 안에서만 재처리 — ②의 절충.한도 소진 — ⓐ 새 PollOutcome 값 추가 / enum 9값이 늘고 계약 변경 ⓑ 기존 실패 값 재사용 / 원인이 로그에만 남는다 ⓒ 예약 실패 시 호출을 아예 시도하지 않고 poll 행도 안 만든다 / 표가 깨끗하지만 "그때 한도가 없었다"는 사실이 표에 안 남는다.적응형 하루 예상 8,556회 대 한도 10,000 이라 지금은 안 나지만 노선이 늘면 난다 |
BE |
B17 contentDigest 산출계약에 없다 |
판본을 새로 끊을지 말지가 이 값 하나로 갈린다. 원천은 "방향별 첫차·막차 네 열은 안 들어간다"까지만 말하고, 어떤 필드를 어떤 순서로 어떤 해시에 넣는지가 없다. 규칙이 흔들리면 같은 정류장 목록이 매일 다른 지문을 내 판본이 날마다 새로 생기고(그때마다 셀 통계가 콜드스타트로 떨어진다), 반대로 너무 적게 넣으면 개편을 놓친다 | 무엇을 넣나 — ① (stopOrder, stationId) 쌍의 정렬 목록 / 순번 축의 뜻이 바뀔 때만 판본이 갈린다. 정류장 이름만 바뀌면 판본이 안 생기는데 이름은 stops[].name 으로 응답에 나가므로 화면이 낡는다 ② + stationName / 이름 변경도 잡는다, 상류가 표기를 다듬을 때마다 판본이 생긴다 ③ + turnSeq·boardingAllowed / 유도값까지 잡는다, 유도 규칙이 바뀌면 정류장이 그대로인데 판본이 갈린다.같이 못박을 것 — 해시 함수( char(64) 라 SHA-256 이 유력) · 정규화(구분자 · 공백 처리 · UTF-8) · 정렬 키. 셋 중 하나만 흔들려도 지문이 달라진다 |
BE |
B18 Cache-ControlC10 · 원천 갈림 |
두 응답의 max-age 를 못 만든다. 계약이 규칙과 그 규칙이 약속하는 효과를 서로 어긋나게 적었다 — 규칙은 "그 시점의 수집 주기에 맞춰 내보낸다(첨두 15 · 낮 60 · 심야 600초)"인데, 바로 다음 문장이 "이 값을 따르면 다음 판이 나올 때 맞춰 다시 부르게 된다"고 약속한다. 고정값으로는 그 약속이 안 지켜진다 — 판이 방금 갱신된 직후에 부른 클라이언트는 한 주기를 통째로 늦게 본다. 참고 구현은 그래서 "다음 관측까지 남은 초"를 내보낸다(4초 간격 실측 16 → 12 → 8 → 5 → 5 → 15). 게다가 고정값 표는 C6 의 경계 겹침(20시·23시)을 그대로 물려받는다 |
① 수집 단계 고정값 — 계산이 없고 시간대 표만 보면 된다 / 판이 방금 갱신됐든 곧 갱신되든 같은 값이라 클라이언트가 한 주기를 통째로 늦게 본다 ② 다음 예정 poll 까지 남은 초 — 새 판이 나오는 순간에 맞춰 온다 / 같은 판을 부를 때마다 헤더가 달라져 중간 캐시가 흔들리고 적응형 스케줄의 예정 시각이 밖으로 드러난다 ③ ② + 하한(예: 최소 5초) — 몰림을 막는다 / 상수가 하나 는다. 어느 쪽이든 /board 와 /vehicles 가 같은 판에서 같은 값을 내야 한다 — 계약이 "서버가 정한다"고 못박은 자리다 |
계약 |
| B19 순번 0 계약에 없다 |
기점 관측이 적재에 실패한다. 순번 규칙이 stateCd = 1 에서 stationSeq − 1 인데, 기점(stationSeq = 1)에 도착 상태로 잡힌 관측은 stopOrder = 0 이 된다. route_stop.stopOrder 는 1 이상이라 복합 FK 가 그 행을 거절한다. 첫차마다 나는 상황인데 원천 어디에도 처리가 없다 |
① 0을 1로 올린다 — 행이 산다 / 아직 아무 정류장도 안 지났는데 1을 지났다고 기록한다. slope·prev_seat 가 한 칸씩 밀린다 ② 그 관측을 excludedRows 로 뺀다 — 뜻이 안 뒤틀린다 / vehiclesInService 가 기점 차량만큼 작아지고 그 차량이 /vehicles 에서도 사라진다 ③ stopOrder = 1 로 두되 phase 는 ARRIVING 그대로 — 행이 살고 국면 정보도 남는다 / 순번과 국면의 짝이 다른 행과 달라진다.정하기 전에 잴 것 — 전수 관측에서 stationSeq = 1 AND stateCd = 1 이 몇 건인가. 그 수가 어느 원천에도 없다 |
BE |
| B20 라벨 관측 선택 계약에 없다 |
arrivedSeats 가 어느 관측의 좌석인지 없다. 계약은 "같은 여정 안에서 대상 순번의 관측을 찾았고 잔여석이 유효하다"까지만 말한다. 그런데 한 정류장에 대해 stopOrder 가 같은 관측이 여러 건 나온다(첨두 15초 주기에 정차·서행이 겹친다). 어느 것을 고르냐로 라벨이 달라진다 — 도착→출발 전이의 68.8%에서 좌석값이 갱신되기 때문이다. 채점·demand_profile·L_p0shift 가 전부 이 값 위에 선다 |
① 순번이 그 값으로 바뀐 첫 관측 — 참고 구현이 쓰는 규칙이다(if prev != st: out[st] = seats). 결정적이고 한 번만 훑는다 / 그 관측은 대상 정류장에서 승하차가 끝난 뒤의 값이라 "타려는 사람이 보는 자리 수"와 반 칸 어긋난다 ② 그 순번의 마지막 관측 — 정차가 끝난 상태에 가장 가깝다 / 다음 순번으로 넘어갈 때까지 확정이 안 되고, 정차가 길면 늦게 닫힌다 ③ phase = ARRIVING 인 관측(= stopOrder = 대상 − 1) — 승차 전 값이라 뜻이 곧다 / 계약의 "대상 순번의 관측"과 순번이 어긋나 fk_vsp_outcome 이 가리키는 행의 뜻을 다시 정의해야 한다.같이 못박을 것 — SKIPPED 와 LOST 의 우선순위. 대상 순번을 건너뛴 뒤 여정이 끊기면 둘 다 해당한다. 어느 쪽으로 닫는지가 없으면 같은 사건이 구현마다 다른 값으로 남아 채점 표본이 갈린다 |
모델 |
4-2. vehicleTripKey — 규칙이 아예 없는 자리 B6 의 상세
가장 큰 결정거리다. 이것 하나가 학습 자료의 slope·prev_seat, 라벨 회수, 채점 표본을 동시에 정한다.
그런데 있는 것은 키의 모양뿐이고 판정 규칙의 값이 없다.
| 있는 것 | 없는 것 — 여기가 결정거리다 |
|---|---|
구성 요소 (sourceId, vehicleId, routeReferenceVersionId, 여정 시작 observedAt) |
순번 되돌림 문턱 — 몇 칸 감소부터 새 여정인가 |
열 텍스트 sourceId|vehicleId|refVersionId|startedAt · varchar(120) |
관측 공백 문턱 — 몇 분 이상 비면 새 여정인가 |
| 분할 신호 셋(순번 감소 · 간격 초과 · 판/정규화 변경) | startedAt 의 직렬화 형식 — 열 텍스트가 키의 일부다 |
vehicleId 가 null 이면 키도 null |
판본이 바뀌는 순간 진행 중 여정을 어떻게 닫는가 |
vehicleId null → 키 null 은 계약 본문에 "(CHECK)"라고 적혀 있지만 V4__a18_seat_forecast.sql 에 그 제약이 없다.
실제 DDL 은 ADD COLUMN vehicle_trip_key varchar(120) 한 줄뿐이다. 제약을 넣을지도 같이 정해야 한다.
순번 되돌림 — 후보 셋
| 후보 | 규칙 | 사는 것 | 치르는 것 |
|---|---|---|---|
| T1 데모가 쓴 것 | 직전보다 순번이 1칸이라도 작으면 새 여정 | 가장 단순하다. 참고 구현이 하루치 재생을 이 규칙으로 돌았다(journey += 1 if seq < prev.seq). 회차 언저리 되돌림도 잡는다 |
상류 지터로 1칸 되돌아가면 한 여정이 둘로 쪼개진다. 쪼개지면 그 관측의 slope·prev_seat 이 결측 지시자로 떨어지고, 앞서 낸 예보는 전부 LOST 로 닫혀 채점 표본에서 사라진다 |
| T2 | 감소폭이 K칸 이상일 때만 새 여정(예: K = 3) |
지터를 흡수한다 | 회차 언저리 되돌림을 놓친다. 3330 은 순번 42~43 에서 끝나고 다시 시작하는 여정이 각 1건씩 있다 — K가 크면 그 둘이 한 여정으로 붙는다. 붙으면 종점 뒤 순번 1 이 같은 여정으로 들어와 지평 창이 다른 운행으로 넘어간다 |
| T3 제안 | 감소하면 새 여정으로 보되, 감소폭 1칸이고 다음 관측이 원래 순번으로 돌아오면 되돌린다(1칸 지터만 유예) | T1 의 정확도에 지터 내성을 더한다. 문턱 상수가 없어 판본 길이에 안 매인다 | 적재가 한 관측 뒤를 봐야 한다 — 여정 키를 즉시 확정하지 못하고 유예 상태가 생긴다. 예보는 그 사이에도 나가므로 키가 나중에 정정되는 행이 생긴다. 정정 규칙을 또 정해야 한다 |
결정 전에 잴 것 — 전수 관측에서 "감소폭 1칸이 났다가 다음 관측에서 되돌아온" 사건이 몇 건인가. 그 수가 0 에 가까우면 T1 이 답이고 T3 는 없어도 되는 복잡도다. 지금 그 수가 어느 원천에도 없다.
관측 공백 — 후보 넷
먼저 못박아야 할 사실 하나 — 회차 대기는 같은 여정 안이다. 회차로 여정을 끊지 않기로 했다. 그러니 공백 문턱은 회차 대기보다 크고 밤 공백보다 작아야 한다. 실측이 그 사이를 이렇게 벌려 놓았다.
| 무엇 | 1650 | 3330 |
|---|---|---|
| 회차 대기 (중앙) | 75.3분 | 94.0분 |
| 회차 대기 — 시간대별(1650) | 07시 106.7 → 12시 84.0 → 17시 59.6 → 21시 46.0분 | |
| 밤 공백 (중앙) | 487분 | 462분 |
| 후보 | 규칙 | 사는 것 | 치르는 것 |
|---|---|---|---|
| G1 | 고정 문턱 하나(예: 120분) | 구현이 한 줄이다. 아침 회차 대기 중앙 106.7분보다 크고 밤 공백 중앙 462분보다 작다 | 중앙값만 알고 꼬리를 모른다. 아침 회차 대기가 120분을 넘는 여정은 둘로 쪼개진다. 문턱 하나로 고정하면 아침에는 한 여정을 둘로 쪼개고 밤에는 둘을 하나로 붙인다 |
| G2 | 시간대별 문턱(아침 크게 · 저녁 작게) | 실측 분포를 따라간다 | 여정 분할 규칙이 시간대 정의에 묶인다 — 미결 1(시간대 축)이 흔들리면 여정 키도 흔들린다. 미결 둘이 한 줄에 엮인다 |
| G3 | 공백은 안 본다(문턱 = ∞). 순번으로만 끊는다 | 규칙이 하나로 준다. 회차 대기를 절대 오분할하지 않는다 | 차량이 하루 안 보이다 같은 순번대로 다시 나타나면 한 여정으로 붙는다. slope 가 어제 값을 참조하고, 밤을 건너 PENDING 이던 예보가 엉뚱한 관측으로 SETTLED 될 수 있다 |
| G4 제안 | 고정 문턱(G1) + 여정 나이 상한(예: 시작으로부터 6시간) | G3 의 최악(밤을 건너 붙는 것)을 나이 상한이 막고, 아침 회차 대기는 문턱을 넉넉히 잡아 흡수한다 | 상수가 둘이다. 둘 다 근거가 되려면 회차 대기 분포의 상위 분위(p95·p99)와 실제 여정 길이 분포가 있어야 하는데 지금은 중앙값만 있다 |
지금 있는 수는 중앙값뿐이다. 문턱은 꼬리에서 정해지는데 꼬리를 모른다.
임시값으로 시작하겠다면 그 값이 틀렸을 때 나오는 증상을 미리 적어 두어라 —
문턱이 작으면 settlement = LOST 비율이 뛰고 slope 결측 지시자가 는다.
문턱이 크면 SETTLED 인데 좌석 기울기가 비현실적인 행이 는다.
둘 다 질의 한 줄로 감시할 수 있다 — 임시값을 쓰되 감시 없이 쓰지는 마라.
나머지 둘
| 무엇 | 선택지와 대가 |
|---|---|
startedAt 직렬화 |
열 텍스트가 키의 일부라 형식이 흔들리면 같은 여정이 두 키가 된다. 지금은 Instant 의 문자열 표현에 기댄 골격뿐인데, 그 표현은 소수부가 0 이면 자릿수를 줄인다 — 같은 여정이 ...:21Z 와 ...:21.000Z 로 갈릴 수 있다.① ISO-8601 UTC · 밀리초 고정 3자리 — 사람이 읽고 정렬이 사전순과 시간순이 같다 / 24자를 쓴다 ② epoch millis 십진 — 13자로 짧아 120자 상한에 여유가 크다 / 사람이 못 읽는다 ③ epoch seconds — 더 짧다 / 같은 초에 두 여정이 시작하면 충돌한다. 어느 쪽이든 폭을 고정해야 한다. 그리고 120자 상한 계산을 한 번 해 두어라 — sourceId+vehicleId+refVersionId+startedAt+구분자 3 |
| 판본 전환 | 계약은 "판본이 바뀌면 새 여정"이라고만 적었고 진행 중이던 여정을 어떻게 닫는지가 없다. ① 아무것도 안 한다 — 새 관측이 새 키를 받고 옛 여정의 PENDING 예보는 회수 배치가 LOST 로 닫는다. 구현이 없다 / 개편 순간에 LOST 가 한 번 튄다 ② 전환 시점에 그 노선의 PENDING 을 일괄 LOST 로 닫는다 — LOST 시각이 정확하다 / 배치가 하나 늘고, 개편이 아닌 재적재에서도 도는 사고가 난다 ③ 순번을 새 판본으로 옮겨 이어 붙인다 — 하지 마라. 순번의 뜻이 판본에 매여 있다는 전제를 깬다.①로 충분해 보인다 — 다만 "개편 직후 LOST 가 튀는 것은 정상"이라고 감시 기준에 적어야 오탐이 안 된다 |
4-3. 나중에 정해도 되는 것 22
기본 동작이 이미 있거나 첫 배포 뒤에 정해도 손해가 없다. 시한 이 붙은 셋은 방치하면 비용이 는다.
| 무엇이 | 지금은 어떻게 되나(기본 동작) | 정할 때의 선택지와 대가 | 누가 |
|---|---|---|---|
| N1 좌석 결측 서빙 미결 5 | 예보 행을 못 만들어 그 차량이 항목째 빠진다. /vehicles 에는 kind: UNKNOWN 으로 남는다 | ① 지금대로 뺀다 / 두 응답이 어긋난 채로 산다 ② 직전 관측 좌석으로 대체 / 항목이 살지만 거짓 값이 계약상 정상 값의 얼굴로 나간다 ③ crowdedCode 로 거친 추정 / 근거가 약하다 — 만석 구간 169개 중 혼잡도가 변한 것은 1개(0.6%)뿐이다. 규모가 작다(차량행의 0.075%) | 계약 |
| N2 예보 보존·파티션 미결 8 시한 | 안 지운다. 노선당 하루 89만~98만행(v3 stop_prediction 의 약 2,000배) — 파이프라인에서 가장 빨리 자라는 표다 | ① 날짜 파티션 + 보존 N일 / 정리가 싸다, 채점 창이 N일로 잘린다 ② 회수 끝난 행만 별도 정리 / 채점 창이 안 잘린다, PENDING 이 영원히 남을 수 있다 ③ 안 지운다 / 몇 달 뒤 문제가 된다. 배포 전에 파티션 축만이라도 정해라 — 나중에 나누려면 표를 다시 만들어야 한다 | BE |
| N3 세대 보관 미결 9 | UPSERT 라 최신 한 세대와 revision 만 남는다 | ① 지금대로 / 값까지 되살리는 완전 재현을 못 한다 ② 세대 이력 표 / 재현되지만 표가 늘고 셀 수만큼 곱해진다. 채점을 세대별로 가르는 것은 지금도 되므로 급하지 않다 | BE |
| N4 셀 재계산 주기 미결 10 | 정해진 것이 없다. 배치가 안 돌면 셀이 안 갱신된다 | ① 배포와 함께 / 재현이 쉽다, 표로 뺀 이유가 반쯤 사라진다 ② 독립 일배치 / 자료 속도로 갱신된다, 같은 배포가 날짜에 따라 다른 셀 값을 읽어 채점 때 trainedThrough 로 갈라야 한다. ②로 시작하고 시각만 못박아도 된다 | 모델 |
| N5 개편 콜드스타트 미결 11 | 새 판본에 셀 행이 0 이라 이웃 폴백만 돈다 | ① 폴백으로 버틴다 / 며칠을 버티는지 모른다 ② 이전 판본 셀을 순번 매핑으로 이식 / 값이 살지만 순번의 뜻이 판본에 매인다는 전제를 깬다 ③ 개편 노선을 PREPARING 으로 내린다 / 정직하지만 화면이 멈춘다. 먼저 잴 것 — 폴백만으로 낸 예보의 지표가 며칠 만에 회복되는가 | 모델 |
| N6 수집 밀도 확정 미결 12 | 적응형으로 이미 돌고 있다(adaptive-kst-v1.0.1) | v3 "60초 고정 · 하루 4,320회"는 적응형 확정 전의 값이다. 현행 설정값 8,556(두 노선 합)은 08-18 원자료 8,556 판과 맞는다 — 문서 쪽 수치를 2026-08-19 에 맞춰 고쳤다. 다만 slope·prev_seat·streak_full 이 밀도에 민감하므로 밀도를 바꾸는 순간 학습이 흔들린다 — 바꾸려면 재학습 | 모델 |
| N7 발행 계층 DROP 미결 14 | 표 둘이 남아 있고 코드가 안 읽는다 | 순서는 stop_prediction → forecast_publication(FK). 그때까지 스케줄러 정지로 둘지, 언제 지울지. 배포를 막지 않는다 | 운영 |
| N8 A18 안전장치 미결 15 시한 | 되돌릴 곳이 없다. 무너졌다는 판정 기준도, 그때 /board 가 낼 응답도 없다 | ① 판정 기준만 먼저(예: 일일 브라이어가 기저선을 며칠 연속 넘으면 경보) / 싸다, 그 뒤 행동이 없다 ② 예보를 끄고 status: PREPARING / 화면이 정직해진다, 서비스가 멈춘다 ③ 정류장 통계만으로 낸 값으로 내림 / 화면이 살지만 그 모델이 아직 없다. 코드는 안 막지만 운영 개시는 막는다 — 켜기 전에 최소한 ① | 운영 |
N9 boarding_allowed 강제미결 16 | 스키마가 안 막는다. 지키는 것은 집계 배치와 추론기이고 어긋난 행이 들어와도 적재가 성공한다 | ① 감시 질의로 본다(6-5) / 공짜, 사후 발견이다 ② 복합 FK 에 boarding_allowed 를 끼운다 / 막힌다, 두 표에 열이 하나씩 는다 ③ 부분 인덱스·트리거 / 강제되지만 쓰기 경로가 하나뿐이라 과하다 | BE |
N10 publicRouteIdv3 잔여 | 도입 안 함. API 경로가 공급자 원문 ID(204000057)를 쓴다 | ① 지금대로 / URL 에 공급자 ID 가 노출되고 공급자가 바뀌면 경로가 바뀐다 ② gg-3330 도입 / 경로가 안정된다, 계약·클라이언트·문서를 동시에 고쳐야 한다. 지금 노선이 둘뿐이라 미루는 비용이 작다 | 계약 |
| N11 학습 사건의 판본 v3 잔여 시한 | 학습 사건에 노선 판본이 없다 | 개편이 나기 전에는 티가 안 난다. 첫 개편이 시한이다 — 그때 과거 사건의 순번 뜻이 갈린다 | 모델 |
| N12 배차 간격 | 수집도 저장도 안 한다. 재료는 상류에 있다(peekAlloc 1650 6분·3330 5분, nPeekAlloc 둘 다 15분) | BoardRoute 가 unevaluatedProperties: false 라 담을 필드가 없다. 지금은 서빙하지 못하므로 저장하지 않는다 — 다음 판 계약 요청 항목이다 | 계약 |
| N13~N15 값 제약 셋 | time_cell_id 는 varchar(40) 뿐(오타가 적재된다) · expected_seats 는 >= 0 만(상한 없음) · target_stop_order 는 >= 1 만(상한은 FK 가 본다) | CHECK 를 더할지 말지. 더하면 적재가 막히고 안 더하면 감시 질의로 본다. expected_seats 상한은 정원이 DB 에 없어 CHECK 로 쓸 수 없다 — 이건 선택지가 아니라 사실이다. B5 를 정하면 time_cell_id 는 같이 정해진다 | BE |
| N16 관측 시각 동일성 | observed_at = poll 의 response_received_at 이라고 문서가 말하는데 DDL 에 그 제약이 없다 | ① 감시 질의 ② CHECK 로는 불가(다른 표) → 트리거 / 쓰기 경로가 하나라 과하다 ③ 관측에서 그 열을 없애고 poll 을 조인 / 정규화되지만 조회가 하나 는다. 권위는 poll 쪽이라는 것만 코드에 지켜지면 된다 | BE |
N17 fk_vsp_outcome | fk_vsp_obs 는 (id, route_reference_version_id) 복합 FK 인데 fk_vsp_outcome 은 (id) 하나뿐이다 — 도착 관측이 다른 판본의 행이어도 DB 가 안 막는다 | ① 복합으로 바꾼다 / 막힌다, 회수 배치가 판본을 같이 넘겨야 한다 ② 지금대로 + 감시 / 공짜. 회수는 같은 여정 안에서만 찾고 여정 키에 판본이 들어 있어 실무상 안 난다 — 그래서 급하지 않다 | BE |
| N18 판본 UPDATE 동시성 | 시간표 UPDATE 와 판본 신설이 경합할 때의 규칙이 없다 | 하루 1회 배치라 경합이 사실상 안 난다. ① 노선 단위 잠금 ② 낙관적 갱신 ③ 지금대로. 지금대로 두되 두 일이 같은 배치에서 순서대로 돌게 하면 대부분 사라진다 | BE |
N19 completionDigest | "구현 시 확정" | B2 와 같이 정하면 된다. 따로 막는 것이 없다 | BE |
| N20 계약에 어긋남 명시 | ApproachingVehicle 의 좌석 결측 어긋남이 계약에 안 적혀 있다 | 판별자를 되살리라는 요구가 아니다. "좌석 결측 차량은 이 배열에서 빠진다"는 한 줄을 description 에 넣자는 것. 스키마 변경이 아니다 | 계약 |
| N21 api 객체 수 장부 C9 | 표제는 api 14(예보 7 · 실시간 7)인데 나열된 이름은 15개(예보 7 · 실시간 8) | 세는 방식을 고르고 장부를 맞춘다. 결론에는 영향이 없다 — 어느 쪽으로 세든 실시간 쪽에서 v4 가 여는 것은 Cache-Control 한 자리다 | BE |
| N22 문서 수치 정본 C3~C8 | 원천끼리 다르게 말한다 — 4-4 에 따로 | 코드를 안 막지만 검수 기준의 근거를 흔든다. 어느 수를 정본으로 삼을지 정하고 나머지를 고쳐야 한다 | 운영 |
4-4. 원천끼리 다르게 말하는 자리 정본을 못박아라
여기 일곱은 둘 다 원천에 적혀 있다. 지어낸 것이 아니라 문서가 갈린 것이다.
C1(status)·C2(배포 단위)·C10(Cache-Control)은 코드를 막아서 위 4-1(B8·B9·B18)에 올렸고, 나머지를 여기 둔다.
| # | 자리 | A | B | 막나 |
|---|---|---|---|---|
| C3 | 좌석 결측 판의 비율 | 해소 — 1.07% 로 통일했다. 결측행이 237 인데 1.10% 는 242 판을 뜻해 한 판에 결측 차량이 둘씩 겹쳐도 넘을 수 없는 수다. 원자료를 다시 재니 08-14~19 에서 결측 240 행이 239 판에 흩어져 있어(21,894 판의 1.09%) 판과 행이 거의 1:1 이다. pipeline.openapi.yaml · domain.html 을 고쳤다 |
해소 | |
| C4 | 정원 말뭉치 행 수 | 해소 — 둘 다 맞고 단위가 달랐다. 52,211 은 도착 관측 행, 464,424 는 그것을 지평 1~12 로 편 확장 행이다(8.9배). 현재 말뭉치도 같은 구조다 — 관측 59,368 에 채점 599,353 으로 10.1배다. 네 자리 모두 단위를 붙였다 | 해소 | |
| C5 | 수집 단계 표 | 해소 — 정본은 수집기 설정이다(quota-mode = 10000). 첨두 07–09·17–20 15초 / 늦은 저녁 20–23 15초 / 심야 꼬리 23–01 · 새벽 04–07 · 낮 09–17 20초 / 심야 01–04 600초. 낮은 60초가 아니라 20초이고 한산 240초 구획은 지금 없다 — 08-18 에 낮이 60→20초로 촘촘해지면서 사라졌다. 08-14~19 원자료의 시간대별 중앙 간격이 이 표와 전부 일치한다. 표를 실은 네 문서를 고쳤다 |
해소 | |
| C6 | 6단계 표의 경계 겹침 | 해소 — 수집기는 반열린 구간을 쓴다. 7 <= h < 9 · 17 <= h < 20 · 20 <= h < 23 식이라 경계 시각은 뒤 단계에 속한다. 20시는 늦은 저녁, 23시는 심야 꼬리다. 표에 이 규칙을 적었다. 다만 B5(모델의 시간대 경계)는 별개로 남는다 — 그쪽은 학습 특징의 경계이지 수집 경계가 아니다 |
해소 | |
| C7 | observedAt 실측 |
모순이 아니다 — 1,294건 전부 queryTime 이 우리 수신 시각보다 2.2~20.5초 뒤이고, 옆 칸의 중앙 +17.265초는 이 범위 안에 든다. 한쪽은 범위, 다른 쪽은 중앙값이다 |
protocol.html: 직접 수집 92호출 전부 미래, 중앙 +17.265초, 인접 호출 시각 되감김 16건(17.4%), HTTP Date 도 초 절삭까지 92/92 일치해 독립 시계가 아니다.둘 다 결론은 같다 — queryTime 을 쓰지 않는다 |
문서 |
| C8 | routeId 형식 |
계약 pattern: '^[0-9]{9}$' · api.html "아홉 자리 숫자다" |
참고 구현은 isdigit() and 6 <= len <= 12. 계약이 정본이다 — 데모가 느슨한 쪽이고 서버는 계약대로 400 INVALID_ROUTE_ID |
문서 |
| C11 | MODEL_OUT_OF_SCOPE 의 판정 축 |
supportedScopeDigest 의 축은 (노선 키, 지평) — 계약이 두 번 못박았다. 이 축에 노선 판본이 없다 |
같은 계약의 오류 표와 api.html 은 "ACTIVE 배포의 supportedScopeDigest 가 이 노선 판본을 담지 않으면 503" 이라고 적었다. 담을 수 없는 것을 담았는지 물을 수 없다 — 판정 자체가 성립하지 않는다 |
B8 |
C11 은 말꼬리가 아니다. 노선 키로 판정하면 개편이 나도 503 이 안 나고(축에 판본이 없으니 개편과 무관하다), 판본으로 판정하면 판본이 새로 생길 때마다 번들을 다시 배포해야 503 이 풀린다. 두 구현의 운영 부담이 다르다.
"queryTime 이 +2.4초 또는 +20.0초", "11~12%가 순서 역전"은 사이트 어느 문서에도 없다 —
과제를 낸 쪽이 따로 잰 값이고 이 문서에 원천이 없다. 검수 근거로 쓰지 마라.
queryTime 에 관해 원천이 보증하는 값은 위 C7 의 두 벌(1,294건 2.2~20.5초 · 92호출 중앙 +17.265초)뿐이다.
4-5. 원천에 아예 없는 것 구현하다 반드시 부딪힌다
위 4-1·4-3 의 항목 상당수가 여기서 왔다. 여기 있는 것을 지어내지 마라 — 없다고 적고 결정을 요구하는 것이 이 문서의 일이다. 아직 어느 표에도 안 올라간 나머지를 모아 둔다.
| 없는 것 | 부딪히는 자리 | 어디로 갔나 |
|---|---|---|
vehicleId 정렬 규칙 · NULL 위치 · 스냅샷 동률 | maxItems: 3 컷이 이 순서에 달렸다 | B13 (막는다) |
contentDigest 산출 규칙(필드 · 해시 · 정규화) | 판본을 새로 끊을지가 이 값으로 갈린다 | B17 (막는다) |
stationSeq = 1 · stateCd = 1 의 stopOrder | 규칙대로면 0이 나오고 복합 FK 가 거절한다 | B19 (막는다) |
PENDING 을 닫는 조건 | 여정이 끊긴 줄 모르는 행이 영원히 남는다 | B7 (회수 창과 한 몸) |
arrivedSeats 를 어느 관측에서 읽나 · SKIPPED/LOST 우선순위 | 채점 라벨과 셀 통계가 전부 이 값 위에 선다 | B20 (막는다) |
| 정원 상수를 번들에 담는 모양 · 미지 차량 | 추론기가 cap 을 못 만든다 | B3 (막는다) |
| L_p0shift 누적기 리셋 시점 · 저장 위치 | 재기동하면 이동량이 사라진다 | B7 (막는다) |
| 회수 배치의 주기와 창 | 느리면 4단계가 하루 종일 안 걸린다 | B7 (막는다) |
| 시간대 경계 포함 규칙 | TimeBand.of() 가 골격이다 | B5 (막는다) |
| 25:00 이후 표기 · 세 벌 대조 실패 | 적재가 멈춘다 → api 가 못 뜬다 | B12 (막는다) |
revision 단조 증가의 범위 | 배치가 무엇 기준으로 +1 하나 | B14 (막는다) |
poll.lastStopOrder() 의 뜻 | 지평 창을 어디서 자르나 | B15 (막는다) |
| 실패한 poll 의 재처리 · 한도 소진 outcome | 구멍이 남거나 시각이 뒤틀린다 | B16 (막는다) |
| 번들 파일 구조 · golden vector 규약 · digest 알고리즘 | 로더와 봉인 검사 | B2·N19 |
/routes 의 status 를 어느 판본 기준으로 | /routes 에 판본 파라미터가 없다 | B8 (막는다) |
staleAt 계산식 | 계약은 "이 시각부터 낡음"이라고만 | B10 (막는다) |
observed_at 동일성 · fk_vsp_outcome 판본 · 값 상한 셋 | DB 가 안 막는 자리 | N13~N17 |
| 판본 UPDATE 동시성 | 시간표 갱신과 판본 신설의 경합 | N18 |
단방향 노선의 directions[] | turnSequence 가 null 이면 UP/DOWN 을 어떻게 가르나. BoardRoute.directions 는 minItems: 1 이라 비울 수 없다 | 여기가 유일하게 아직 아무 표에도 없다 — 두 노선 다 회차가 있어 지금은 안 난다. 노선이 늘 때 부딪힌다 |
5. 경계 조건 "이때 무엇이 참인가"
응답과 저장을 갈라 적는다. 둘이 다른 자리가 결함이 숨는 자리다 — 응답은 200 인데 저장이 비었거나, 저장은 정상인데 응답에서 사라지는 경우.
| 상황 | 무엇이 참이어야 하나 | 응답은 | 저장은 |
|---|---|---|---|
| 첫차 전 · 막차 후 | 서비스는 정상이다. 낼 답이 없을 뿐이다 | 200 · approachingVehicles: []. 상태 코드도 사유 코드도 안 붙인다 |
poll 은 계속 돈다. SUCCESS_EMPTY 가 쌓인다 |
차량 0대SUCCESS_EMPTY |
그 판은 정상 스냅샷 후보다. 예보 행이 0건이어도 예보 단계를 지났다 | 200 · vehiclesInService: 0 · Board.model 은 ACTIVE 배포에서 읽는다(가리킬 예보 행이 없다) |
forecastCompletedAt 을 찍는다. 안 찍으면 그 판이 영구히 후보에서 빠진다 |
| 버스는 있는데 지평 12 밖 | 지평 12 안에 안 드는 항목이 다수다(자르기 전 중앙 462·554 중 26.5%·27.5%만 안에 든다) | 200 · [] |
예보 행을 만들지 않는다. 관측은 그대로 있다 |
| 순번 1~12 정류장 | "곧 오는 버스 없음"이 아니다. 볼 칸이 sequence − 1 개뿐이다 |
200 · [] · sequence 를 같이 주어 클라이언트가 가릴 재료를 준다 |
없음. 실제 다음 버스는 중앙 8.3~12.0분 뒤에 오는데 그 차의 96.2~96.8%가 차고지 대기라 차량위치 API 에 없다 |
| 좌석 결측 관측 미결 5 |
-1 은 만석이 아니다. 대상을 만들 수 없다 — 설계행렬이 잔여석을 그대로 쓰는데 그 자리에는 결측 지시자가 없다 |
그 차량이 항목째 빠진다. /vehicles 에는 kind: UNKNOWN 으로 남는다 — 두 응답이 어긋난다 |
remainingSeats = NULL + seatUnavailableReason(XOR). 차량행 314,688 중 237행(0.075%), 연속 18회 사례 있음 |
| 여정 키 없음 ( vehicleId 결측) |
예보는 낸다. slope·prev_seat 에 결측 지시자가 있어 여정 첫 관측과 같은 방식으로 점수화된다 |
정상 항목으로 나간다. vehicleId 는 null 을 키째 넣어 보낸다(필수인데 null 허용) |
vehicle_trip_key = NULL. 라벨 회수만 못 하고 그 예보 행은 LOST 로 닫힌다. 전수 22,010 스냅샷에서 vehId 결측 0건 |
| 여정이 끊김 | 대상 순번에 닿기 전에 관측이 끊겼다. 모델이 틀린 것이 아니다 | 영향 없음(이미 나간 응답이다) | settlement = LOST · settledAt 만 있고 나머지 NULL. 채점에서 뺀다 |
| 여정이 대상을 건너뜀 | 순번을 두 칸 이상 뛴 구간이다. 도착 관측이 아예 없다 | 영향 없음 | SKIPPED. LOST 와 갈라 둔 이유 — 한 값으로 뭉개면 못 맞힌 것과 자료가 없는 것이 같아 보인다 |
| 회차를 넘어감 | 예보를 낸다. 항목의 10.0%(1650)·12.8%(3330)가 회차 너머다. 현재 잔여석의 설명력이 무너지고(지평 2 R² 0.873→0.370, 지평 8 0.178→0.011) 정류장 통계가 떠받친다 | 정상 항목. viaTurnaround 같은 필드는 두지 않는다 — 클라이언트가 sequence − horizonStops 와 turnSequence 로 유도한다 |
정상 행. 지평 12 회차 너머 실제 만석률 0.672%인데 예측 평균 0.249% — 과소 예측이고 따로 채점해 본 적이 없다 |
| 승차 불가 정류장 | boardingAllowed = NOT stationId LIKE '277%'. 1650 24곳 · 3330 7곳 |
200 · stops[] 에는 담고 approachingVehicles 만 []. 질의의 WHERE s.boarding_allowed 가 걸러 조립 쪽에 조건이 안 남는다 |
예보 행도 셀 행도 만들지 않는다. 다만 미결 16 — 스키마가 안 막는다 |
| 노선 개편 중 미결 11 |
순번의 뜻이 판본에 매여 있다. /board 와 /vehicles 의 referenceVersionId 가 다르면 개편 중이다 |
정상 응답. 클라이언트가 referenceVersionId 변화를 보고 화면을 다시 받는다 |
새 판본에 셀 행이 0 이라 이웃 폴백만 돈다(콜드스타트). 진행 중 여정의 처리는 4-2 참조 |
| 마이그레이션 직후 | 이전 poll 이 전부 후보에서 빠지는 것이 맞는 동작이다 — 그 poll 들엔 예보 행이 없다 | 첫 poll 이 예보까지 마칠 때까지 스냅샷이 없다. 그 사이의 응답을 무엇으로 할지가 B10 | 신설 표 둘은 빈 채로 시작. 백필 0건. 이전 관측은 여정 키가 비어 라벨 대상에서 빠진다(서빙은 결측 지시자로 받는다) |
| 상류 장애 | 실패한 poll 은 스냅샷 후보가 아니다. 시간이 지날수록 판이 낡는다 | 낡은 판을 계속 내다가 문턱을 넘으면 503 NO_RECENT_OBSERVATION. 문턱값 미결 B10 |
TRANSPORT_ERROR 로 poll 을 닫는다. 관측 행은 없다 |
| 응답이 불완전 | HTTP 200 이라도 msgHeader.resultCode 를 확인해야 한다. 오류 XML 과 정상 JSON 은 형태가 다를 수 있다 |
같은 자리(낡음 판정)로 흘러간다. GBIS 오류 본문을 클라이언트로 그대로 전달하지 마라 | INCOMPLETE_ENVELOPE |
resultCode = 4 |
재시도하지 않고 운행 종료로 단정하지도 않는다. 심야에 이 응답이 반복된 뒤 다음 poll 에서 차량이 다시 나타난 실측이 있다 | 영향 없음(그 판은 후보가 아니다) | 그 결과로 poll 을 닫는다. HTTP client 의 자동 retry·redirect 는 끈다 — 의도한 재시도는 새 attempt + 새 permit |
| 보냈는데 결과 모름 | 보수적 소비 — permit commit 뒤 프로세스가 죽어도 되돌리지 않는다 | 영향 없음 | UNKNOWN_AFTER_DISPATCH. 한도는 이미 차감된 채로 둔다 |
| 한도 소진 B16 · 원천 없음 |
조건부 UPDATE 가 실패하면 예약이 안 된다 — 거기까지만 원천에 있다 | 수집이 멈추면 판이 낡고 결국 503 으로 간다 | 그때 poll 을 어떤 outcome 으로 닫는지 정해진 것이 없다. 적응형 하루 예상 8,556회 대 한도 10,000 이라 지금은 안 난다 |
| 예보 쓰기 연속 실패 미결 7 |
관측은 정상인데 예보만 없다. 스냅샷이 계속 낡는다 | 가장 위험한 자리다 — 잘못 만들면 200 에 approachingVehicles 만 전부 비어 나가고 정상과 구별되지 않는다. forecast_completed_at IS NOT NULL 을 스냅샷 선택에서 빼지 마라 |
관측은 쌓이고 예보 행만 없다. 그 poll 들은 후보에서 빠진다 |
| ACTIVE 승격 직후 | 한 판의 예보 행은 한 transaction · 한 배포 · 같은 generatedAt 이다 |
정상. 예보 행을 배포로 거르지 마라 — 거르면 승격 직후 한 판의 차량이 전부 사라진다 | modelDeploymentId 는 읽기 필터가 아니라 출처 표시다. 한 poll 안에서 값이 갈리면 그 자체가 사고 신호 |
| 활성 번들이 이 노선을 미지원 | supportedScopeDigest 의 축은 (노선 키, 지평)이다. "판본 미지원"은 이 축으로 물을 수 없는 질문이다 → C11 |
503 MODEL_OUT_OF_SCOPE. /routes 는 목록이라 503 을 낼 수 없다 → B8 |
예보 행을 안 만든다 |
| 지원 밖 지평 | "격자에 구멍을 두고 배포하면 안 된다" | 그 항목이 응답에서 통째로 빠진다. 사유를 붙이지 않는다 | 그 대상 정류장에 예보 행을 만들지 않는다 |
| 기점에 도착 상태로 잡힘 B19 · 원천 없음 |
stationSeq = 1 · stateCd = 1. 순번 규칙대로면 stopOrder = 0 인데 그 순번은 판본에 없다 |
정한 규칙에 따라 달라진다 — 행을 살리면 그 차량이 /board·/vehicles 에 나오고, 빼면 둘 다에서 사라진다 |
지금 규칙 그대로면 복합 FK 가 거절해 관측이 안 들어간다. 첫차마다 나는 상황이다 |
| 첫차·막차가 빈 판본 | DirectionInfo 여섯 필드가 전부 필수이고 BoardRoute.directions 는 minItems: 1 |
/board 를 만들 수 없다. 마이그레이션이 실패하는 것이 아니라 api 가 정상 응답을 못 만든다 — 증상이 다르니 헷갈리지 마라 |
판본 행은 있고 시간표 4열이 NULL. 대응책은 그런 판본을 FORECAST_READY 로 안 올리는 것인데 정한 규칙이 없다 |
| 단방향 노선 ( turnSequence null) |
null 은 "단방향"이라는 뜻이다 | turnSequence: null 을 키째 빼지 말고 내보낸다. 클래스 수준 NON_NULL 을 걸면 계약을 어긴다 |
directions[] 를 어떻게 만드는지 원천에 없다 — 두 노선 다 회차가 있어 지금은 안 난다 |
vehiclesInService > 0 인데 전부 빔 |
운행 중이지만 이 정류장 지평 안에는 없다. status 를 내리지 마라 — status 는 "답을 낼 수 있는가", approachingVehicles 는 "지금 낼 답이 있는가"로 축이 다르다 |
200 · vehiclesInService 는 그 수 그대로 |
vehiclesInService = 그 poll 의 storedRows. 지평 안 차량 수가 아니다. 미매핑 관측이 excludedRows 로 빠져 작아질 수 있다 |
내리면 안 되는 것 하나만 다시 — approachingVehicles 가 비었다고 RouteStatus 를 내리면
매일 밤 노선이 PREPARING 으로 떨어졌다가 아침에 돌아온다.
6. 검수 기준 시험 시나리오 63
"이렇게 하면 맞게 만든 것이다"를 시험으로 적는다. DB 가 막는 것 · 응답이 지키는 것 · DB 로 못 막아서 다른 것으로 지키는 것 셋으로 가른다.
| 묶음 | 건수 | 무엇을 보는가 |
|---|---|---|
| 6-1 DB 제약이 막아야 하는 것 | 21 | 넣으면 거절돼야 한다. 10건은 PostgreSQL 16 에서 이미 돌려 전부 거절을 확인했다 |
| 6-2 막으면 안 되는 것 | 3 | 정상 행이 제약에 걸리지 않는지. 이쪽이 더 무섭다 — 걸리면 자료가 조용히 사라진다 |
| 6-3 코드 불변식 | 11 | 생성자와 구조. DB 까지 가서 터지면 어느 층이 망가졌는지 모른다 |
| 6-4 응답 계약 | 15 | 스키마·정렬·상한·헤더 |
| 6-5 DB 로 못 막는 불변식 | 8 | 감시 질의로 지킨다. 무엇으로 지키는지까지가 검수 항목이다 |
| 6-6 재현·회귀 | 5 | 같은 입력이면 같은 출력 |
6-1. DB 제약이 막아야 하는 것 21
| # | 넣는 것 | 막는 제약 | 상태 |
|---|---|---|---|
| 1 | 지평 13 | ck_vsp_horizon | 확인 완료 PostgreSQL 16 에서 열 건 전부 거절 |
| 2 | p_full = NaN | ck_vsp_pfull (p_full = p_full) | |
| 3 | PENDING 인데 arrived_seats 가 있음 | ck_vsp_seats_state | |
| 4 | SETTLED 인데 도착 관측이 없음 | ck_vsp_outcome_state | |
| 5 | 다른 판본의 순번에 예보를 붙임 | fk_vsp_obs (복합 FK) | |
| 6 | 판본에 없는 순번을 대상으로 | fk_vsp_stop (복합 FK) | |
| 7 | occupancy_mean = 1.4 | ck_prof_occupancy | |
| 8 | day_count > sample_count | ck_prof_counts | |
| 9 | 첫차 25:00 | ck_ref_departure_format | |
| 10 | response_received_at 이 NULL 인데 forecast_completed_at 이 있음 | ck_poll_forecast | |
| 11 | PENDING 인데 settled_at 이 있음 | ck_vsp_settled_state | 아직 안 돌렸다 위 열 건에 없다. 같이 걸어야 한다 |
| 12 | settled_at < generated_at | ck_vsp_settled_order | |
| 13 | p_full = ±Infinity | ck_vsp_pfull | |
| 14 | expected_seats < 0 | ck_vsp_expected | |
| 15 | revision = 0 | ck_prof_revision | |
| 16 | net_demand_mean = -1.5 | ck_prof_demand | |
| 17 | p_full_raw = NaN · [0,1] 밖 | ck_vsp_praw | |
| 18 | settlement = 'DONE'(다섯 값 밖) | ck_vsp_settlement | |
| 19 | cell_profile_revision = 0 | ck_vsp_revision | |
| 20 | target_stop_order = 0 | ck_vsp_target | |
| 21 | arrived_seats = -1 | ck_vsp_arrived |
9번이 B12 와 붙어 있다 — 상류가 25:00 을 주면 이 제약이 적재를 막는다.
"막는 것이 맞다"고 정할 수도 있고 "접어서 넣는다"로 정할 수도 있는데, 정하기 전에는 이 시험의 기대값을 못 적는다.
17~21 은 DDL 에 제약이 있는데 시험 목록에 없던 다섯이다 — 제약이 있으니 새로 걸 것은 없고 시험만 이어 붙이면 된다.
6-2. 막으면 안 되는 것 3
| 넣는 것 | 무엇을 확인하나 |
|---|---|
SETTLED 정상 회수 | 도착 관측 · 실제 좌석 · 회수 시각 셋이 다 참인 행. 짝 규칙 셋이 정상 행을 막지 않는지 |
expected_seats 가 NULL 인 유효 행 | p_full 만으로 응답 항목이 되는지. expectedSeats 는 선택 필드다 |
net_demand_mean 이 음수 | 하차 우세 정류장. 범위를 0 이상으로 잡았으면 여기서 걸렸을 행이다 — 순수요는 음수가 정상이다 |
6-3. 코드 불변식 11
| 어디 | 무엇을 거절해야 하나 |
|---|---|
SeatDistribution | 빈 배열 |
비유한 값 · [0,1] 밖 | |
확률 합이 1이 아님(허용 오차 1e-9) | |
배열 방어 복사 — 생성자와 mass() 둘 다. 한쪽만 복사하면 밖에서 고칠 수 있다 | |
Horizon | 0 과 13. 상한 12 가 박히는 자리가 넷(Horizon.MAX · ck_vsp_horizon · 계약의 maximum: 12 · ApprovedModelRef.covers()) — 하나만 올리면 응답이 계약을 벗어난다 |
VehicleStopTarget | 지평과 순번 차가 어긋남 |
| 좌석 결측 관측 — 설계행렬을 채울 수 없다 | |
VehicleTripKey | vehicleId 가 null 이면 객체를 만들지 않는다. 열 텍스트 120자 초과 시 예외 |
SeatForecastResult | pFullRaw 가 [0,1] 밖이거나 비유한 |
DemandProfile | revision < 1. cells 가 불변 NavigableMap 인지 |
DemandProfileRepository | "셀 하나 조회" 메서드가 없는지(구조 시험). 셀 하나씩 집어 오면 셋이 동시에 깨진다 — z화(상수를 저장하지 않고 그때 유도) · 이웃 폴백(반경 4 안의 다른 행이 손에 있어야 한다) · 구간합(통과 구간의 행들을 합쳐 읽는다) |
6-4. 응답 계약 15
| 시험 | 기대 · 근거 |
|---|---|
| 스냅샷 질의가 인덱스를 타는지 | EXPLAIN 으로 ix_poll_forecast_ready. 다만 행이 적으면 순차 스캔을 고르는 것이 정상이다 — 확인할 것은 WHERE 절이 인덱스 술어와 글자 그대로 같은지다 |
| 예보 쓰기를 강제로 실패시킨다 | /board 가 그 판을 안 고르는지. 고르면 200 에 차량만 전부 빈 응답이 나가고 정상과 구별되지 않는다 |
SUCCESS_EMPTY poll | forecast_completed_at 이 채워지는지. 안 채우면 그 판이 영구히 후보에서 빠진다 |
| 같은 자료로 두 번 요청 | 바이트 동일. vehicleId 가 null 인 항목이 섞인 경우도 같은지 → B13 |
| 승차 불가 정류장 전수 | 1650 24곳 · 3330 7곳의 approachingVehicles 가 언제나 [] |
stops[] 길이 | 판본 정류장 수와 같다(1650 89 · 3330 85). 승차 불가를 포함한 수다 — 승차 가능 143 과 헷갈리지 마라 |
| 정류장당 항목 수 | ≤ 3, horizonStops 오름차순. maxItems: 3 은 정류장마다 걸린다 — 노선 전체 LIMIT 은 뜻이 다르다 |
expectedSeats 가 없을 때 | 키가 없어야 한다(null 이 아니라) |
turnSequence 가 null 일 때 | 키가 있어야 한다(빠지면 안 된다). 클래스 수준 직렬화 규칙을 걸면 여기서 깨진다 |
1 − p_full | 반올림 없이 나가는지 — 소수 6자리 이상으로 확인. 검증 스크립트의 round(…, 4) 를 운영 질의로 옮기면 규약이 조용히 깨진다 |
| 범위 재검사 코드가 없는지 | ck_vsp_pfull 이 [0,1] + NaN·±Infinity 를 이미 거절하므로 1 − p_full 은 계약 범위에 자동으로 든다. api 에서 다시 자르지 마라 |
| 오류 봉투 | 정확히 네 필드(code · message · requestId · retryable) · Cache-Control: no-store. 디버깅용 필드를 하나 얹으면 계약 위반이다 |
Retry-After | 실제 재시도 시각을 아는 retryable 503 에만. 모르면 안 붙인다 — 숫자를 지어내면 클라이언트가 그 시각에 몰려 온다 |
| 두 응답의 캐시 | /board 와 /vehicles 의 Cache-Control 이 같은 판에서 같은 값인지. 서버가 정한다 — 프론트가 주기를 계산하지 않는다. max-age 산출식 자체가 미결이라 기대값을 아직 못 적는다 → B18 |
| 개명 지점 | station_id 가 응답 JSON 에 안 나오는지. 개명은 api 조립 두 곳뿐이다 |
6-5. DB 제약으로 표현 못 하는 불변식 8
왜 CHECK 가 안 되나 — CHECK 는 한 행 안의 열만 본다. 아래 불변식은 전부 다른 표의 값이거나 여러 행에 걸친 것이다. 트리거로 만들 수 있지만 그러지 않기로 했다 — 쓰기 경로가 processor 한 곳뿐이라는 사실에 기대고 그쪽 테스트와 감시 질의로 막는다. 그러니 이 표의 오른쪽 칸이 곧 검수 항목이다.
| 불변식 | 왜 CHECK 가 안 되나 | 무엇으로 지키나 |
|---|---|---|
horizonStops = targetStopOrder − stopOrder | stopOrder 가 관측 표에 있다 | 생성자(6-3) + 감시 질의 ①. 쓰기 경로가 하나 |
generatedAt >= observedAt | 같음 | 같은 감시 질의 ① |
| 경유 정류장에 행이 없다 | boarding_allowed 가 route_stop 에 있고 복합 FK 가 그 열을 안 본다 | 감시 질의 ② (두 표에 각각). 미결 16 — 어긋난 행이 들어와도 적재가 성공한다 |
| 한 판은 한 배포 | 행 여럿에 걸친 조건 | 감시 질의 ③. 쓰기가 poll 단위 transaction 이고 시작 시점 ACTIVE 를 내내 쓴다. 값이 갈리면 그 자체가 사고 신호 |
한 판은 같은 generatedAt | 같음 | 감시 질의 ④ |
-1 이 안 새어 든다 | CHECK 로 되지만 이미 난 적이 있는 버그라 감시도 둔다 | remaining_seats < 0 이 0건인지 · remaining_seats IS NULL ↔ seat_unavailable_reason IS NOT NULL 동치 |
| 정원 상수가 맞다 | 정원이 DB 에 없다(번들의 특징 계약이 소유) | 양방향 대조 — 상수보다 큰 값이 관측되면 상수가 작은 것이고, 최대 관측값이 상수에 계속 못 미치면 상수가 큰 것이다. 47석 오류는 후자였고 상한 검사로는 안 잡혔다 |
observed_at = poll 의 response_received_at | 다른 표 | 감시 질의. 권위는 poll 쪽이다 — SUCCESS_EMPTY 판에는 관측 행이 없는데 Board.observedAt 은 그때도 필수다 |
-- ① 지평·시각 불변식
SELECT p.vehicle_observation_id, p.target_stop_order
FROM vehicle_stop_prediction p JOIN vehicle_observation o ON o.id = p.vehicle_observation_id
WHERE p.horizon_stops <> p.target_stop_order - o.stop_order OR p.generated_at < o.observed_at;
-- ② 경유 정류장에 행이 생겼나 (demand_profile 에도 같은 질의)
SELECT count(*) FROM vehicle_stop_prediction p JOIN route_stop s
ON s.route_reference_version_id = p.route_reference_version_id AND s.stop_order = p.target_stop_order
WHERE NOT s.boarding_allowed;
-- ③ 한 판이 한 배포인가 ④ 같은 generatedAt 인가 (count(DISTINCT p.generated_at) 로 바꿔 같은 모양)
SELECT o.poll_id, count(DISTINCT p.model_deployment_id) FROM vehicle_stop_prediction p
JOIN vehicle_observation o ON o.id = p.vehicle_observation_id
GROUP BY 1 HAVING count(DISTINCT p.model_deployment_id) > 1;감시 질의는 "돌린다"가 아니라 "주기와 경보를 붙인다"까지가 검수다. 질의만 문서에 적어 두면 아무도 안 돌린다. 어느 주기로 돌고 0 이 아닐 때 누가 보는지를 같이 정해라.
6-6. 재현·회귀 5
| 시험 | 기대 |
|---|---|
| golden vector | 파이썬 산출값과 자바 추론기의 pFull·expectedSeats 대조. 번들 검증 규약은 그대로 산다 |
| 결정성 | 겹 나누기·마스킹이 행 식별자 해시(blake2b)라 학습 행의 순서가 바뀌어도 같은 결과. 예보 경로에서 난수를 쓰지 않는다 |
| 지표 재현 | 지평 12 에서 종합 70.3 · 브라이어 0.00820 · PR-AUC 0.3729 · 커버율 85.5%. 말뭉치 464,424행(arr_seat 결측 23행 제외 → 464,401) · 만석 4,851건 · 기저율 1.045% |
| 커버율 재계산 | 11,005판 × 143곳 = 157만 칸. 모델 추정치가 아니라 운행 기하에서 나오는 값이다 |
| 이어 갈 시험 | 승격 CAS 의 시험은 v4 에도 남는다 — model_deployment 가 그대로이고 ACTIVE 는 여전히 전역 1개다. 「봉인 확인 5종」은 발행 계층의 장치인데 계약의 작업량 표가 둘을 한 줄에 묶어 적었다 — 무엇이 남고 무엇이 사라지는지 목록을 갈라야 이 항목의 기대값을 적을 수 있다 |
/board 시험은 대부분 못 쓴다
회귀가 아니라 교체다. 읽는 표가 통째로 바뀌고 응답의 모양이 정류장당 값 하나에서 차량 목록으로 간다. "기존 시험이 다 통과한다"를 검수 기준으로 삼지 마라 — 통과하면 오히려 바뀐 것이 없다는 뜻이다.
7. 작업 순서 무엇부터 만들어야 다음이 안 막히나
왼쪽이 결정, 오른쪽이 구현이다. 각 결정은 바로 오른쪽 단계를 막는다 — 그 단계에 손대기 전에 정해야 한다.
| 단계 | 무엇을 만드나 | 앞이 없으면 나는 일 | 끝났다고 말할 수 있는 조건 |
|---|---|---|---|
| ① 마이그레이션 | 표 2 신설(vehicle_stop_prediction · demand_profile) · 기존 표에 열 추가 · 제약 |
없음(첫 단계). 다만 표 안에서 순서가 강제된다 — ux_obs_context 를 vehicle_stop_prediction 생성보다 앞에 만들어야 한다 |
6-1 스물한 건이 전부 거절되고 6-2 세 건이 전부 통과. 백필 0건이 맞다 |
| ② 노선정보 수집 | 방향별 첫차·막차 적재. 노선당 하루 1회(KST 03:30) + 판본 신설 시점 | 열이 NULL 이라 api 가 정상 응답을 못 만든다(DirectionInfo 여섯 필드가 전부 필수). 마이그레이션이 실패하는 것이 아니다 — 증상이 다르다 |
두 노선의 4열이 채워지고 ck_ref_departure_format 을 통과. 판본 적재 때만 갱신하면 모자란다 — 정류장이 그대로인 채 막차만 바뀌면 시간표가 조용히 낡는다 |
| ③ 적재 규칙 | 여정 키 판정 · boarding_allowed 판정 · 좌석 해석. 전부 적재 시점 한 번 |
여정 키가 없으면 라벨 회수가 통째로 안 된다. 조회 때 판정하지 마라 — 학습 쪽과 서빙 쪽에 분할 로직이 두 벌 생기면 어긋나는 순간을 못 잡는다 | remaining_seats = -1 이 0건. 승차 가능 정류장 수가 1650 65 · 3330 78(합 143). 접두 판정과 이름의 (경유) 표기가 일치(참고 구현이 assert 로 막는 그 조건) |
| ④ 추론기 | 번들 로더 · 점수화 · 예보 쓰기 · forecastCompletedAt 표시 |
여기까지 없으면 /board 가 고를 스냅샷이 하나도 없다. 관측만 쌓이고 판이 안 선다 |
golden vector 대조 통과. SUCCESS_EMPTY poll 에도 표시가 찍히는지. 한 poll 의 행이 한 배포·같은 generatedAt 인지(감시 질의 ③④) |
| ⑤ 회수 · 집계 | 라벨 회수(settlement 5값) · demand_profile 재계산 · 온라인 이동 |
④가 없으면 회수할 예보가 없다. ③이 없으면 회수 자체가 불가(여정 키로 찾는다) | 다섯 값이 다 나오는지. SETTLED 만 채점·셀·이동에 쓰이는지. 노선·시간대마다 셀 행 수 = 승차 가능 정류장 수(65 / 78)이고 stopOrder 가 연속하지 않는지 |
| ⑥ api 조립 | /board 다시 쓰기 · 정렬 · 상한 3 · 개명 · 오류 봉투 |
④가 없으면 빈 응답만 나온다. ⑤가 없어도 조립은 된다 — 셀이 비면 폴백으로 도는 것이 정상이다 | 6-4 열다섯 건 전부. 정류장 조회와 예보 조회를 한 질의로 합치지 않았는지 — 합치면 LEFT JOIN 이 되고 "항목 없음"과 "항목 하나"를 조립 쪽에서 또 갈라야 한다 |
②를 건너뛰고 api 를 올리면 directions[].firstDepartureTime 이 필수인데 열이 NULL 이라
응답 조립이 그 자리에서 막힌다. 마이그레이션이 실패하는 것이 아니라 api 가 정상 응답을 못 만든다 —
로그에 뜨는 증상이 다르니 헷갈리지 마라.
그리고 ①→②→api 는 배포 순서이고, 위 표의 ①~⑥은 만드는 순서다. 둘은 다른 축이다 —
④~⑥은 코드가 다 있어도 배포 때는 ②가 끝난 뒤여야 한다.
①이 끝나면 ③(적재)과 ⑥(api 조립)은 동시에 갈 수 있다 — ⑥은 표 모양만 있으면 질의와 조립을 짤 수 있고 예보 행은 고정 자료로 채워 시험하면 된다. ④는 번들이 오기 전까지 로더와 형만 만들 수 있고 점수화의 검수(golden vector)는 번들이 온 뒤로 미뤄진다. ⑤는 ④가 만든 행이 있어야 시험이 되므로 마지막에 붙는다.
이 절이 가정하는 것 하나 — 위 순서는 4-1 의 결정 스물이 각 단계 시작 전에 내려진다는 전제 위에 있다. 임시값으로 시작하면 순서는 같지만 ③·④에서 만든 자료를 나중에 버려야 할 수 있다 — 여정 키와 시간대 축이 그 둘이다.
원천 문서
이 문서의 모든 이름과 수치는 아래에서 왔다. 여기 없는 것은 §4에서 "원천 없음"으로 표시했다.