"""RewardCalculator — 협상 라운드 보상 계산 (우리 자체 공식, RewardConfig 주입). 클린룸: 보상 공식의 '형태'(가격보상 + 종료보상 - 페널티, 동적 가중치)는 기능적 아이디어이고, 아래 산식은 우리 자체 설계다(특정 고객 산식 복제 아님). 모든 계수는 RewardConfig 에서 주입. PoC 의도: 학습 루프가 협상 성과(타결 여부·타결가·턴 수)를 보상으로 흡수하는지 검증. 산식은 결정론적이며 RewardConfig 로 튜닝 가능하다. """ from dataclasses import dataclass from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot from tenancy.config import RewardConfig @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): self._cfg = config def _price_reward(self, snapshot: NegotiationSnapshot) -> float: """KT 구매자 관점: 협력사 제시가가 낮을수록 보상↑ (anchor 앵커링가 < target 목표 매입가). progress = clip((target - input) / (target - anchor), 0, 1). 제시가가 목표가면 0, 앵커링가까지 내려오면 1, 더 낮으면 1로 캡. """ band = snapshot.target_price - snapshot.anchor_price if band <= 0: return 0.0 return _clip((snapshot.target_price - snapshot.input_price) / band, 0.0, 1.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: """라운드가 진행될수록 가중치를 beta 만큼 감쇠해 [min_weight, max_weight] 로 클립. w = max_weight - beta * round_number → 초반 라운드일수록 가격보상을 더 크게 본다. (w1~w5 는 차원별 가중치로 P-후속 단계에서 state 차원 중요도에 결합 예정.) """ w = self._cfg.max_weight - self._cfg.beta * max(0, snapshot.round_number) return _clip(w, 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 + end_reward - penalty return RewardBreakdown( price_reward=price_reward, end_reward=end_reward, penalty=penalty, weight=weight, total=total, )