- algorithms/dark_vessel.py: compute_dark_suspicion 에 params 인자 추가 · DARK_SUSPICION_DEFAULT_PARAMS 상수(19 가중치 + SOG/반복/gap/tier 임계) · _merge_default_params 깊이 병합 — params=None 시 BACK-COMPAT 완전 보장 · override 가 DEFAULT 를 변조하지 않는 불변성 - models_core/registered/dark_suspicion_model.py: BaseDetectionModel Adapter · AnalysisResult 리스트에서 gap_info 재평가 · evaluated/critical/high/watch_count 메트릭 기록 - models_core/seeds/v1_dark_suspicion.sql: DRAFT seed (운영 영향 0) · BEGIN/COMMIT 미포함 — 호출자 트랜잭션 제어 (psql -1 또는 wrap) · JSONB params 는 Python DEFAULT 와 1:1 일치 - models_core/seeds/README.md: 실행·승격 절차, 금지 패턴, 롤백 - tests/test_dark_suspicion_params.py: 5건 동치성 검증 · DEFAULT 형태, None↔DEFAULT 동치성, override 불변성 · seed SQL JSONB ↔ Python DEFAULT 정적 일치 검증 - 전체 20/20 테스트 통과 (Phase 1-2 기반 15 + Phase 2-1 동치성 5) - seed SQL 운영 DB dry-run(BEGIN/ROLLBACK) 성공 — INSERT 2건 정상, tier_thresholds 일치 확인 후속: gear_violation_g01_g06 / transshipment_5stage / risk_composite / pair_trawl_tier 는 별도 PR. 각 모델 같은 패턴(params 인자 + DEFAULT 상수 + Adapter + seed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
6.2 KiB
Python
160 lines
6.2 KiB
Python
"""Phase 2 PoC #1 — dark_suspicion params 외부화 동치성 테스트.
|
|
|
|
이 파일은 pandas 미설치 환경에서도 실행 가능하도록 구성한다.
|
|
`_merge_default_params` 와 DEFAULT_PARAMS 상수 자체만 단독 검증.
|
|
|
|
`compute_dark_suspicion` 전체 E2E 는 pandas 가 설치된 prediction 환경에서
|
|
수동으로 한 사이클 실행하여 신·구 diff=0 을 확인한다 (seed SQL 안내 참조).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import os
|
|
import sys
|
|
import types
|
|
import unittest
|
|
|
|
# pandas 미설치 환경 우회 — algorithms.dark_vessel 이 pandas 를 top-level import
|
|
# 하므로, 그 import 를 stub 으로 대체해 DEFAULT_PARAMS 와 _merge_default_params 만
|
|
# 추출한다.
|
|
if 'pandas' not in sys.modules:
|
|
pd_stub = types.ModuleType('pandas')
|
|
pd_stub.DataFrame = type('DataFrame', (), {}) # annotation 용 dummy
|
|
pd_stub.Timestamp = type('Timestamp', (), {})
|
|
sys.modules['pandas'] = pd_stub
|
|
|
|
# pydantic_settings stub (다른 테스트와 동일 관용)
|
|
if 'pydantic_settings' not in sys.modules:
|
|
stub = types.ModuleType('pydantic_settings')
|
|
|
|
class _S:
|
|
def __init__(self, **kw):
|
|
for name, value in self.__class__.__dict__.items():
|
|
if name.isupper():
|
|
setattr(self, name, kw.get(name, value))
|
|
|
|
stub.BaseSettings = _S
|
|
sys.modules['pydantic_settings'] = stub
|
|
|
|
# algorithms.location 도 top-level 의 haversine_nm import 가 있으므로 stub
|
|
if 'algorithms' not in sys.modules:
|
|
pkg = types.ModuleType('algorithms')
|
|
pkg.__path__ = [os.path.join(os.path.dirname(__file__), '..', 'algorithms')]
|
|
sys.modules['algorithms'] = pkg
|
|
|
|
if 'algorithms.location' not in sys.modules:
|
|
loc = types.ModuleType('algorithms.location')
|
|
loc.haversine_nm = lambda a, b, c, d: 0.0 # pragma: no cover
|
|
sys.modules['algorithms.location'] = loc
|
|
|
|
# 이제 dark_vessel 의 DEFAULT_PARAMS 와 _merge_default_params 만 조용히 import
|
|
dv = importlib.import_module('algorithms.dark_vessel')
|
|
|
|
|
|
class DarkSuspicionParamsTest(unittest.TestCase):
|
|
|
|
def test_default_params_shape(self):
|
|
"""DEFAULT_PARAMS 는 11개 패턴 + tier_thresholds + sog_thresholds 를 포함한다."""
|
|
p = dv.DARK_SUSPICION_DEFAULT_PARAMS
|
|
self.assertIn('weights', p)
|
|
self.assertIn('tier_thresholds', p)
|
|
self.assertEqual(p['tier_thresholds'], {'critical': 70, 'high': 50, 'watch': 30})
|
|
# 11 패턴 기본 가중치 키
|
|
weights = p['weights']
|
|
for key in [
|
|
'P1_moving_off', 'P1_slow_moving_off',
|
|
'P2_sensitive_zone', 'P2_special_zone',
|
|
'P3_repeat_high', 'P3_repeat_low', 'P3_recent_dark',
|
|
'P4_distance_anomaly',
|
|
'P5_daytime_fishing_off',
|
|
'P6_teleport_before_gap',
|
|
'P7_unpermitted',
|
|
'P8_very_long_gap', 'P8_long_gap',
|
|
'P9_fishing_vessel_dark', 'P9_cargo_natural_gap',
|
|
'P10_underway_deliberate', 'P10_anchored_natural',
|
|
'P11_heading_cog_mismatch',
|
|
'out_of_coverage',
|
|
]:
|
|
self.assertIn(key, weights, f'weights.{key} missing')
|
|
|
|
def test_merge_none_returns_default_reference(self):
|
|
"""params=None 이면 DEFAULT 그대로 사용 (Phase 2 이전과 동일 동작)."""
|
|
self.assertIs(dv._merge_default_params(None), dv.DARK_SUSPICION_DEFAULT_PARAMS)
|
|
|
|
def test_merge_empty_dict_returns_default_equivalent(self):
|
|
"""params={} 면 DEFAULT 와 key-level 완전 동일."""
|
|
merged = dv._merge_default_params({})
|
|
self.assertEqual(merged, dv.DARK_SUSPICION_DEFAULT_PARAMS)
|
|
|
|
def test_merge_override_replaces_only_given_keys(self):
|
|
"""override 는 해당 key 만 교체, 나머지는 DEFAULT 유지."""
|
|
override = {'tier_thresholds': {'critical': 80}}
|
|
merged = dv._merge_default_params(override)
|
|
# critical 만 교체됨
|
|
self.assertEqual(merged['tier_thresholds']['critical'], 80)
|
|
# high/watch 는 DEFAULT 유지
|
|
self.assertEqual(merged['tier_thresholds']['high'], 50)
|
|
self.assertEqual(merged['tier_thresholds']['watch'], 30)
|
|
# weights 같은 다른 최상위 키는 DEFAULT 유지
|
|
self.assertEqual(
|
|
merged['weights']['P1_moving_off'],
|
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['weights']['P1_moving_off'],
|
|
)
|
|
# override 가 DEFAULT 를 변조하지 않는다 (불변성)
|
|
self.assertEqual(
|
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['tier_thresholds']['critical'], 70,
|
|
)
|
|
|
|
def test_seed_sql_values_match_python_default(self):
|
|
"""seed SQL 의 params JSONB 가 Python DEFAULT 와 1:1 일치하는지 정적 검증."""
|
|
seed_path = os.path.join(
|
|
os.path.dirname(__file__), '..',
|
|
'models_core', 'seeds', 'v1_dark_suspicion.sql',
|
|
)
|
|
with open(seed_path, 'r', encoding='utf-8') as f:
|
|
sql = f.read()
|
|
|
|
# $json$...$json$ 블록에서 JSON 추출
|
|
start = sql.index('$json$') + len('$json$')
|
|
end = sql.index('$json$', start)
|
|
raw = sql[start:end].strip()
|
|
params = json.loads(raw)
|
|
|
|
self.assertEqual(
|
|
params['tier_thresholds'],
|
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['tier_thresholds'],
|
|
)
|
|
self.assertEqual(
|
|
params['weights'],
|
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['weights'],
|
|
)
|
|
self.assertEqual(
|
|
params['sog_thresholds'],
|
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['sog_thresholds'],
|
|
)
|
|
self.assertEqual(
|
|
params['repeat_thresholds'],
|
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['repeat_thresholds'],
|
|
)
|
|
self.assertEqual(
|
|
params['gap_min_thresholds'],
|
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['gap_min_thresholds'],
|
|
)
|
|
self.assertEqual(
|
|
params['heading_cog_mismatch_deg'],
|
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['heading_cog_mismatch_deg'],
|
|
)
|
|
self.assertEqual(
|
|
params['p4_distance_multiplier'],
|
|
dv.DARK_SUSPICION_DEFAULT_PARAMS['p4_distance_multiplier'],
|
|
)
|
|
self.assertEqual(
|
|
list(params['p5_daytime_range']),
|
|
list(dv.DARK_SUSPICION_DEFAULT_PARAMS['p5_daytime_range']),
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|