"""Detection Model 추상 계층. prediction 모듈의 기존 함수형 알고리즘(`algorithms/*`) 을 그대로 두고, Adapter 형태로 감싸서 "모델 단위 실행·버전·파라미터"를 표준화한다. 설계: - `ModelContext` — 한 사이클의 공통 입력/공유 상태 (불변 전제) - `ModelResult` — 한 모델·한 버전의 실행 결과 (입력별 output + 메트릭) - `BaseDetectionModel` — 등록 가능한 최소 계약 (model_id / version / role / params / run) 불변식: - SHADOW/CHALLENGER 는 `ctx.shared[model_id]` 에 기록되지 않음 (Executor 책임) - `params` 는 DRAFT 로 수정, ACTIVE 는 immutable 스냅샷 (DB 제약과 같은 규약) """ from __future__ import annotations import logging from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime from typing import Any, Optional logger = logging.getLogger(__name__) # role 상수 — DB CHECK 제약과 동일한 문자열 ROLE_PRIMARY = 'PRIMARY' ROLE_SHADOW = 'SHADOW' ROLE_CHALLENGER = 'CHALLENGER' ALLOWED_ROLES = (ROLE_PRIMARY, ROLE_SHADOW, ROLE_CHALLENGER) @dataclass class ModelContext: """한 사이클 공통 입력 + 모델 간 공유 상태. Attributes: cycle_started_at: 사이클 시작 시각 (모든 모델·버전이 공유) vessel_store: 인메모리 AIS 캐시 (Optional — 테스트 시 None 허용) conn: kcgdb psycopg2 connection (Optional — 테스트 시 None 허용) shared: 선행 모델 PRIMARY 결과 보관소. key=model_id, value=ModelResult SHADOW/CHALLENGER 는 여기에 쓰지 않는다 (오염 차단). inputs: 모델이 소비할 공통 입력 목록 (선박 row 등). 버전 간 공정 비교 보장. extras: 필요시 모델별 보조 데이터 (feature flag, tunable 등) """ cycle_started_at: datetime vessel_store: Any = None conn: Any = None shared: dict = field(default_factory=dict) inputs: list = field(default_factory=list) extras: dict = field(default_factory=dict) @dataclass class ModelResult: """한 모델·한 버전의 실행 결과. Attributes: model_id: 모델 식별자 version_id: detection_model_versions.id version_str: 'v1.0.0' 등 사람이 읽는 버전 문자열 role: PRIMARY / SHADOW / CHALLENGER outputs_per_input: [(input_ref, output_dict), ...] input_ref 는 비교용 키(예: {'mmsi': '412...', 'analyzed_at': ...}) output_dict 는 JSONB 저장 가능한 결과 snapshot metrics: detection_model_metrics 로 기록될 집계 관측치 (key=metric_key, value=numeric) duration_ms: 이 버전 단위 실행 소요 """ model_id: str version_id: int version_str: str role: str outputs_per_input: list[tuple[dict, dict]] = field(default_factory=list) metrics: dict[str, float] = field(default_factory=dict) duration_ms: int = 0 class BaseDetectionModel(ABC): """탐지 모델 추상 베이스. 구현체는 `prediction/models_core/registered/` 하위에 두고 `ModelRegistry.discover_classes()` 가 자동 import 한다. 클래스 레벨 속성(model_id / depends_on) 은 **클래스 정의 시** 고정, 인스턴스 속성(version_id / version_str / role / params) 은 `ModelRegistry` 가 ACTIVE 버전 스냅샷을 읽어 주입한다. 한 `BaseDetectionModel` 서브클래스에 대해 DB 에 N 개 ACTIVE 버전이 있으면 Registry 는 **각 버전마다 별도 인스턴스**를 생성한다 (PRIMARY 1 + SHADOW/CHALLENGER N). """ # --- 클래스 메타 (서브클래스가 override) --- model_id: str = '' depends_on: list[str] = [] # V034 스키마 컬럼 길이 상한 — 운영자 실수·장기 실행에서 silent 한 persist 실패를 # 방지하기 위해 클래스 정의 시점에 선제 검증한다. _MODEL_ID_MAXLEN = 64 def __init__( self, version_id: int, version_str: str, role: str, params: dict, ) -> None: if role not in ALLOWED_ROLES: raise ValueError(f'invalid role: {role!r} (expected {ALLOWED_ROLES})') if not self.model_id: raise ValueError( f'{type(self).__name__}.model_id is empty — override as class attribute' ) if len(self.model_id) > self._MODEL_ID_MAXLEN: raise ValueError( f'{type(self).__name__}.model_id too long ' f'({len(self.model_id)} > {self._MODEL_ID_MAXLEN}): {self.model_id!r}' ) self.version_id = version_id self.version_str = version_str self.role = role self.params: dict = dict(params) if params else {} # --- 서브클래스 구현 포인트 --- @abstractmethod def run(self, ctx: ModelContext) -> ModelResult: """한 사이클에 대해 모델을 실행. 반환값의 `outputs_per_input` 은 입력 단위 비교가 가능하도록 **같은 input_ref 스키마를 같은 model_id 내에서 유지**해야 한다. (PRIMARY 와 SHADOW 의 input_ref 가 일치해야 diff JOIN 이 가능.) """ raise NotImplementedError # --- 편의 --- def label(self) -> str: return f'{self.model_id}@{self.role}[{self.version_str}]' def __repr__(self) -> str: # pragma: no cover return f'<{type(self).__name__} {self.label()} version_id={self.version_id}>' def make_input_ref(mmsi: str, analyzed_at: Optional[datetime] = None, **extra) -> dict: """관용 input_ref 생성기. PRIMARY/SHADOW 가 같은 포맷을 쓰도록 강제하는 도우미.""" ref: dict[str, Any] = {'mmsi': str(mmsi)} if analyzed_at is not None: ref['analyzed_at'] = analyzed_at.isoformat() if isinstance(analyzed_at, datetime) else analyzed_at for k, v in extra.items(): ref[k] = v return ref