카드 카탈로그(negodata)가 Q-table action space 를 정의하는 정본이 되고, 카드 변경이
config 수정·학습 손실 없이 agent 에 자동 반영되는 고리를 완성.
- action space 정리: 카탈로그 전체(NGC-001~011, 11장) 고정, 견적별 선택은 축소가 아니라
available_mask(_selection_mask) 로 처리 — action_id↔카드 대응을 견적마다 일정하게 유지해
Q-table 학습 일관성 보장. 구 인덱스 방식(selected[action_id]) 폐기.
- ① 카탈로그 DB 정본화: action_mapping.type=db 면 registry 가 card.nego_cards(user_id NULL,
number 순) 조회로 action_to_card 동적 구성(파일은 폴백). port/adapter(card_catalog_*).
_base=type:db. → negodata 카드 추가/삭제 시 config 수정 불필요.
- ② 차원 변경 학습 보존 마이그레이션: migrate_active_version_dim — 겹치는 셀 복사
(append/truncate 안전) + 새 카드 fresh. model_store.load 가 차원 불일치 시 호출.
- ③ reload 엔드포인트: /v1/catalog-refresh(테넌트) · /v1/catalog-refresh-all(전역, 화이트리스트).
- ④ 브랜드: company_profile_repo — 자동 온보딩 고객사(company_id UUID)는
company.companies.name 으로 {company_name} 채움. 데모 테넌트는 파일 유지.
- 크로스서비스: negodata card_service 가 공용 nego 카드 변경 시 agent_notify 로 전역 리로드 알림
(best-effort, is_test skip). config 에 agent_base_url.
- 하니스 episodes 400→600(action 11 수렴). 테스트 갱신·추가로 agent 98/98.
알려진 갭(후속): per-company 카탈로그 스코프(회사 카드도 action space 포함), 카탈로그 중간
삭제 시 카드번호 기반 마이그레이션.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
124 lines
5.5 KiB
Python
124 lines
5.5 KiB
Python
"""P5 검증 — 베이스 warm-start / cold-start 3단 (계획서 D).
|
|
|
|
1. warm_start_from_base: 차원 호환 시 base Q값/방문수 복제(visit 감쇠), base_version_id 추적.
|
|
2. cold-start: 신규 테넌트 첫 정책 로드 → v000_warmstart_from_base 생성.
|
|
3. 차원 불일치 → warm-start None → 휴리스틱 빈 버전 폴백.
|
|
4. 활성 버전 있으면 warm-start 안 함(기존 사용).
|
|
"""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from common.database.model.models import BASE_COMPANY_ID
|
|
from negotiation.policy.model_store import QTablePolicyStore
|
|
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
|
|
from tenancy.config_loader import TenantConfigLoader
|
|
from tenancy.registry import TenantEngineRegistry
|
|
|
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
|
|
|
|
|
def _reg():
|
|
return TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
|
|
|
|
|
|
async def _seed_base(S=162, A=9):
|
|
"""_base 활성 버전 + 셀 시드 (state 5 의 action 2·3)."""
|
|
base = LearningRepository(BASE_COMPANY_ID)
|
|
vid = await base.get_or_create_active_version(
|
|
state_space_size=S, action_space_size=A, learning_rate=0.1, discount_factor=0.95,
|
|
scope=1, version_name="base_v000")
|
|
await base.reset_learning()
|
|
await base.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=10)
|
|
await base.upsert_cell(vid, state_index=5, action_id=3, q_value=0.5, count=4)
|
|
return vid
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_warm_start_copies_base_with_decayed_visits(db_engine):
|
|
base_vid = await _seed_base()
|
|
repo = LearningRepository("co-new")
|
|
vid = await repo.warm_start_from_base(state_space_size=162, action_space_size=9,
|
|
learning_rate=0.1, discount_factor=0.95, visit_decay=0.5)
|
|
assert vid is not None
|
|
|
|
# 버전 메타: scope=tenant, base_version_id 추적, 활성
|
|
err, ver = await repo.read(lambda s: repo.get_version_by_name(s, "v000_warmstart_from_base"))
|
|
assert ver is not None and ver.scope == 2 and ver.is_active
|
|
assert str(ver.base_version_id) == str(base_vid)
|
|
|
|
# Q값 복제 + visit 감쇠 복제
|
|
qcells, vcells = await repo.load_cells(vid)
|
|
qmap = {(s, a): q for s, a, q in qcells}
|
|
vmap = {(s, a): c for s, a, c in vcells}
|
|
assert qmap[(5, 2)] == 0.9 and qmap[(5, 3)] == 0.5
|
|
assert vmap[(5, 2)] == 5 and vmap[(5, 3)] == 2 # 10*0.5, 4*0.5
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cold_start_creates_warmstart_version(db_engine):
|
|
await _seed_base(A=11) # ktcommerce action_space=11 과 차원 일치해야 warm-start
|
|
eng = await _reg().get_engine("ktcommerce") # 활성 버전 없음 → cold-start
|
|
policy, version_id, repo = await QTablePolicyStore.load(eng)
|
|
err, ver = await repo.read(lambda s: repo.get_active_version(s))
|
|
assert ver.version_name == "v000_warmstart_from_base"
|
|
# 복제된 Q가 정책에 적재됨
|
|
assert policy.qtable.q[5, 2] == 0.9
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_dim_change_migrates_preserving_learning(db_engine):
|
|
"""카탈로그 카드 수 변경(9→11) 시 학습 보존 마이그레이션 — 겹치는 셀 복사 + 새 카드 fresh."""
|
|
import uuid as _uuid
|
|
cid = str(_uuid.uuid4())
|
|
# 이 회사 활성 버전을 A=9 로 시드 + 셀 (5,2)=0.9
|
|
repo = LearningRepository(cid)
|
|
vid = await repo.get_or_create_active_version(
|
|
state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95,
|
|
scope=2, version_name="old_v9")
|
|
await repo.upsert_cell(vid, state_index=5, action_id=2, q_value=0.9, count=7)
|
|
|
|
# 엔진(_base type:db → 카탈로그 11장) 로드 → 9≠11 감지 → 마이그레이션
|
|
eng = await _reg().get_engine(cid)
|
|
assert eng.action_space_size == 11
|
|
policy, new_vid, _ = await QTablePolicyStore.load(eng)
|
|
assert str(new_vid) != str(vid) # 새 버전
|
|
assert policy.qtable.q[5, 2] == 0.9 # 기존 학습 보존
|
|
assert policy.qtable.q[5, 10] == 0.0 # 새 카드(action 10) fresh
|
|
assert policy.qtable.visits[5, 2] == 7 # 방문수도 보존
|
|
# 새 버전이 활성 · 차원 11
|
|
err, active = await repo.read(lambda s: repo.get_active_version(s))
|
|
assert str(active.version_id) == str(new_vid) and active.action_space_size == 11
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dimension_mismatch_falls_back_to_heuristic(db_engine):
|
|
await _seed_base(S=162, A=9)
|
|
repo = LearningRepository("co-mismatch")
|
|
# 다른 차원 요청 → 복제 불가
|
|
vid = await repo.warm_start_from_base(state_space_size=100, action_space_size=9,
|
|
learning_rate=0.1, discount_factor=0.95)
|
|
assert vid is None # 호출자가 휴리스틱 폴백
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_base_returns_none(db_engine):
|
|
# base 미시드 → warm-start None
|
|
repo = LearningRepository("co-nobase")
|
|
vid = await repo.warm_start_from_base(state_space_size=162, action_space_size=9,
|
|
learning_rate=0.1, discount_factor=0.95)
|
|
assert vid is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_existing_version_not_warmstarted(db_engine):
|
|
await _seed_base()
|
|
eng = await _reg().get_engine("ktcommerce")
|
|
# 첫 로드 → warm-start 버전 생성
|
|
await QTablePolicyStore.load(eng)
|
|
# 둘째 로드 → 기존 활성 버전 재사용(중복 warm-start 안 함)
|
|
_, _, repo = await QTablePolicyStore.load(eng)
|
|
err, vers = await repo.read(lambda s: repo.list_versions(s))
|
|
assert len(vers) == 1
|