test(lotto-signals): 메트릭 함수·adaptive baseline 단위 테스트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 02:32:10 +09:00
parent b4e873b5b0
commit c985d2c605

View File

@@ -0,0 +1,126 @@
# agent-office/tests/test_lotto_signals.py
import math
import pytest
from app.curator import signals
def test_sim_consensus_top10_geomean():
"""top-10 consensus 평균이 기하평균 기반인지."""
best_picks = [
{"scores": [10, 10, 10, 10, 10]}, # high & uniform
{"scores": [9, 9, 9, 9, 9]},
{"scores": [8, 8, 8, 8, 8]},
{"scores": [7, 7, 7, 7, 7]},
{"scores": [6, 6, 6, 6, 6]},
{"scores": [5, 5, 5, 5, 5]},
{"scores": [4, 4, 4, 4, 4]},
{"scores": [3, 3, 3, 3, 3]},
{"scores": [2, 2, 2, 2, 2]},
{"scores": [1, 1, 1, 1, 1]}, # top 10
{"scores": [0, 0, 0, 0, 0]}, # bottom 10
] * 1 + [{"scores": [0, 0, 0, 0, 0]}] * 10
result = signals.sim_consensus_score(best_picks)
assert 0.0 <= result <= 1.0
assert result > 0.4
def test_sim_consensus_geomean_penalizes_imbalance():
"""5종 중 한 종만 폭주하는 outlier 후보는 균형 후보보다 작아야 한다."""
balanced = [{"scores": [5, 5, 5, 5, 5]}] * 20
imbalanced = [{"scores": [25, 0, 0, 0, 0]}] * 20
s_balanced = signals.sim_consensus_score(balanced)
s_imbalanced = signals.sim_consensus_score(imbalanced)
assert s_imbalanced < s_balanced
def test_strategy_drift_score():
"""drift = 전략별 가중치 변화 절댓값 합."""
w_prev = {"gap_focus": 0.30, "hot_focus": 0.25, "pair_bias": 0.45}
w_curr = {"gap_focus": 0.40, "hot_focus": 0.20, "pair_bias": 0.40}
result = signals.strategy_drift_score(w_prev, w_curr)
assert abs(result - 0.20) < 1e-9
def test_strategy_drift_new_strategy_appears():
"""이전에 없던 전략이 등장하면 그 가중치 전체가 drift에 가산."""
w_prev = {"gap_focus": 0.5, "hot_focus": 0.5}
w_curr = {"gap_focus": 0.4, "hot_focus": 0.4, "newbie": 0.2}
result = signals.strategy_drift_score(w_prev, w_curr)
assert abs(result - 0.4) < 1e-9
def test_confidence_score_passthrough():
"""confidence는 큐레이션 결과의 값 그대로 (0~1 clamp 확인)."""
assert signals.confidence_score({"confidence": 0.85}) == 0.85
assert signals.confidence_score({"confidence": 1.2}) == 1.0
assert signals.confidence_score({"confidence": -0.1}) == 0.0
assert signals.confidence_score({}) is None
def test_adaptive_baseline_cold_start():
"""window 크기 < 4 → warmup, z=None."""
bl = signals.AdaptiveBaseline(window=[1.0, 1.1, 0.9], window_max=8)
z, fire = bl.evaluate(value=1.5, z_normal=1.5, z_urgent=2.5)
assert fire == "warmup"
assert z is None
def test_adaptive_baseline_preparing():
"""window 4~7 → 보수적 임계치 z=2.0."""
bl = signals.AdaptiveBaseline(window=[1.0, 1.0, 1.0, 1.0], window_max=8)
z, fire = bl.evaluate(value=3.0, z_normal=1.5, z_urgent=2.5)
assert fire in ("normal", "urgent")
def test_adaptive_baseline_normal_window_full():
"""window 8 풀, value가 평균보다 1.5σ 이상이면 normal."""
bl = signals.AdaptiveBaseline(
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
window_max=8,
)
z, fire = bl.evaluate(value=1.20, z_normal=1.5, z_urgent=2.5)
assert fire == "normal"
assert z is not None and z >= 1.5
def test_adaptive_baseline_urgent():
"""z >= 2.5 → urgent."""
bl = signals.AdaptiveBaseline(
window=[1.0, 1.1, 0.9, 1.0, 1.0, 1.1, 0.9, 1.0],
window_max=8,
)
z, fire = bl.evaluate(value=2.0, z_normal=1.5, z_urgent=2.5)
assert fire == "urgent"
def test_adaptive_baseline_push_updates_window():
"""push 시 FIFO 동작."""
bl = signals.AdaptiveBaseline(window=[1, 2, 3, 4, 5, 6, 7, 8], window_max=8)
bl.push(9.0)
assert bl.window == [2, 3, 4, 5, 6, 7, 8, 9.0]
def test_decide_fire_level_combination():
"""2개 이상 normal 발화 → urgent."""
sigs = [
{"metric": "sim", "z": 1.6, "fire": "normal"},
{"metric": "drift", "z": 1.7, "fire": "normal"},
{"metric": "conf", "z": 0.5, "fire": "noop"},
]
assert signals.decide_overall_fire(sigs) == "urgent"
sigs2 = [
{"metric": "sim", "z": 1.6, "fire": "normal"},
{"metric": "drift", "z": 0.3, "fire": "noop"},
]
assert signals.decide_overall_fire(sigs2) == "normal"
sigs3 = [
{"metric": "sim", "z": 3.0, "fire": "urgent"},
{"metric": "drift", "z": 0.2, "fire": "noop"},
]
assert signals.decide_overall_fire(sigs3) == "urgent"
sigs4 = [{"metric": "sim", "z": 0.5, "fire": "noop"}]
assert signals.decide_overall_fire(sigs4) == "noop"