feat(db): Detection Model Registry 스키마 (V034, Phase 1-1)

prediction 의 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고 프론트엔드에서
파라미터·버전을 관리할 수 있도록 하는 기반 인프라의 DB 계층.

기존 V014 correlation_param_models 패턴을 일반화:
- JSONB params + is_active(V014) → model_id × version × role×status (V034)
- 한 모델을 여러 파라미터 셋으로 동시 실행 지원 (PRIMARY/SHADOW/CHALLENGER)
- Compare API 기반 제공 (PRIMARY vs SHADOW diff 집계)

### 스키마 (테이블 4 + 뷰 1)

1. detection_models — 모델 카탈로그 (model_id PK, tier 1~5, category, entry_module/callable, is_enabled)
2. detection_model_dependencies — 모델 간 DAG 엣지 (self-loop 금지, input_key 포함)
3. detection_model_versions — 파라미터 스냅샷 + 라이프사이클 + role
   - status: DRAFT / TESTING / ACTIVE / ARCHIVED
   - role: PRIMARY / SHADOW / CHALLENGER (ACTIVE 일 때만)
   - UNIQUE partial index: model_id 당 PRIMARY×ACTIVE 최대 1건
   - SHADOW/CHALLENGER×ACTIVE 는 N건 허용 (병렬 비교)
   - parent_version_id 로 fork 계보 추적
4. detection_model_run_outputs — 버전별 실행 결과 원시 snapshot
   - PARTITION BY RANGE (cycle_started_at)
   - 초기 2개월(2026-04, 2026-05) 파티션 + 이후 월별 자동생성 TODO (Phase 1-2)
   - input_ref JSONB GIN index — 같은 입력 기준 PRIMARY×SHADOW JOIN 용
5. detection_model_metrics — 사이클 단위 집계 메트릭 (cycle_duration_ms 등)
6. v_detection_model_comparison — PRIMARY×SHADOW 같은 입력으로 JOIN 한 VIEW

### 권한
- auth_perm_tree: 'ai-operations:detection-models' (parent=admin, nav_sort=250)
- ADMIN 5 ops / OPERATOR READ+UPDATE / ANALYST·VIEWER READ

### 후속 (Phase 1-2)
- partition_manager 에 detection_model_run_outputs 월별 자동 생성/만료 로직 추가
- 기본 retention 7일 (SHADOW 대량 누적 대비)

### 검증
- Flyway 자동 적용 (백엔드 재빌드+배포)
- 이후 Python Model Registry 가 이 스키마 위에서 동작
This commit is contained in:
htlee 2026-04-20 06:22:25 +09:00
부모 2395ef1613
커밋 dae7aea861

파일 보기

@ -0,0 +1,226 @@
-- V034: Detection Model Registry 기반 스키마 (Phase 1-1)
--
-- prediction 모듈의 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고,
-- 운영자가 프론트엔드에서 파라미터·버전을 관리할 수 있도록 하는 기반 인프라.
-- 핵심 개념:
-- * Model — 독립 탐지 단위 (dark_suspicion / gear_violation_g01_g06 등)
-- * Version — 같은 model 의 파라미터 스냅샷. 라이프사이클 DRAFT→ACTIVE→ARCHIVED
-- * Role — PRIMARY(운영 반영, 최대 1개) / SHADOW·CHALLENGER(관측용, N개)
-- * DAG — model_id 간 선행·후행 의존성 (선행 PRIMARY 결과만 후행에 주입)
--
-- 기존 V014 correlation_param_models 패턴의 일반화 — JSONB params + is_active 를
-- model_id × version × role 차원으로 확장. V014 는 Phase 2 에서 이 스키마로
-- 이주 후 2~3 릴리즈 후 deprecate 예정.
--
-- 참고: docs/prediction-analysis.md §7 P1/P2 + .claude/plans/vast-tinkering-knuth.md
-- ══════════════════════════════════════════════════════════════════
-- 1. detection_models — 모델 카탈로그 (고정 메타)
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_models (
model_id VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(200) NOT NULL,
tier INT NOT NULL, -- 1(Primitive) ~ 5(Meta)
category VARCHAR(40), -- DARK_VESSEL/GEAR/PATTERN/TRANSSHIP/META
description TEXT,
entry_module VARCHAR(200) NOT NULL, -- 'prediction.algorithms.dark_vessel'
entry_callable VARCHAR(100) NOT NULL, -- 'compute_dark_suspicion'
is_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 전역 ON/OFF 킬스위치
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CHECK (tier BETWEEN 1 AND 5)
);
CREATE INDEX IF NOT EXISTS idx_detection_models_tier
ON kcg.detection_models(tier, category);
CREATE INDEX IF NOT EXISTS idx_detection_models_enabled
ON kcg.detection_models(is_enabled) WHERE is_enabled = TRUE;
COMMENT ON TABLE kcg.detection_models IS
'탐지 모델 카탈로그 — prediction Model 인터페이스의 단위 정의';
-- ══════════════════════════════════════════════════════════════════
-- 2. detection_model_dependencies — 모델 간 DAG 엣지
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_dependencies (
model_id VARCHAR(64) NOT NULL
REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE,
depends_on VARCHAR(64) NOT NULL
REFERENCES kcg.detection_models(model_id) ON DELETE RESTRICT,
input_key VARCHAR(64) NOT NULL, -- 'gap_info' / 'pair_result' 등
PRIMARY KEY (model_id, depends_on, input_key),
CHECK (model_id <> depends_on) -- self-loop 금지
);
CREATE INDEX IF NOT EXISTS idx_detection_model_deps_reverse
ON kcg.detection_model_dependencies(depends_on);
COMMENT ON TABLE kcg.detection_model_dependencies IS
'모델 실행 DAG — 선행 model PRIMARY 결과의 어떤 key 를 후행이 입력으로 쓰는지';
-- ══════════════════════════════════════════════════════════════════
-- 3. detection_model_versions — 파라미터 스냅샷 + 라이프사이클 + role
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_versions (
id BIGSERIAL PRIMARY KEY,
model_id VARCHAR(64) NOT NULL
REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE,
version VARCHAR(32) NOT NULL, -- SemVer 권장 '1.0.0', 자유도 허용
status VARCHAR(20) NOT NULL, -- DRAFT / TESTING / ACTIVE / ARCHIVED
role VARCHAR(20), -- PRIMARY / SHADOW / CHALLENGER (ACTIVE 일 때만)
params JSONB NOT NULL, -- 임계값·가중치·상수
notes TEXT,
traffic_weight INT DEFAULT 0, -- CHALLENGER split (0~100, 후속 기능)
parent_version_id BIGINT
REFERENCES kcg.detection_model_versions(id) ON DELETE SET NULL,
created_by UUID,
created_at TIMESTAMPTZ DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
UNIQUE (model_id, version),
CHECK (status IN ('DRAFT','TESTING','ACTIVE','ARCHIVED')),
CHECK (role IS NULL OR role IN ('PRIMARY','SHADOW','CHALLENGER')),
CHECK (status <> 'ACTIVE' OR role IS NOT NULL), -- ACTIVE → role 필수
CHECK (traffic_weight BETWEEN 0 AND 100)
);
-- 한 model_id 에 PRIMARY×ACTIVE 는 최대 1건 (운영 반영 보호)
-- SHADOW/CHALLENGER×ACTIVE 는 N 건 허용
CREATE UNIQUE INDEX IF NOT EXISTS uk_detection_model_primary
ON kcg.detection_model_versions(model_id)
WHERE status = 'ACTIVE' AND role = 'PRIMARY';
CREATE INDEX IF NOT EXISTS idx_detection_model_versions_active
ON kcg.detection_model_versions(model_id, status)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_detection_model_versions_status
ON kcg.detection_model_versions(status, model_id);
COMMENT ON TABLE kcg.detection_model_versions IS
'모델 버전 + 파라미터 + 라이프사이클. ACTIVE 는 role=PRIMARY 1개 + SHADOW/CHALLENGER N개';
-- ══════════════════════════════════════════════════════════════════
-- 4. detection_model_run_outputs — 버전별 입력·출력 비교 (파티션)
-- 같은 (cycle, model_id, input_ref) 로 PRIMARY vs SHADOW JOIN 비교
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs (
id BIGSERIAL,
cycle_started_at TIMESTAMPTZ NOT NULL, -- 같은 사이클 식별 (모든 버전 공유)
model_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL,
role VARCHAR(20) NOT NULL, -- PRIMARY / SHADOW / CHALLENGER
input_ref JSONB NOT NULL, -- {mmsi, analyzed_at} 등
outputs JSONB NOT NULL, -- 모델 반환값 snapshot
cycle_duration_ms INT,
recorded_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id, cycle_started_at),
CHECK (role IN ('PRIMARY','SHADOW','CHALLENGER'))
) PARTITION BY RANGE (cycle_started_at);
-- 2026-04 파티션 (이후는 partition_manager 가 월별 자동 생성)
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_04
PARTITION OF kcg.detection_model_run_outputs
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_05
PARTITION OF kcg.detection_model_run_outputs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE INDEX IF NOT EXISTS idx_run_outputs_compare
ON kcg.detection_model_run_outputs(model_id, cycle_started_at DESC, role);
CREATE INDEX IF NOT EXISTS idx_run_outputs_version
ON kcg.detection_model_run_outputs(version_id, cycle_started_at DESC);
CREATE INDEX IF NOT EXISTS idx_run_outputs_input
ON kcg.detection_model_run_outputs USING GIN (input_ref jsonb_path_ops);
COMMENT ON TABLE kcg.detection_model_run_outputs IS
'버전별 실행 결과 원시 snapshot — PRIMARY vs SHADOW diff 분석용. 월별 파티션, 기본 7일 retention';
-- ══════════════════════════════════════════════════════════════════
-- 5. detection_model_metrics — 사이클 단위 집계 메트릭
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_metrics (
id BIGSERIAL PRIMARY KEY,
model_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL
REFERENCES kcg.detection_model_versions(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
metric_key VARCHAR(64) NOT NULL, -- cycle_duration_ms/detected_count/tier_critical_count
metric_value NUMERIC,
cycle_started_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_lookup
ON kcg.detection_model_metrics(model_id, version_id, cycle_started_at DESC);
CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_cycle
ON kcg.detection_model_metrics(cycle_started_at DESC, model_id);
COMMENT ON TABLE kcg.detection_model_metrics IS
'모델 실행 집계 메트릭 — dashboard/compare API 의 관측 소스';
-- ══════════════════════════════════════════════════════════════════
-- 6. Compare VIEW — PRIMARY vs SHADOW 결과를 같은 입력 단위로 JOIN
-- ══════════════════════════════════════════════════════════════════
CREATE OR REPLACE VIEW kcg.v_detection_model_comparison AS
SELECT
p.cycle_started_at,
p.model_id,
p.input_ref,
p.outputs AS primary_outputs,
s.outputs AS shadow_outputs,
p.version_id AS primary_version_id,
s.version_id AS shadow_version_id,
s.role AS shadow_role
FROM kcg.detection_model_run_outputs p
JOIN kcg.detection_model_run_outputs s
ON p.cycle_started_at = s.cycle_started_at
AND p.model_id = s.model_id
AND p.input_ref = s.input_ref
WHERE p.role = 'PRIMARY'
AND s.role IN ('SHADOW', 'CHALLENGER');
COMMENT ON VIEW kcg.v_detection_model_comparison IS
'PRIMARY × SHADOW/CHALLENGER 동일 입력 결과 비교 — backend Compare API 의 집계 소스';
-- ══════════════════════════════════════════════════════════════════
-- 7. 권한 트리 / 메뉴 슬롯
-- ai-operations:detection-models — AI 모델관리 하위 혹은 별도 노드.
-- 기존 ai-operations:ai-model(nav=200) 과 구분하기 위해 별도 nav_sort=250.
-- parent_cd='admin' (AI 운영 그룹 평탄화 패턴 따름)
-- ══════════════════════════════════════════════════════════════════
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('ai-operations:detection-models', 'admin', '탐지 모델 관리', 1, 25,
'/detection-models', 'nav.detectionModels',
'features/ai-operations/DetectionModelManagement', 250,
'{"ko":"탐지 모델 관리","en":"Detection Models"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ══════════════════════════════════════════════════════════════════
-- 8. 권한 부여
-- ADMIN : 5 ops 전부 (모델 카탈로그 관리)
-- OPERATOR : READ + UPDATE (SHADOW activate 정도. promote-primary 는 ADMIN 만)
-- ANALYST/VIEWER: READ (파라미터 조회 + Compare 분석)
-- FIELD : (생략 — 현장 단속 담당, 모델 관리 불필요)
-- ══════════════════════════════════════════════════════════════════
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
WHERE r.role_cd = 'OPERATOR'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:detection-models', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('ANALYST', 'VIEWER')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;