"""RewardCalculator — 협상 라운드 보상 계산 (설계 명세 v4 정합, RewardConfig/StateConfig 주입). 클린룸: 보상 공식의 '형태'(가격보상 + 종료보상 - 페널티, 동적 가중치)는 기능적 아이디어이고, 아래 산식은 우리 자체 설계다(특정 고객 산식 복제 아님). 모든 계수는 config 에서 주입. 산식 (q-table 상세 설명 v4 식 (8)~(14)): R = W × R_price + (1 − W) × R_end − R_penalty R_price 3단계: P < anchor → 1 + β·(anchor−P)/anchor (초과달성 보너스) anchor ≤ P ≤ target → (target−P)/(target−anchor) P > target → 0 W = clip(Σ wᵢ·Sᵢ, min_weight, max_weight) — Sᵢ 는 각 state 차원의 정규화 가중치 R_penalty = λ × round """ from dataclasses import dataclass from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot from negotiation.qtable.domain.service.state_calculator import build_state from tenancy.config import RewardConfig, StateConfig @dataclass(frozen=True) class RewardBreakdown: """보상 구성요소(투명성/디버깅용). total 이 학습에 쓰인다.""" price_reward: float end_reward: float penalty: float weight: float total: float def _clip(v: float, lo: float, hi: float) -> float: return max(lo, min(hi, v)) class RewardCalculator: def __init__(self, config: RewardConfig, state_config: StateConfig): self._cfg = config self._state_cfg = state_config def _price_reward(self, snapshot: NegotiationSnapshot) -> float: """KT 구매자 관점: 협력사 제시가가 낮을수록 보상↑ (anchor 앵커링가 < target 목표 매입가). 3단계 차등 (식 9~11): 앵커 미만이면 기본 1.0 + β 보너스, 범위 내 선형, 목표 초과면 0. """ anchor, target, price = snapshot.anchor_price, snapshot.target_price, snapshot.input_price band = target - anchor if band <= 0 or anchor <= 0: return 0.0 if price < anchor: return 1.0 + self._cfg.beta * (anchor - price) / anchor if price <= target: return (target - price) / band return 0.0 def _end_reward(self, outcome: NegotiationOutcome) -> float: if outcome == NegotiationOutcome.SUCCESS: return self._cfg.success_reward if outcome == NegotiationOutcome.FAILURE: return self._cfg.failure_penalty return self._cfg.ongoing_reward def _dynamic_weight(self, snapshot: NegotiationSnapshot) -> float: """5개 state 차원의 정규화 가중치(Sᵢ) × 메타 가중치(wᵢ) 가중합 (식 12~13). W_raw = w1·S_revenue + w2·S_dist + w3·S_partner + w4·S_accept + w5·S_pricezone, [min_weight, max_weight] 로 클립. W 가 클수록 가격보상, 작을수록 종료보상 비중↑. """ st = build_state(snapshot, self._state_cfg) s = self._state_cfg w_raw = ( self._cfg.w1 * s.revenue.weights[st.revenue_idx] + self._cfg.w2 * s.distribution.weights[st.distribution_idx] + self._cfg.w3 * s.partner.weights[st.partner_idx] + self._cfg.w4 * s.acceptance.weights[st.acceptance_idx] + self._cfg.w5 * s.price_zone.weights[st.price_zone_idx] ) return _clip(w_raw, self._cfg.min_weight, self._cfg.max_weight) def calculate(self, snapshot: NegotiationSnapshot) -> RewardBreakdown: price_reward = self._price_reward(snapshot) end_reward = self._end_reward(snapshot.outcome) weight = self._dynamic_weight(snapshot) penalty = self._cfg.penalty_lambda * max(0, snapshot.round_number) total = weight * price_reward + (1.0 - weight) * end_reward - penalty return RewardBreakdown( price_reward=price_reward, end_reward=end_reward, penalty=penalty, weight=weight, total=total, )