"""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))