o2o-negosium-original/agent/eval_harness/simulator.py

84 lines
3.9 KiB
Python

"""에피소드 시뮬레이터 — 정책 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)