199 lines
7.4 KiB
Python
199 lines
7.4 KiB
Python
# lotto/tests/test_weight_evolver.py
|
||
import json
|
||
import math
|
||
import pytest
|
||
|
||
from app import weight_evolver as we
|
||
|
||
|
||
def test_clamp_and_normalize_min_floor():
|
||
"""모든 값이 0.05 이상이 되도록 보장 + 합=1.0."""
|
||
W = we.clamp_and_normalize([0.01, 0.6, 0.2, 0.1, 0.09])
|
||
assert all(w >= 0.05 - 1e-9 for w in W)
|
||
assert abs(sum(W) - 1.0) < 1e-9
|
||
|
||
|
||
def test_clamp_and_normalize_negative_becomes_floor():
|
||
W = we.clamp_and_normalize([-0.1, 0.5, 0.3, 0.2, 0.1])
|
||
assert W[0] >= 0.05 - 1e-9
|
||
assert abs(sum(W) - 1.0) < 1e-9
|
||
|
||
|
||
def test_perturbation_changes_around_base():
|
||
"""σ=0.05 정규분포 perturbation 후 정규화 — 각 값이 합리적 범위 안."""
|
||
base = [0.2, 0.2, 0.2, 0.2, 0.2]
|
||
W = we.perturb_weights(base, sigma=0.05, seed=42)
|
||
assert abs(sum(W) - 1.0) < 1e-9
|
||
assert all(w >= 0.05 - 1e-9 for w in W)
|
||
|
||
|
||
def test_dirichlet_random_distribution():
|
||
"""Dirichlet α=2 — 5종 비음수 합=1."""
|
||
W = we.dirichlet_weights(alpha=2.0, seed=42)
|
||
assert abs(sum(W) - 1.0) < 1e-9
|
||
assert all(0.05 - 1e-9 <= w <= 1.0 for w in W)
|
||
|
||
|
||
def test_generate_weekly_candidates_count():
|
||
"""6개 후보 생성 — 4 perturb + 2 dirichlet."""
|
||
base = [0.2, 0.2, 0.2, 0.2, 0.2]
|
||
trials = we.generate_weekly_candidates(base, seed=42)
|
||
assert len(trials) == 6
|
||
sources = [t["source"] for t in trials]
|
||
assert sources.count("perturb") == 4
|
||
assert sources.count("dirichlet") == 2
|
||
days = sorted(t["day_of_week"] for t in trials)
|
||
assert days == [0, 1, 2, 3, 4, 5]
|
||
|
||
|
||
def test_calc_pick_score_six_match():
|
||
"""6개 모두 일치 → 1등 → base=1.0 + bonus 1.0 = 2.0."""
|
||
score = we.calc_pick_score([1, 2, 3, 4, 5, 6], [1, 2, 3, 4, 5, 6])
|
||
assert score == pytest.approx(2.0)
|
||
|
||
|
||
def test_calc_pick_score_four_match():
|
||
"""4개 일치 → 4등 → base=4/6 + bonus 0.3."""
|
||
score = we.calc_pick_score([1, 2, 3, 4, 7, 8], [1, 2, 3, 4, 5, 6])
|
||
assert score == pytest.approx(4/6 + 0.3)
|
||
|
||
|
||
def test_calc_pick_score_three_match():
|
||
"""3개 일치 → 5등 → base=3/6 + bonus 0.1."""
|
||
score = we.calc_pick_score([1, 2, 3, 7, 8, 9], [1, 2, 3, 4, 5, 6])
|
||
assert score == pytest.approx(3/6 + 0.1)
|
||
|
||
|
||
def test_calc_pick_score_two_match_no_bonus():
|
||
"""2개 일치 → 미당첨 → base=2/6 + bonus 0."""
|
||
score = we.calc_pick_score([1, 2, 7, 8, 9, 10], [1, 2, 3, 4, 5, 6])
|
||
assert score == pytest.approx(2/6)
|
||
|
||
|
||
def test_decide_base_update_winner_4plus_replaces():
|
||
"""winner_max_correct ≥ 4 → 교체."""
|
||
current = [0.2, 0.2, 0.2, 0.2, 0.2]
|
||
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
|
||
new_base, reason = we.decide_base_update(
|
||
winner_max_correct=4,
|
||
winner_W=winner_W,
|
||
current_base=current,
|
||
)
|
||
assert new_base == winner_W
|
||
assert reason == "winner_4plus"
|
||
|
||
|
||
def test_decide_base_update_winner_3_ema_blend():
|
||
"""winner_max_correct = 3 → 0.3*winner + 0.7*current."""
|
||
current = [0.2, 0.2, 0.2, 0.2, 0.2]
|
||
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
|
||
new_base, reason = we.decide_base_update(
|
||
winner_max_correct=3,
|
||
winner_W=winner_W,
|
||
current_base=current,
|
||
)
|
||
expected = [0.3 * w + 0.7 * c for w, c in zip(winner_W, current)]
|
||
assert all(abs(a - b) < 1e-9 for a, b in zip(new_base, expected))
|
||
assert reason == "ema_blend"
|
||
|
||
|
||
def test_decide_base_update_winner_lt3_unchanged():
|
||
"""winner_max_correct ≤ 2 → 직전 base 유지."""
|
||
current = [0.2, 0.2, 0.2, 0.2, 0.2]
|
||
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
|
||
new_base, reason = we.decide_base_update(
|
||
winner_max_correct=2,
|
||
winner_W=winner_W,
|
||
current_base=current,
|
||
)
|
||
assert new_base == current
|
||
assert reason == "unchanged"
|
||
|
||
|
||
def test_decide_base_update_cold_start_returns_default():
|
||
"""current_base=None (첫 회) → 균등 default 반환."""
|
||
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
|
||
new_base, reason = we.decide_base_update(
|
||
winner_max_correct=4,
|
||
winner_W=winner_W,
|
||
current_base=None,
|
||
)
|
||
assert new_base == winner_W
|
||
assert reason == "winner_4plus"
|
||
|
||
|
||
def test_select_winner_by_lift_gating():
|
||
# engine_w 3개 + random_null 기준. lift = engine 등수점수 − random 등수점수
|
||
per_w = [
|
||
{"trial_id": 1, "day_of_week": 0, "weight": [0.2]*5, "prize_score": 5.0},
|
||
{"trial_id": 2, "day_of_week": 1, "weight": [0.3,0.2,0.2,0.2,0.1], "prize_score": 9.0},
|
||
{"trial_id": 3, "day_of_week": 2, "weight": [0.1,0.3,0.2,0.2,0.2], "prize_score": 4.0},
|
||
]
|
||
# random baseline이 8.0이면 lift는 -3, +1, -4 → 최대 lift(+1) < ε(2) → 게이팅
|
||
winner = we.select_winner_by_lift(per_w, random_score=8.0, epsilon=2.0)
|
||
assert winner["gated"] is True # 최대 lift(+1) < ε(2) → 게이팅
|
||
winner2 = we.select_winner_by_lift(per_w, random_score=3.0, epsilon=2.0)
|
||
assert winner2["gated"] is False
|
||
assert winner2["trial_id"] == 2 # prize 9 → lift +6
|
||
|
||
|
||
def test_prize_score_from_hist():
|
||
# 등수 가중치: 1등 매우 큼, 하위는 작게
|
||
s = we.prize_score_from_hist({"m3": 10, "m4": 2, "m5": 0, "m6": 0, "bonus_hits": 0})
|
||
s_big = we.prize_score_from_hist({"m3": 0, "m4": 0, "m5": 0, "m6": 1, "bonus_hits": 0})
|
||
assert s_big > s # 1등 1장이 5등 다수보다 큼
|
||
|
||
|
||
def test_select_winner_by_lift_preserves_all_keys():
|
||
"""select_winner_by_lift는 per_w 항목의 모든 키를 보존해야 한다.
|
||
best_match, weight_label 등 identity 필드가 누락되면 evaluate_weekly가 깨진다."""
|
||
per_w = [
|
||
{
|
||
"trial_id": 10,
|
||
"weight_label": "w0",
|
||
"weight": [0.2] * 5,
|
||
"prize_score": 3.0,
|
||
"best_match": 3,
|
||
},
|
||
{
|
||
"trial_id": 11,
|
||
"weight_label": "w1",
|
||
"weight": [0.3, 0.2, 0.2, 0.2, 0.1],
|
||
"prize_score": 20.0,
|
||
"best_match": 4,
|
||
},
|
||
]
|
||
result = we.select_winner_by_lift(per_w, random_score=5.0, epsilon=2.0)
|
||
assert result["gated"] is False
|
||
assert result["trial_id"] == 11
|
||
assert result["weight_label"] == "w1" # identity 키 보존
|
||
assert result["best_match"] == 4 # best_match 키 보존
|
||
assert "lift" in result # lift 추가됨
|
||
assert result["lift"] == pytest.approx(15.0)
|
||
|
||
|
||
def test_gated_path_keeps_base_via_select_winner():
|
||
"""gated=True일 때 select_winner_by_lift의 반환값 검증.
|
||
evaluate_weekly 내의 gated 분기가 올바른 값에 의존함을 확인한다."""
|
||
per_w = [
|
||
{"trial_id": 1, "weight_label": "w0", "weight": [0.2]*5,
|
||
"prize_score": 5.0, "best_match": 2},
|
||
{"trial_id": 2, "weight_label": "w1", "weight": [0.3,0.2,0.2,0.2,0.1],
|
||
"prize_score": 7.0, "best_match": 3},
|
||
]
|
||
# random_best=8.0 → 최대 engine lift=7-8=-1 → gated
|
||
result = we.select_winner_by_lift(per_w, random_score=8.0, epsilon=we.LIFT_EPSILON)
|
||
assert result["gated"] is True
|
||
assert result["lift"] < 0
|
||
|
||
# decide_base_update를 통해 gated가 unchanged를 유도하는지 확인
|
||
# (gated override가 없더라도, 현재 LIFT_EPSILON=10.0 하에서 lift<0이면 항상 gated)
|
||
current = [0.2, 0.2, 0.2, 0.2, 0.2]
|
||
# gated이면 evaluate_weekly가 current_base를 그대로 유지해야 함
|
||
# 여기서는 override 로직을 직접 재현해 검증한다
|
||
gated = result["gated"]
|
||
new_base_override = list(current) if gated else None
|
||
reason_override = "unchanged_gated" if gated else "should_not_reach"
|
||
assert new_base_override == current
|
||
assert reason_override == "unchanged_gated"
|