o2o-negosium-original/agent/negotiation/qtable/domain/service/state_calculator.py

90 lines
3.6 KiB
Python

"""build_state — NegotiationSnapshot + StateConfig → State/state_index (우리 자체 구현).
각 차원 인덱스 산출 규칙(기능적 방법)은 우리 설계이며, 임계값/코드맵은 config 주입이다.
도메인은 tenant-agnostic: 같은 config + 같은 snapshot 이면 항상 같은 출력(결정론).
"""
from typing import List
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
from negotiation.qtable.domain.model.state import State, encode_index
from tenancy.config import StateConfig
def _threshold_bucket(value: float, thresholds: List[float]) -> int:
"""thresholds 경계로 구간 인덱스 산출. value <= thresholds[i] 이면 i, 모두 초과면 마지막 구간.
경계 포함 규칙: value <= threshold → 해당 구간(하한). (예: th=[10,30] → ≤10:0, ≤30:1, else:2)
"""
for i, th in enumerate(thresholds):
if value <= th:
return i
return len(thresholds) # 마지막 구간 (= dim-1, dim = len(thresholds)+1)
def _acceptance_bucket(ratio: float, thresholds: List[float]) -> int:
"""수용률 구간: < thresholds[0] → 0, <= thresholds[1] → 1, ... else 마지막.
하한은 strict-less, 이후 경계는 inclusive (low 는 미만, mid 이상은 이하).
"""
if ratio < thresholds[0]:
return 0
for i in range(1, len(thresholds)):
if ratio <= thresholds[i]:
return i
return len(thresholds)
def _partner_bucket(count: int) -> int:
"""파트너 수 → 인덱스. 규약: single=0, multiple=1, none=2."""
if count <= 0:
return 2 # none
if count == 1:
return 0 # single
return 1 # multiple
def _price_zone_bucket(input_price: float, anchor_price: float, target_price: float) -> int:
"""입력가격 구간 (KT 구매자 관점, anchor=협력사 기준가 ≥ target=KT 목표 매입가).
협력사 제시가가 앵커가 이하면 우선협상 가능 구간(0), 초과면 추가 협상 구간(1).
"""
if anchor_price <= 0 or target_price <= 0:
raise ValueError("anchor_price/target_price must be positive")
if input_price <= anchor_price:
return 0 # at_or_below_anchor (우선협상 가능)
return 1 # above_anchor (협상 지속)
def state_dims(cfg: StateConfig) -> List[int]:
"""각 차원의 크기. config 의 weights/code_map 길이로 결정."""
return [
len(cfg.revenue.weights),
len(cfg.distribution.weights),
len(cfg.partner.weights),
len(cfg.acceptance.weights),
len(cfg.price_zone.weights),
]
def build_state(snapshot: NegotiationSnapshot, cfg: StateConfig) -> State:
"""snapshot 을 config 기준으로 이산 State 로 변환."""
revenue_idx = _threshold_bucket(snapshot.revenue_amount, cfg.revenue.thresholds)
code = (snapshot.distribution_code or "").strip()
if code not in cfg.distribution.code_map:
raise ValueError(f"unknown distribution code: {snapshot.distribution_code!r} (code_map keys={list(cfg.distribution.code_map)})")
distribution_idx = cfg.distribution.code_map[code]
partner_idx = _partner_bucket(snapshot.partner_count)
acceptance_idx = _acceptance_bucket(snapshot.acceptance_ratio, cfg.acceptance.thresholds)
price_zone_idx = _price_zone_bucket(snapshot.input_price, snapshot.anchor_price, snapshot.target_price)
return State(revenue_idx, distribution_idx, partner_idx, acceptance_idx, price_zone_idx)
def state_index(snapshot: NegotiationSnapshot, cfg: StateConfig) -> int:
"""snapshot → 단일 정수 state_index (mixed-radix)."""
st = build_state(snapshot, cfg)
return encode_index(list(st.to_tuple()), state_dims(cfg))