# Gear Parent Inference Dataflow Paper ## 초록 이 문서는 `iran-airstrike-replay-codex`의 한국 수역 어구 모선 추적 체계를 코드 기준으로 복원하는 통합 기술 문서다. 범위는 `snpdb` 5분 궤적 적재, 인메모리 캐시 유지, 어구 그룹 검출, 서브클러스터 생성, `1h/1h-fb/6h` 폴리곤 스냅샷 저장, correlation 기반 후보 점수화, coverage-aware parent inference, `episode_id` 기반 연속성 계층, backend read model, review/exclusion/label v2까지 포함한다. 문서의 목적은 “현재 무엇이 구현되어 있고, 각 경우의 수에서 어떤 분기 규칙이 적용되는가”를 한 문서에서 복원 가능하게 만드는 것이다. ## 1. 범위와 전제 ### 1.1 구현 기준 - frontend: `frontend/` - backend: `backend/` - prediction: `prediction/` - schema migration: `database/migration/012_gear_parent_inference.sql`, `database/migration/014_gear_parent_workflow_v2_phase1.sql`, `database/migration/015_gear_parent_episode_tracking.sql` ### 1.2 실행 환경 - lab backend: `rocky-211:18083` - lab prediction: `redis-211:18091` - lab schema: `kcg_lab` - 로컬 프론트 진입점: `yarn dev:lab`, `yarn dev:lab:ssh` ### 1.3 문서의 구분 - 구현됨: - 현재 repo 코드와 lab 배포에 이미 반영된 규칙 - 후속 확장 후보: - episode continuity 위에서 추가로 올릴 `focus mode`, richer episode lineage API, calibration report ## 2. 문제 정의 이 시스템은 한국 수역에서 AIS 신호를 이용해 아래 문제를 단계적으로 푼다. 1. 최근 24시간의 선박/어구 궤적을 메모리 캐시에 유지한다. 2. 동일한 어구 이름 계열을 공간적으로 묶어 어구 그룹을 만든다. 3. 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 생성한다. 4. 주변 선박 또는 잘못 분류된 어구 AIS를 후보로 수집하고 correlation 점수를 만든다. 5. 후보를 모선 추론 점수로 다시 환산한다. 6. 사람이 라벨/제외를 누적해 모델 정확도 고도화용 데이터셋을 만든다. 핵심 난점은 아래 세 가지다. - DB 적재 지연 때문에 live incremental cache와 fresh reload가 다를 수 있다. - 같은 `parent_name` 아래에서도 실제로는 여러 공간 덩어리로 갈라질 수 있다. - 짧은 항적이 `track/proximity/activity`에서 과대평가될 수 있다. ## 3. 전체 아키텍처 흐름 ```mermaid flowchart LR A["signal.t_vessel_tracks_5min
5분 bucket linestringM"] --> B["prediction/db/snpdb.py
safe bucket + overlap backfill"] B --> C["prediction/cache/vessel_store.py
24h in-memory cache"] C --> D["prediction/fleet_tracker.py
gear_identity_log / snapshot"] C --> E["prediction/algorithms/polygon_builder.py
gear group detect + sub-cluster + snapshots"] E --> F["kcg_lab.group_polygon_snapshots"] C --> G["prediction/algorithms/gear_correlation.py
raw metrics + EMA score"] G --> H["kcg_lab.gear_correlation_raw_metrics"] G --> I["kcg_lab.gear_correlation_scores"] F --> J["prediction/algorithms/gear_parent_inference.py
candidate build + scoring + status"] H --> J I --> J K["v2 exclusions / labels"] --> J J --> L["kcg_lab.gear_group_parent_candidate_snapshots"] J --> M["kcg_lab.gear_group_parent_resolution"] J --> N["kcg_lab.gear_parent_label_tracking_cycles"] F --> O["backend GroupPolygonService"] L --> O M --> O N --> O O --> P["frontend ParentReviewPanel"] ``` ## 4. 원천 데이터와 시간 모델 ### 4.1 원천 데이터 형식 원천은 `signal.t_vessel_tracks_5min`이며, `1 row = 1 MMSI = 5분 구간의 궤적 전체`를 `LineStringM`으로 보관한다. 실제 위치 포인트는 `ST_DumpPoints(track_geom)`로 분해하고, 각 점의 timestamp는 `ST_M((dp).geom)`에서 꺼낸다. 구현 위치는 `prediction/db/snpdb.py`다. ### 4.2 safe watermark 현재 구현은 “DB 적재가 완료된 bucket만 읽는다”는 원칙을 따른다. - `prediction/time_bucket.py` - `compute_safe_bucket()` - `compute_initial_window_start()` - `compute_incremental_window_start()` - 기본값: - `SNPDB_SAFE_DELAY_MIN` - `SNPDB_BACKFILL_BUCKETS` 핵심 규칙: 1. 초기 적재는 `now - safe_delay`를 5분 내림한 `safe_bucket`까지만 읽는다. 2. 증분 적재는 `last_bucket - backfill_window`부터 `safe_bucket`까지 다시 읽는다. 3. live cache는 `timestamp`가 아니라 `time_bucket` 기준으로 24시간 cutoff를 맞춘다. ### 4.3 왜 safe watermark가 필요한가 `time_bucket > last_bucket`만 사용하면, 늦게 들어온 같은 bucket row를 영구히 놓칠 수 있다. 현재 구현은 overlap backfill과 dedupe로 이 drift를 줄인다. - 조회: `prediction/db/snpdb.py` - 병합 dedupe: `prediction/cache/vessel_store.py` ## 5. Stage 1: 캐시 적재와 유지 ### 5.1 초기 적재 `prediction/main.py`는 시작 시 `vessel_store.load_initial(24)`를 호출한다. `prediction/cache/vessel_store.py`의 규칙: 1. `snpdb.fetch_all_tracks(hours)`로 최근 24시간을 safe bucket까지 읽는다. 2. MMSI별 DataFrame으로 `_tracks`를 구성한다. 3. 최대 `time_bucket`을 `_last_bucket`으로 저장한다. 4. static info와 permit registry를 함께 refresh한다. ### 5.2 증분 병합 스케줄러는 `snpdb.fetch_incremental(vessel_store.last_bucket)`로 overlap backfill 구간을 다시 읽는다. `merge_incremental()` 규칙: 1. 기존 DataFrame과 새 batch를 합친다. 2. `timestamp`, `time_bucket`으로 정렬한다. 3. `timestamp` 기준 중복은 `keep='last'`로 제거한다. 4. batch의 최대 `time_bucket`이 더 크면 `_last_bucket`을 갱신한다. ### 5.3 stale eviction `evict_stale()`는 safe bucket 기준 24시간 이전 포인트를 제거한다. `time_bucket`이 있으면 bucket 기준, 없으면 timestamp 기준으로 fallback한다. ## 6. Stage 2: 어구 identity 추출 `prediction/fleet_tracker.py`는 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2`를 파싱하고 `gear_identity_log`를 관리한다. ### 6.1 이름 기반 필터 공통 규칙은 `prediction/algorithms/gear_name_rules.py`에 있다. - 정규화: - 대문자화 - 공백, `_`, `-`, `%` 제거 - 추적 가능 최소 길이: - 정규화 길이 `>= 4` `fleet_tracker.py`와 `polygon_builder.py`는 모두 `is_trackable_parent_name()`을 사용한다. 즉 짧은 이름은 추론 이전, 그룹 생성 이전 단계부터 제외된다. ### 6.2 identity log 동작 `fleet_tracker.py`의 핵심 분기: 1. 같은 MMSI + 같은 이름: - 기존 활성 row의 `last_seen_at`, 위치만 갱신 2. 같은 MMSI + 다른 이름: - 기존 row 비활성화 - 새 row insert 3. 다른 MMSI + 같은 이름: - 기존 row 비활성화 - 새 MMSI로 row insert - 기존 `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전 ## 7. Stage 3: 어구 그룹 생성과 서브클러스터 실제 어구 그룹은 `prediction/algorithms/polygon_builder.py`의 `detect_gear_groups()`가 만든다. ### 7.1 1차 그룹화 규칙: 1. 최신 position 이름이 어구 패턴에 맞아야 한다. 2. `STALE_SEC`를 넘는 오래된 신호는 제외한다. 3. `440`, `441` MMSI는 어구 AIS 미사용으로 간주해 제외한다. 4. `is_trackable_parent_name(parent_raw)`를 만족해야 한다. 5. 같은 `parent_name`은 공백 제거 버전으로 묶는다. ### 7.2 서브클러스터 생성 같은 이름 아래에서도 거리 기반 연결성으로 덩어리를 나눈다. - 거리 임계치: `MAX_DIST_DEG = 0.15` - 연결 규칙: - 각 어구가 클러스터 내 최소 1개와 `MAX_DIST_DEG` 이내면 같은 연결 요소 - 구현: - Union-Find 모선이 이미 있으면, 모선과 가장 가까운 클러스터를 seed cluster로 간주한다. ### 7.3 `sub_cluster_id` 부여 규칙 현재 구현은 아래와 같다. 1. 클러스터가 1개면 `sub_cluster_id = 0` 2. 클러스터가 여러 개면 `1..N` 3. 이후 동일 `parent_key`의 두 서브그룹이 다시 근접 병합되면 `sub_cluster_id = 0` 즉 `sub_cluster_id`는 영구 식별자가 아니라 “그 시점의 공간 분리 라벨”이다. ### 7.4 병합 규칙 동일 `parent_key`의 두 그룹이 다시 가까워지면: 1. 멤버를 합친다. 2. 부모 MMSI가 없는 큰 그룹에 작은 그룹의 `parent_mmsi`를 승계할 수 있다. 3. `sub_cluster_id = 0`으로 재설정한다. ### 7.5 스냅샷 생성 규칙 `build_all_group_snapshots()`는 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 만든다. - `1h` - 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `>= 2` - `1h-fb` - 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `< 2` - 리플레이/일치율 추적용 - 라이브 현황에서 제외 - `6h` - 6시간 내 stale이 아니어야 함 추가 규칙: 1. 서브클러스터 내 1h 활성 멤버가 2개 미만이면 최신 2개로 fallback display를 만든다. 2. 수역 외(`GEAR_OUT_ZONE`)인데 멤버 수가 `MIN_GEAR_GROUP_SIZE` 미만이면 스킵한다. 3. 모선이 있고, 멤버와 충분히 근접하면 `members[].isParent = true`로 같이 넣는다. ## 8. Stage 4: correlation 모델 `prediction/algorithms/gear_correlation.py`는 어구 그룹별 raw metric과 EMA score를 만든다. ### 8.1 후보 생성 입력: - group center - group radius - active ratio - group member MMSI set 출력 후보: - 선박 후보(`VESSEL`) - 잘못 분류된 어구 후보(`GEAR_BUOY`) 후보 수는 그룹당 최대 `30`개로 제한된다. ### 8.2 raw metric 선박 후보는 최근 6시간 항적 기반으로 아래 값을 만든다. - `proximity_ratio` - `visit_score` - `activity_sync` - `dtw_similarity` 어구 후보는 단순 거리 기반 `proximity_ratio`만 사용한다. ### 8.3 EMA score 모델 파라미터(`gear_correlation_param_models`)별로 아래를 수행한다. 1. composite score 계산 2. 이전 score와 streak를 읽는다 3. `update_score()`로 EMA 갱신 4. threshold 이상이거나 기존 row가 있으면 upsert 반대로 이번 사이클 후보군에서 빠진 기존 항목은 `OUT_OF_RANGE`로 fast decay된다. ### 8.4 correlation 산출물 - `gear_correlation_raw_metrics` - `gear_correlation_scores` 여기까지는 “잠재적 모선/근접 대상”의 score이고, 최종 parent inference는 아직 아니다. ## 9. Stage 5: parent inference `prediction/algorithms/gear_parent_inference.py`가 최종 모선 추론을 수행한다. 전체 진입점은 `run_gear_parent_inference(vessel_store, gear_groups, conn)`이다. ### 9.1 전체 분기 개요 ```mermaid flowchart TD A["active gear group"] --> B{"direct parent member
exists?"} B -- yes --> C["DIRECT_PARENT_MATCH
fresh resolution upsert"] B -- no --> D{"trackable parent name?"} D -- no --> E["SKIPPED_SHORT_NAME"] D -- yes --> F["build candidate set"] F --> G{"candidate exists?"} G -- no --> H["NO_CANDIDATE"] G -- yes --> I["score + rank + margin + stable cycles"] I --> J{"auto promotion rule?"} J -- yes --> K["AUTO_PROMOTED"] J -- no --> L{"top score >= 0.60?"} L -- yes --> M["REVIEW_REQUIRED"] L -- no --> N["UNRESOLVED"] ``` ### 9.1.1 episode continuity 선행 단계 현재 구현에서 `run_gear_parent_inference()`는 후보 점수를 만들기 전에 먼저 `prediction/algorithms/gear_parent_episode.py`를 호출해 active 그룹의 continuity를 계산한다. 입력: - 현재 cycle `gear_groups` - 정규화된 `parent_name` - 최근 `6h` active `gear_group_episodes` - 최근 `24h` episode prior, `7d` lineage prior, `30d` label prior 집계 핵심 규칙: 1. continuity score는 `0.75 * member_jaccard + 0.25 * center_support`다. 2. 중심점 지원값은 `12nm` 이내일수록 커진다. 3. continuity score가 충분하거나, overlap member가 있고 거리 조건을 만족하면 연결 후보로 본다. 4. 두 개 이상 active episode가 하나의 현재 cluster로 들어오면 `MERGE_NEW`다. 5. 하나의 episode가 여러 현재 cluster로 갈라지면 하나는 `SPLIT_CONTINUE`, 나머지는 `SPLIT_NEW`다. 6. 아무 previous episode와도 연결되지 않으면 `NEW`다. 7. 현재 cycle과 연결되지 못한 active episode는 `EXPIRED` 또는 `MERGED`로 종료한다. 현재 저장되는 continuity 메타데이터: - `gear_group_parent_candidate_snapshots.episode_id` - `gear_group_parent_resolution.episode_id` - `gear_group_parent_resolution.continuity_source` - `gear_group_parent_resolution.continuity_score` - `gear_group_parent_resolution.prior_bonus_total` - `gear_group_episodes` - `gear_group_episode_snapshots` ### 9.2 direct parent 보강 최신 어구 그룹에 아래 중 하나가 있으면 후보 추론 대신 직접 모선 매칭으로 처리한다. 1. `members[].isParent = true` 2. `group.parent_mmsi` 존재 이 경우: - `status = DIRECT_PARENT_MATCH` - `decision_source = DIRECT_PARENT_MATCH` - `confidence = 1.0` - `candidateCount = 0` 단, 기존 상태가 `MANUAL_CONFIRMED`면 그 수동 상태를 유지한다. ### 9.3 짧은 이름 스킵 정규화 이름 길이 `< 4`면: - 후보 생성 자체를 수행하지 않는다. - `status = SKIPPED_SHORT_NAME` - `decision_source = AUTO_SKIP` ### 9.4 후보 집합 후보 집합은 아래의 합집합이다. 1. default correlation model 상위 후보 2. registry name exact bucket 3. 기존 resolution의 `selected_parent_mmsi` 또는 이전 top candidate 여기에 아래를 적용한다. - active global exclusion 제거 - active group exclusion 제거 - 최근 reject cooldown 후보 제거 ### 9.5 이름 점수 현재 구현 규칙: 1. 원문 완전일치: `1.0` 2. 정규화 완전일치: `0.8` 3. prefix/contains: `0.5` 4. 숫자를 제거한 순수 문자 부분만 동일: `0.3` 5. 그 외: `0.0` 비교 대상: - `parent_name` - 후보 AIS 이름 - registry `name_cn` - registry `name_en` ### 9.6 coverage-aware evidence 짧은 항적 과대평가를 막기 위해 raw score와 effective score를 분리한다. evidence에 남는 값: - `trackPointCount` - `trackSpanMinutes` - `overlapPointCount` - `overlapSpanMinutes` - `inZonePointCount` - `inZoneSpanMinutes` - `trackCoverageFactor` - `visitCoverageFactor` - `activityCoverageFactor` - `coverageFactor` 현재 최종 점수에는 raw가 아니라 adjusted score가 들어간다. ### 9.7 점수 식 가중치 합은 아래다. - `0.40 * base_corr` - `0.15 * name_match` - `0.15 * track_similarity_effective` - `0.10 * visit_effective` - `0.05 * proximity_effective` - `0.05 * activity_effective` - `0.10 * stability` - `+ registry_bonus(0.05)` 그 다음 별도 후가산: - `412/413` MMSI 보너스 `+0.15` - 단, `preBonusScore >= 0.30`일 때만 적용 - `episode/lineage/label prior bonus` - 최근 동일 episode `24h` - 동일 lineage `7d` - 라벨 세션 `30d` - 총합 cap `0.20` ### 9.8 상태 전이 분기 조건: - `NO_CANDIDATE` - 후보가 하나도 없을 때 - `AUTO_PROMOTED` - `target_type == VESSEL` - candidate source에 `CORRELATION` 포함 - `final_score >= auto_promotion_threshold` - `margin >= auto_promotion_margin` - `stable_cycles >= auto_promotion_stable_cycles` - `REVIEW_REQUIRED` - `final_score >= 0.60` - `UNRESOLVED` - 나머지 추가 예외: - 기존 상태가 `MANUAL_CONFIRMED`면 수동 상태를 유지한다. - active label session이 있으면 tracking row를 별도로 적재한다. ### 9.9 산출물 - `gear_group_parent_candidate_snapshots` - `gear_group_parent_resolution` - `gear_parent_label_tracking_cycles` - `gear_group_episodes` - `gear_group_episode_snapshots` ## 10. Stage 6: backend read model backend의 중심은 `backend/.../GroupPolygonService.java`다. ### 10.1 최신 1h만 라이브로 간주 group list, review queue, detail API는 모두 최신 전역 `1h` 스냅샷만 기준으로 삼는다. 핵심 효과: 1. `1h-fb`는 라이브 현황에서 기본 제외된다. 2. 이미 사라진 과거 sub-cluster는 detail API에서 다시 보이지 않는다. ### 10.2 stale inference 차단 `resolution.last_evaluated_at >= group.snapshot_time`인 경우만 join한다. 즉 최신 group snapshot보다 오래된 candidate/resolution은 detail/review/list에서 숨긴다. 이 규칙이 `ZHEDAIYU02433`, `ZHEDAIYU02394` 유형 stale 표시를 막는다. ### 10.3 detail API 의미 `/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference` 현재 의미: - 해당 그룹의 최신 전역 `1h` live sub-cluster 집합 - 각 sub-cluster의 fresh resolution - 각 sub-cluster의 latest candidate snapshot ## 11. Stage 7: review / exclusion / label v2 v2 Phase 1은 “자동 추론 결과”와 “사람 판단 데이터”를 분리하는 구조다. ### 11.1 사람 판단 저장소 - `gear_parent_candidate_exclusions` - `gear_parent_label_sessions` - `gear_parent_label_tracking_cycles` ### 11.2 액션 의미 - 그룹 제외: - 특정 `group_key + sub_cluster_id`에서 특정 후보 MMSI를 일정 기간 제거 - 전체 후보 제외: - 특정 MMSI를 모든 그룹 후보군에서 제거 - 정답 라벨: - 특정 그룹에 대해 정답 parent MMSI를 `1/3/5일` 세션으로 지정 - prediction은 이후 cycle마다 top1/top3 여부를 추적 ### 11.3 why v2 기존 `MANUAL_CONFIRMED`/`REJECT`는 운영 override 성격이 강했고, “모델 정확도 평가용 백데이터”와 섞였다. v2는 이 둘을 분리해 라벨을 평가 데이터로 쓰도록 한다. ## 12. 실제 경우의 수 분기표 | 경우 | 구현 위치 | 현재 동작 | | --- | --- | --- | | 이름 길이 `< 4` | `gear_name_rules.py`, `fleet_tracker.py`, `polygon_builder.py`, `gear_parent_inference.py` | identity/grouping/inference 단계에서 제외 또는 `SKIPPED_SHORT_NAME` | | 직접 모선 포함 | `polygon_builder.py`, `gear_parent_inference.py` | `DIRECT_PARENT_MATCH` fresh resolution | | 같은 이름, 멀리 떨어진 어구 | `polygon_builder.py` | 별도 sub-cluster 생성 | | 두 서브클러스터가 다시 근접 | `polygon_builder.py` | 하나로 병합, `sub_cluster_id = 0` | | group 전체 1h 활성 멤버 `< 2` | `polygon_builder.py` | `1h-fb` 생성, live 현황 제외 | | 후보가 하나도 없음 | `gear_parent_inference.py` | `NO_CANDIDATE` | | 짧은 항적이 우연히 근접 | `gear_parent_inference.py` | coverage-aware 보정으로 effective score 감소 | | stale old inference가 남아 있음 | `GroupPolygonService.java` | 최신 group snapshot보다 오래되면 숨김 | | 직접 parent가 이미 있음 | `gear_parent_inference.py` | 후보 계산 대신 direct parent resolution | ## 13. `sub_cluster_id`의 한계 현재 코드에서 `sub_cluster_id`는 영구 identity가 아니다. 이유: 1. 같은 이름 그룹의 공간 분리 수가 cycle마다 달라질 수 있다. 2. 병합되면 `0`으로 재설정된다. 3. 멤버가 추가/이탈해도 기존 번호 의미가 유지된다고 보장할 수 없다. 따라서 `group_key + sub_cluster_id`는 “현재 cycle의 공간 덩어리”를 가리키는 키로는 유효하지만, 장기 연속 추적 키로는 부적합하다. ## 14. Stage 8: `episode_id` continuity + prior bonus ### 14.1 목적 현재 구현의 `episode_id`는 “같은 어구 덩어리의 시간적 연속성”을 추적하는 별도 식별자다. `sub_cluster_id`를 대체하지 않고, 그 위에 얹는 계층이다. 핵심 목적: - 작은 멤버 변화는 같은 episode로 이어 붙인다. - 구조적 split/merge는 continuity source로 기록한다. - long-memory는 `stable_cycles` 직접 승계가 아니라 약한 prior bonus로만 전달한다. ### 14.2 현재 저장소 - `gear_group_episodes` - active/merged/expired episode 현재 상태 - `gear_group_episode_snapshots` - cycle별 episode 스냅샷 - `gear_group_parent_candidate_snapshots` - `episode_id`, `normalized_parent_name`, `episode_prior_bonus`, `lineage_prior_bonus`, `label_prior_bonus` - `gear_group_parent_resolution` - `episode_id`, `continuity_source`, `continuity_score`, `prior_bonus_total` ### 14.3 continuity score 현재 continuity score는 아래다. ```text continuity_score = 0.75 * member_jaccard + 0.25 * center_support ``` - `member_jaccard` - 현재/이전 episode 멤버 MMSI Jaccard - `center_support` - 중심점 거리 `12nm` 이내일수록 높아지는 값 연결 후보 판단: - continuity score `>= 0.45` - 또는 overlap member가 있고 거리 조건을 만족하면 연결 후보로 인정 ### 14.4 continuity source 규칙 - `NEW` - 어떤 이전 episode와도 연결되지 않음 - `CONTINUED` - 1:1 continuity - `SPLIT_CONTINUE` - 하나의 이전 episode가 여러 현재 cluster로 갈라졌고, 그중 주 가지 - `SPLIT_NEW` - split로 새로 생성된 가지 - `MERGE_NEW` - 2개 이상 active episode가 의미 있게 하나의 현재 cluster로 합쳐짐 - `DIRECT_PARENT_MATCH` - 직접 모선 포함 그룹이 fresh resolution으로 정리되는 경우의 최종 resolution source ### 14.5 merge / split / expire 현재 구현 규칙: 1. split - 가장 유사한 현재 cluster 1개는 기존 episode 유지 - 나머지는 새 episode 생성 - 새 episode에는 `split_from_episode_id` 저장 2. merge - 2개 이상 previous episode가 같은 현재 cluster로 의미 있게 들어오면 새 episode 생성 - 이전 episode들은 `MERGED`, `merged_into_episode_id = 새 episode` 3. expire - 최근 `6h` active episode가 현재 cycle 어떤 cluster와도 연결되지 않으면 `EXPIRED` ### 14.6 prior bonus 계층 현재 final score에는 signal score 뒤에 아래 prior bonus가 후가산된다. - `episode_prior_bonus` - 최근 동일 episode `24h` - cap `0.10` - `lineage_prior_bonus` - 동일 정규화 이름 lineage `7d` - cap `0.05` - `label_prior_bonus` - 동일 lineage 라벨 세션 `30d` - cap `0.10` - 총합 cap - `0.20` 현재 후보가 이미 candidate set에 들어온 경우에만 적용하며, 과거 점수를 직접 carry하는 대신 약한 보너스로만 사용한다. ### 14.7 병합 후 후보 관성 질문 사례처럼 `A` episode 후보 `a`, `B` episode 후보 `b`가 있다가 병합 후 `b`가 더 적합해질 수 있다. 현재 구현은 병합 시 무조건 `A`를 유지하지 않고 새 episode를 생성해 `A/B` 둘 다의 history를 prior bonus 풀에서 재평가한다. 따라서 `b`는 완전 신규 후보처럼 0에서 시작하지 않지만, `A`의 과거 `stable_cycles`가 그대로 지배하지도 않는다. ## 15. 현재 episode 상태 흐름 ```mermaid stateDiagram-v2 [*] --> Active Active --> Active: "CONTINUED / 소규모 멤버 변동" Active --> Active: "SPLIT_CONTINUE" Active --> Active: "MERGE_NEW로 새 episode 생성 후 연결" Active --> Merged: "merged_into_episode_id 기록" Active --> Expired: "최근 6h continuity 없음" Merged --> [*] Expired --> [*] ``` ## 16. 결론 현재 구현은 아래를 모두 포함한다. - safe watermark + overlap backfill 기반 incremental 안정화 - 짧은 이름 그룹 제거 - 거리 기반 sub-cluster와 `1h/1h-fb/6h` 스냅샷 - correlation + parent inference 분리 - coverage-aware score 보정 - stale inference 차단 - direct parent supplement - v2 exclusion/label/tracking 저장소 - `episode_id` continuity와 prior bonus 남은 과제는 `episode` 자체보다, 이 continuity 계층을 read model과 시각화에서 더 설명력 있게 노출하는 것이다. 즉 다음 단계의 핵심은 episode 도입이 아니라, `episode lineage API`, calibration report, richer review analytics를 얹는 일이다. ## 17. 참고 코드 - `prediction/main.py` - `prediction/time_bucket.py` - `prediction/db/snpdb.py` - `prediction/cache/vessel_store.py` - `prediction/fleet_tracker.py` - `prediction/algorithms/gear_name_rules.py` - `prediction/algorithms/polygon_builder.py` - `prediction/algorithms/gear_correlation.py` - `prediction/algorithms/gear_parent_episode.py` - `prediction/algorithms/gear_parent_inference.py` - `backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java` - `backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java` - `database/migration/012_gear_parent_inference.sql` - `database/migration/014_gear_parent_workflow_v2_phase1.sql` - `database/migration/015_gear_parent_episode_tracking.sql`