kcg-monitoring/docs/GEAR-PARENT-INFERENCE-ALGORITHM-SPEC.md
htlee 7dd46f2078 feat: 어구 모선 추론(Gear Parent Inference) 시스템 이식
Codex Lab 환경(iran-airstrike-replay-codex)에서 검증 완료된
어구 모선 자동 추론 + 검토 워크플로우 전체를 이식.

## Python (prediction/)
- gear_parent_inference(1,428줄): 다층 점수 모델 (correlation + name + track + prior bonus)
- gear_parent_episode(631줄): Episode 연속성 (Jaccard + 공간거리)
- gear_name_rules: 모선 이름 정규화 + 4자 미만 필터
- scheduler: 추론 호출 단계 추가 (4.8)
- fleet_tracker/kcgdb: SQL qualified_table() 동적화
- gear_correlation: timestamp 필드 추가

## DB (database/migration/ 012~015)
- 후보 스냅샷, resolution, episode, 라벨 세션, 제외 관리 테이블 9개 + VIEW 2개

## Backend (Java)
- 12개 DTO/Controller (ParentInferenceWorkflowController 등)
- GroupPolygonService: parent_resolution LEFT JOIN + 15개 API 메서드

## Frontend
- ParentReviewPanel: 모선 검토 대시보드
- vesselAnalysis: 10개 신규 API 함수 + 6개 타입

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:42:31 +09:00

515 lines
17 KiB
Markdown

# Gear Parent Inference Algorithm Spec
## 문서 목적
이 문서는 현재 구현된 어구 모선 추적 알고리즘을 모듈, 메서드, 파라미터, 판단 기준, 저장소, 엔드포인트, 영향 관계 기준으로 정리한 구현 명세다. `GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md`가 서술형 통합 문서라면, 이 문서는 구현과 후속 변경 작업에 바로 연결할 수 있는 참조 스펙이다.
## 1. 시스템 요약
### 1.1 현재 목적
- 최근 24시간 한국 수역 AIS를 캐시에 유지한다.
- 어구 이름 패턴과 위치를 기준으로 어구 그룹을 만든다.
- 주변 선박/오분류 어구를 correlation 후보로 평가한다.
- 후보 중 대표 모선 가능성이 높은 선박을 추론한다.
- 사람의 라벨/제외를 별도 저장소에 남겨 향후 모델 평가와 자동화 전환에 활용한다.
### 1.2 현재 점수 구조의 역할 구분
- `gear_correlation_scores.current_score`
- 후보 스크리닝용 correlation score
- EMA 기반 단기 메모리
- `gear_group_parent_candidate_snapshots.final_score`
- 모선 추론용 최종 후보 점수
- coverage-aware 보정과 이름/안정성/episode/lineage/label prior 반영
- `gear_group_parent_resolution`
- 그룹별 현재 추론 상태
- `gear_group_episodes`, `gear_group_episode_snapshots`
- `sub_cluster_id`와 분리된 continuity memory
- `gear_parent_label_tracking_cycles`
- 라벨 세션 동안의 자동 추론 성능 추적
## 2. 현재 DB 저장소와 유지 기간
| 저장소 | 역할 | 현재 유지 규칙 |
| --- | --- | --- |
| `group_polygon_snapshots` | `1h/1h-fb/6h` 그룹 스냅샷 | `7일` cleanup |
| `gear_correlation_raw_metrics` | correlation raw metric 시계열 | `7일` retention partition |
| `gear_correlation_scores` | correlation EMA score 현재 상태 | `30일` 미관측 시 cleanup |
| `gear_group_parent_candidate_snapshots` | cycle별 parent candidate snapshot | 현재 자동 cleanup 없음 |
| `gear_group_parent_resolution` | 그룹별 현재 추론 상태 1행 | 현재 자동 cleanup 없음 |
| `gear_group_episodes` | active/merged/expired episode 현재 상태 | 현재 자동 cleanup 없음 |
| `gear_group_episode_snapshots` | cycle별 episode continuity 스냅샷 | 현재 자동 cleanup 없음 |
| `gear_parent_candidate_exclusions` | 그룹/전역 후보 제외 | 기간 종료 또는 수동 해제까지 |
| `gear_parent_label_sessions` | 정답 라벨 세션 | 만료 시 `EXPIRED`, row는 유지 |
| `gear_parent_label_tracking_cycles` | 라벨 세션 cycle별 추적 | 현재 자동 cleanup 없음 |
## 3. 모듈 인덱스
### 3.1 시간/원천 적재
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/time_bucket.py` | `compute_safe_bucket()` | DB 적재 완료 전 bucket 차단 |
| `prediction/time_bucket.py` | `compute_initial_window_start()` | 초기 24h window 시작점 |
| `prediction/time_bucket.py` | `compute_incremental_window_start()` | overlap backfill 시작점 |
| `prediction/db/snpdb.py` | `fetch_all_tracks()` | safe bucket까지 초기 bulk 적재 |
| `prediction/db/snpdb.py` | `fetch_incremental()` | backfill 포함 증분 적재 |
| `prediction/cache/vessel_store.py` | `load_initial()` | 초기 메모리 캐시 구성 |
| `prediction/cache/vessel_store.py` | `merge_incremental()` | 증분 merge + dedupe |
| `prediction/cache/vessel_store.py` | `evict_stale()` | 24h sliding window 유지 |
### 3.2 어구 identity / 그룹
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/fleet_tracker.py` | `track_gear_identity()` | 어구 이름 파싱, identity log 관리 |
| `prediction/algorithms/gear_name_rules.py` | `normalize_parent_name()` | 모선명 정규화 |
| `prediction/algorithms/gear_name_rules.py` | `is_trackable_parent_name()` | 짧은 이름 제외 |
| `prediction/algorithms/polygon_builder.py` | `detect_gear_groups()` | 어구 그룹 및 서브클러스터 생성 |
| `prediction/algorithms/polygon_builder.py` | `build_all_group_snapshots()` | `1h/1h-fb/6h` 스냅샷 저장용 생성 |
### 3.3 correlation / parent inference
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `prediction/algorithms/gear_correlation.py` | `run_gear_correlation()` | raw metric + EMA score 계산 |
| `prediction/algorithms/gear_correlation.py` | `_compute_gear_vessel_metrics()` | proximity/visit/activity 계산 |
| `prediction/algorithms/gear_correlation.py` | `update_score()` | EMA + freeze/decay 상태 전이 |
| `prediction/algorithms/gear_parent_episode.py` | `build_episode_plan()` | continuity source와 episode assignment 계산 |
| `prediction/algorithms/gear_parent_episode.py` | `compute_prior_bonus_components()` | episode/lineage/label prior bonus 계산 |
| `prediction/algorithms/gear_parent_episode.py` | `sync_episode_states()` | `gear_group_episodes` upsert |
| `prediction/algorithms/gear_parent_episode.py` | `insert_episode_snapshots()` | episode snapshot append |
| `prediction/algorithms/gear_parent_inference.py` | `run_gear_parent_inference()` | 최종 모선 추론 실행 |
| `prediction/algorithms/gear_parent_inference.py` | `_build_candidate_scores()` | 후보별 상세 점수 계산 |
| `prediction/algorithms/gear_parent_inference.py` | `_name_match_score()` | 이름 점수 규칙 |
| `prediction/algorithms/gear_parent_inference.py` | `_build_track_coverage_metrics()` | coverage-aware evidence 계산 |
| `prediction/algorithms/gear_parent_inference.py` | `_select_status()` | 상태 전이 규칙 |
### 3.4 backend read model / workflow
| 모듈 | 메서드 | 역할 |
| --- | --- | --- |
| `GroupPolygonService.java` | group list/review/detail SQL | 최신 `1h` live + stale suppression read model |
| `ParentInferenceWorkflowController.java` | exclusion/label API | 사람 판단 저장소 API |
## 4. 메서드 상세
## 4.1 `prediction/time_bucket.py`
### `compute_safe_bucket(now: datetime | None = None) -> datetime`
- 입력:
- 현재 시각
- 출력:
- `safe_delay`를 뺀 뒤 5분 단위로 내림한 KST naive bucket
- 기준:
- `SNPDB_SAFE_DELAY_MIN`
- 영향:
- 초기 적재, 증분 적재, eviction 기준점
### `compute_incremental_window_start(last_bucket: datetime) -> datetime`
- 입력:
- 현재 캐시의 마지막 처리 bucket
- 출력:
- `last_bucket - SNPDB_BACKFILL_BUCKETS * 5m`
- 의미:
- 늦게 들어온 같은 bucket row 재흡수
## 4.2 `prediction/db/snpdb.py`
### `fetch_all_tracks(hours: int = 24) -> pd.DataFrame`
- 역할:
- safe bucket까지 최근 `N`시간 full load
- 핵심 쿼리 조건:
- bbox: `122,31,132,39`
- `time_bucket > window_start`
- `time_bucket <= safe_bucket`
- 출력 컬럼:
- `mmsi`, `timestamp`, `time_bucket`, `lat`, `lon`, `raw_sog`
### `fetch_incremental(last_bucket: datetime) -> pd.DataFrame`
- 역할:
- overlap backfill 포함 증분 load
- 핵심 쿼리 조건:
- `time_bucket > from_bucket`
- `time_bucket <= safe_bucket`
- 주의:
- 이미 본 bucket도 일부 다시 읽는 구조다
## 4.3 `prediction/cache/vessel_store.py`
### `load_initial(hours: int = 24) -> None`
- 역할:
- 초기 bulk DataFrame을 MMSI별 track cache로 구성
- 파생 효과:
- `_last_bucket` 갱신
- static info refresh
- permit registry refresh
### `merge_incremental(df_new: pd.DataFrame) -> None`
- 역할:
- 증분 batch merge
- 기준:
- `timestamp`, `time_bucket` 정렬
- `timestamp` 기준 dedupe
- 영향:
- 같은 bucket overlap backfill에서도 최종 row만 유지
### `evict_stale(hours: int = 24) -> None`
- 역할:
- sliding 24h 유지
- 기준:
- `time_bucket` 있으면 bucket 기준
- 없으면 timestamp fallback
## 4.4 `prediction/fleet_tracker.py`
### `track_gear_identity(gear_signals, conn) -> None`
- 역할:
- 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2` 추출
- `gear_identity_log` insert/update
- 입력:
- gear signal list
- 주요 기준:
- 정규화 길이 `< 4`면 건너뜀
- 같은 이름, 다른 MMSI는 identity migration 처리
- 영향:
- `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전 가능
## 4.5 `prediction/algorithms/polygon_builder.py`
### `detect_gear_groups(vessel_store) -> list[dict]`
- 역할:
- 어구 이름 기반 raw group 생성
- 거리 기반 서브클러스터 분리
- 근접 병합
- 입력:
- `all_positions`
- 주요 기준:
- 어구 패턴 매칭
- `440/441` 제외
- `is_trackable_parent_name()`
- `MAX_DIST_DEG = 0.15`
- 출력:
- `parent_name`, `parent_mmsi`, `sub_cluster_id`, `members`
### `build_all_group_snapshots(vessel_store, company_vessels, companies) -> list[dict]`
- 역할:
- `FLEET`, `GEAR_IN_ZONE`, `GEAR_OUT_ZONE``1h/1h-fb/6h` snapshot 생성
- 주요 기준:
- 같은 `parent_name` 전체 기준 1h active member 수
- `GEAR_OUT_ZONE` 최소 멤버 수
- parent nearby 시 `isParent=true`
## 4.6 `prediction/algorithms/gear_correlation.py`
### `run_gear_correlation(vessel_store, gear_groups, conn) -> dict`
- 역할:
- 그룹당 후보 탐색
- raw metric 저장
- EMA score 갱신
- 입력:
- `gear_groups`
- 출력:
- `updated`, `models`, `raw_inserted`
### `_compute_gear_vessel_metrics(gear_center_lat, gear_center_lon, gear_radius_nm, vessel_track, params) -> dict`
- 출력 metric:
- `proximity_ratio`
- `visit_score`
- `activity_sync`
- `composite`
- 한계:
- raw metric은 짧은 항적에 과대 우호적일 수 있음
- 이 문제는 parent inference 단계의 coverage-aware 보정에서 완화
### `update_score(prev_score, raw_score, streak, last_observed, now, gear_group_active_ratio, shadow_bonus, params) -> tuple`
- 상태:
- `ACTIVE`
- `PATTERN_DIVERGE`
- `GROUP_QUIET`
- `NORMAL_GAP`
- `SIGNAL_LOSS`
- 의미:
- correlation score는 장기 기억보다 short-memory EMA에 가깝다
## 4.7 `prediction/algorithms/gear_parent_inference.py`
### `run_gear_parent_inference(vessel_store, gear_groups, conn) -> dict[str, int]`
- 역할:
- direct parent 보강
- active exclusion/label 적용
- 후보 점수 계산
- 상태 전이
- snapshot/resolution/tracking 저장
### `_load_existing_resolution(conn, group_keys) -> dict`
- 역할:
- 현재 그룹의 이전 resolution 상태 로드
- 현재 쓰임:
- `PREVIOUS_SELECTION` 후보 seed
- `stable_cycles`
- `MANUAL_CONFIRMED` 유지
- reject cooldown
### `_build_candidate_scores(...) -> list[CandidateScore]`
- 후보 집합 원천:
- 상위 correlation 후보
- registry name exact bucket
- previous selection
- 제거 규칙:
- global exclusion
- group exclusion
- reject cooldown
- 점수 항목:
- `base_corr_score`
- `name_match_score`
- `track_similarity_score`
- `visit_score_6h`
- `proximity_score_6h`
- `activity_sync_score_6h`
- `stability_score`
- `registry_bonus`
- `china_mmsi_bonus` 후가산
### `_name_match_score(parent_name, candidate_name, registry) -> float`
- 규칙:
- 원문 동일 `1.0`
- 정규화 동일 `0.8`
- prefix/contains `0.5`
- 숫자 제거 후 문자 부분 동일 `0.3`
- else `0.0`
### `_build_track_coverage_metrics(center_track, vessel_track, gear_center_lat, gear_center_lon) -> dict`
- 역할:
- short-track 과대평가 방지용 증거 강도 계산
- 핵심 출력:
- `trackCoverageFactor`
- `visitCoverageFactor`
- `activityCoverageFactor`
- `coverageFactor`
- downstream:
- `track`, `visit`, `proximity`, `activity` raw score에 곱해 effective score 생성
## 4.8 `prediction/algorithms/gear_parent_episode.py`
### `build_episode_plan(groups, previous_by_lineage) -> EpisodePlan`
- 역할:
- 현재 cycle group을 이전 active episode와 매칭
- `NEW`, `CONTINUED`, `SPLIT_CONTINUE`, `SPLIT_NEW`, `MERGE_NEW` 결정
- 입력:
- `GroupEpisodeInput[]`
- 최근 `6h` active `EpisodeState[]`
- continuity score:
- `0.75 * member_jaccard + 0.25 * center_support`
- 기준:
- `member_jaccard`
- 중심점 거리 `12nm`
- continuity score threshold `0.45`
- merge score threshold `0.35`
- 출력:
- assignment map
- expired episode set
- merged target map
### `compute_prior_bonus_components(...) -> dict[str, float]`
- 역할:
- 동일 candidate에 대한 episode/lineage/label prior bonus 계산
- 입력 집계 범위:
- episode prior: `24h`
- lineage prior: `7d`
- label prior: `30d`
- cap:
- `episode <= 0.10`
- `lineage <= 0.05`
- `label <= 0.10`
- `total <= 0.20`
- 출력:
- `episodePriorBonus`
- `lineagePriorBonus`
- `labelPriorBonus`
- `priorBonusTotal`
### `sync_episode_states(conn, observed_at, plan) -> None`
- 역할:
- active/merged/expired episode 상태를 `gear_group_episodes`에 반영
- 기준:
- merge 대상은 `MERGED`
- continuity 없는 old episode는 `EXPIRED`
### `insert_episode_snapshots(conn, observed_at, plan, payloads) -> int`
- 역할:
- cycle별 continuity 결과와 top candidate/result를 `gear_group_episode_snapshots`에 저장
- 기록:
- `episode_id`
- `parent_episode_ids`
- `top_candidate_mmsi`
- `top_candidate_score`
- `resolution_status`
### `_select_status(top_candidate, margin, stable_cycles) -> tuple[str, str]`
- 상태:
- `NO_CANDIDATE`
- `AUTO_PROMOTED`
- `REVIEW_REQUIRED`
- `UNRESOLVED`
- auto promotion 조건:
- `target_type == VESSEL`
- `CORRELATION` source 포함
- `final_score >= 0.72`
- `margin >= 0.15`
- `stable_cycles >= 3`
- review 조건:
- `final_score >= 0.60`
## 5. 현재 엔드포인트 스펙
## 5.1 조회 계열
### `/api/kcg/vessel-analysis/groups/parent-inference/review`
- 역할:
- 최신 전역 `1h` 기준 검토 대기 목록
- 조건:
- stale resolution 숨김
- candidate count는 latest candidate snapshot 기준
### `/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference`
- 역할:
- 특정 그룹의 현재 live sub-cluster 상세
- 주의:
- “현재 최신 전역 `1h`에 실제 존재하는 sub-cluster만” 반환
### `/api/kcg/vessel-analysis/parent-inference/candidate-exclusions`
- 역할:
- 그룹/전역 제외 목록 조회
### `/api/kcg/vessel-analysis/parent-inference/label-sessions`
- 역할:
- active 또는 전체 라벨 세션 조회
## 5.2 액션 계열
### `POST /candidate-exclusions/global`
- 역할:
- 전역 후보 제외 생성
- 영향:
- 다음 cycle부터 모든 그룹에서 해당 MMSI 제거
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/exclude`
- 역할:
- 그룹 단위 후보 제외 생성
- 영향:
- 다음 cycle부터 해당 그룹에서만 제거
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/label`
- 역할:
- 기간형 정답 라벨 세션 생성
- 영향:
- 다음 cycle부터 tracking row 누적
## 6. 현재 기억 구조와 prior bonus
### 6.1 short-memory와 long-memory의 분리
- `gear_correlation_scores`
- EMA short-memory
- 미관측 시 decay
- 현재 후보 seed 역할
- `gear_group_parent_resolution`
- 현재 상태 1행
- same-episode가 아니면 `PREVIOUS_SELECTION` carry를 직접 사용하지 않음
- `gear_group_episodes`
- continuity memory
- `candidate_snapshots`
- bonus 집계 원천
### 6.2 현재 final score의 장기 기억 반영
현재는 과거 점수를 직접 carry하지 않고, 약한 prior bonus만 후가산한다.
```text
final_score =
current_signal_score
+ china_mmsi_bonus
+ prior_bonus_total
```
여기서 `prior_bonus_total`은:
- `episode_prior_bonus`
- `lineage_prior_bonus`
- `label_prior_bonus`
의 합이며 총합 cap은 `0.20`이다.
### 6.3 왜 weak prior인가
과거 점수를 그대로 넘기면:
- 다른 episode로 잘못 관성이 전이될 수 있다
- split/merge 이후 잘못된 top1이 고착될 수 있다
- 오래된 오답이 장기 drift로 남을 수 있다
그래서 현재 구현은 과거 점수를 “현재 score 자체”가 아니라 “작은 bonus”로만 쓴다.
## 7. 현재 continuity / prior 동작
### 7.1 episode continuity
- 같은 lineage 안에서 최근 `6h` active episode를 불러온다.
- continuity score가 높은 이전 episode가 있으면 `CONTINUED`
- 1개 parent episode가 여러 current cluster로 이어지면 `SPLIT_CONTINUE` + `SPLIT_NEW`
- 여러 previous episode가 하나 current cluster로 모이면 `MERGE_NEW`
- 어떤 current와도 연결되지 못한 old episode는 `EXPIRED`
### 7.2 prior 집계
| prior | 참조 범위 | 현재 집계 값 |
| --- | --- | --- |
| episode prior | 최근 동일 episode `24h` | seen_count, top1_count, avg_score, last_seen_at |
| lineage prior | 동일 이름 lineage `7d` | seen_count, top1_count, top3_count, avg_score, last_seen_at |
| label prior | 라벨 세션 `30d` | session_count, last_labeled_at |
### 7.3 구현 시 주의
- 과거 점수를 직접 누적하지 말 것
- prior는 bonus로만 사용하고 cap을 둘 것
- split/merge 이후 parent 후보 관성은 약하게만 상속할 것
- stale live sub-cluster와 vanished old sub-cluster를 혼동하지 않도록, aggregation도 최신 episode anchor를 기준으로 할 것
## 8. 참조 문서
- [GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md)
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2.md)
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md)