"""build_state_features — snapshot(raw 연속값) → 실수 벡터 (DQN/action-as-feature 용). state_calculator.build_state(이산화)와 대비되는 연속 표현. 이산화(등급/162칸)를 하지 않고 정규화된 raw 값을 그대로 벡터로 내보낸다. 협력사 특징(매출·경쟁사수·유통)이 벡터에 포함되므로 '협력사를 입력으로'(Phase 3)가 자연스럽게 달성된다. """ import numpy as np from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot DIST_CLASSES = ("A", "B", "C") STATE_FEATURE_DIM = 9 # build_state_features 벡터 길이. feature 추가 시 갱신. TENANT_FEATURE_DIM = 5 # build_tenant_features 벡터 길이. def build_tenant_features(reward_cfg) -> np.ndarray: """고객사 '성향'을 ID 가 아니라 보상 설정값(내용)으로 벡터화 (Phase 3 고객사 조건화). 새 고객사도 tenant.yaml 의 reward 설정만 있으면 즉시 조건화된다 (cold-start 없음). """ return np.array([ reward_cfg.max_weight, # 가격 중시 정도 (W↑ = 가격보상 비중↑) reward_cfg.success_reward / 2.0, # 성사를 얼마나 크게 치는가 -reward_cfg.failure_penalty / 2.0, # 결렬을 얼마나 무서워하는가 reward_cfg.penalty_lambda * 20.0, # 속도 성향 (오래 끌수록 벌점) reward_cfg.beta, # 앵커 초과달성 보너스 성향 ], dtype=np.float32) def build_state_features(s: NegotiationSnapshot) -> np.ndarray: """정규화된 연속 상태 벡터. 등급화 없음 — 990원과 850원이 구별된다.""" dist_onehot = [1.0 if s.distribution_code == c else 0.0 for c in DIST_CLASSES] anchor = max(s.anchor_price, 1.0) target = max(s.target_price, 1.0) return np.array([ min(s.revenue_amount, 5e8) / 5e8, # 협력사 매출 (0~1) *dist_onehot, # 유통 A/B/C min(s.partner_count, 5) / 5.0, # 대안 협력사 수 (BATNA) float(np.clip(s.acceptance_ratio, 0.0, 1.0)), # 수용률 float(np.clip((s.input_price - anchor) / anchor, -1.0, 2.0)), # 앵커 대비 격차 (연속!) float(np.clip((target - s.input_price) / target, -2.0, 1.0)), # 목표 대비 여유 min(s.round_number, 10) / 10.0, # 라운드 ], dtype=np.float32)