"""에피소드 시뮬레이터 — 정책 vs 구매자의 한 협상 시퀀스 (H5). 한 에피소드: 앵커에서 시작, 매 턴 정책이 카드 선택 → 구매자 반응(수락/계속/이탈) → 보상. 수락 시 타결가로 성공 종료, 마지막 턴까지 미수락이면 결렬. 보상은 RewardCalculator(테넌트 config)로 채점. 정책의 update(Transition)로 온라인 학습(시퀀스 보상). 동일 seed 시 재현 가능(페어드 비교). """ from dataclasses import dataclass from eval_harness.buyer import HeuristicBuyer, Scenario from negotiation.policies.base import EpisodeState, PolicyContext, Transition from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot from negotiation.qtable.domain.service.reward_calculator import RewardCalculator from negotiation.qtable.domain.service.state_calculator import state_index from tenancy.config import RewardConfig, StateConfig @dataclass class EpisodeResult: success: bool settled_price: float turns: int total_reward: float target_price: float first_action: int = -1 # 첫 턴 선택 카드(학습 수렴 측정용) @property def settled_ratio(self) -> float: return self.settled_price / self.target_price if self.target_price else 0.0 def run_episode(policy, buyer: HeuristicBuyer, scenario: Scenario, state_cfg: StateConfig, reward_cfg: RewardConfig, action_space_size: int, max_turns: int = 5, learn: bool = True) -> EpisodeResult: rc = RewardCalculator(reward_cfg) episode = EpisodeState() total_r = 0.0 # 협력사는 목표가보다 높게 시작(협상 여지). KT 가 카드로 앵커가 이하까지 끌어내린다. price = scenario.target_price * 1.15 last_idx = last_action = None first_action = -1 def snap(p, turn, outcome): return NegotiationSnapshot( revenue_amount=scenario.revenue_amount, distribution_code=scenario.distribution_code, partner_count=scenario.partner_count, acceptance_ratio=scenario.acceptance_ratio, input_price=p, anchor_price=scenario.anchor_price, target_price=scenario.target_price, round_number=turn, outcome=outcome, ) for turn in range(1, max_turns + 1): s = snap(price, turn, NegotiationOutcome.ONGOING) idx = state_index(s, state_cfg) ctx = PolicyContext(state_index=idx, snapshot=s, action_space_size=action_space_size, episode=episode) decision = policy.select(ctx) last_idx, last_action = idx, decision.action_id if turn == 1: first_action = decision.action_id resp = buyer.respond(decision.action_id, scenario, turn, price) price = resp.new_price # 협력사가 양보한 새 가격 # 우선협상(제시가 ≤ anchor) 또는 협력사 수락 → 타결 if resp.accept or price <= scenario.anchor_price: fs = snap(price, turn, NegotiationOutcome.SUCCESS) r = rc.calculate(fs).total # 낮은 타결가일수록 보상↑ if learn: policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=r, done=True)) total_r += r return EpisodeResult(True, price, turn, total_r, scenario.target_price, first_action) # 미타결: 진행 보상 후 다음 턴(가격은 계속 내려간 상태) r = rc.calculate(s).total if learn: policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=r, done=False)) total_r += r # 카드 소진/라운드 종료까지 우선협상 미달 → 결렬 fs = snap(price, max_turns, NegotiationOutcome.FAILURE) r = rc.calculate(fs).total if learn and last_idx is not None: policy.update(Transition(state_index=last_idx, action_id=last_action, reward=r, done=True)) total_r += r return EpisodeResult(False, price, max_turns, total_r, scenario.target_price, first_action)