Files
PolyGateway/tests/unit/test_health_selector.py
T
iomgaa 6b98a89bb3 feat: default two structured re-asks and sharper AIMD cut
Round 5 left three residual failure classes; ladder exhaustion (3.2%
of structured calls with a single re-ask) and 429 leakage (5.8%, AIMD
oscillating above the sustainable point) are addressable: re-ask
default goes 1 to 2 (conservative vs instructor's 3) and the AIMD cut
factor drops to 0.5.
2026-07-21 12:52:32 -04:00

122 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HealthAwareSelector 单测(M2.5 设计 §3.2): EWMA×在途 P2C,地板探索。"""
import pytest
from polygateway.ports import OutcomeAwareSelector
from polygateway.sources import HealthAwareSelector, RoundRobinSelector
from polygateway.types import SourceConfig, SourceStats
def _src(name: str) -> SourceConfig:
return SourceConfig(
name=name,
provider="p",
base_url="https://gw.example/v1",
api_key="sk-x",
model="m",
timeout_s=60.0,
)
def _stats(**inflight: int) -> dict[str, SourceStats]:
return {name: SourceStats(inflight=n, rpm_used=0, tpm_used=0) for name, n in inflight.items()}
class TestHealthAwareSelector:
def test_implements_outcome_protocol(self):
assert isinstance(HealthAwareSelector(), OutcomeAwareSelector)
assert not isinstance(RoundRobinSelector(), OutcomeAwareSelector)
def test_unhealthy_source_demoted(self):
# s2 连续失败 → EWMA 塌陷 → 排序永远在健康源之后(rng 定值消除 P2C 随机性)
sel = HealthAwareSelector(rng=lambda: 0.0)
sources = [_src("s1"), _src("s2")]
for _ in range(10):
sel.record_outcome("s2", ok=False)
order = sel.order(sources, _stats(s1=0, s2=0))
assert [s.name for s in order] == ["s1", "s2"]
def test_floor_keeps_exploration_possible(self):
# 地板 0.05: 塌陷源分数不归零——EWMA 十次失败后仍 > 0,P2C 采样到时可胜平局
sel = HealthAwareSelector(rng=lambda: 0.0)
for _ in range(50):
sel.record_outcome("s2", ok=False)
assert sel._score("s2", _stats(s2=0)) == pytest.approx(0.05)
def test_ewma_climbs_back_on_recovery(self):
# 恢复源连续成功,EWMA α=0.2 自然爬升(即天然 slow-start)
sel = HealthAwareSelector(rng=lambda: 0.0)
for _ in range(50):
sel.record_outcome("s2", ok=False)
low = sel._score("s2", _stats(s2=0))
for _ in range(10):
sel.record_outcome("s2", ok=True)
high = sel._score("s2", _stats(s2=0))
assert high > 0.85 > low
def test_inflight_suppresses_score(self):
sel = HealthAwareSelector(rng=lambda: 0.0)
assert sel._score("s1", _stats(s1=0)) == pytest.approx(1.0)
assert sel._score("s1", _stats(s1=3)) == pytest.approx(0.25)
def test_p2c_head_randomized_rest_by_score(self):
# rng 驱动 P2C 取样: 三源同分时头名由 rng 决定;其余按分数降序
sources = [_src("s1"), _src("s2"), _src("s3")]
sel = HealthAwareSelector(rng=iter([0.9, 0.0]).__next__) # 采样 s3 与 s1 比分
for _ in range(5):
sel.record_outcome("s3", ok=False) # s3 塌陷
order = sel.order(sources, _stats(s1=0, s2=0, s3=0))
assert order[0].name == "s1" # 两候选中 s1 胜出
assert order[-1].name == "s3" # 塌陷源垫底
def test_health_exposes_raw_ewma(self):
sel = HealthAwareSelector(rng=lambda: 0.0)
assert sel.health("s1") == pytest.approx(1.0) # 乐观初始
for _ in range(3):
sel.record_outcome("s1", ok=False)
assert sel.health("s1") == pytest.approx(1.0 * 0.8**3)
def test_missing_stats_defaults_to_zero_inflight(self):
sel = HealthAwareSelector(rng=lambda: 0.0)
assert [s.name for s in sel.order([_src("s1")], {})] == ["s1"]
class TestAdaptivePacer:
"""AIMD 自适应并发(M2.5 设计 §3.35): 429 乘性削减,成功加性增长。"""
def test_initial_and_bounds(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
assert pacer.limit("s1") == pytest.approx(8.0)
for _ in range(200):
pacer.on_backpressure("s1")
assert pacer.limit("s1") == pytest.approx(1.0) # 下限 1
for _ in range(2000):
pacer.on_success("s1")
assert pacer.limit("s1") == pytest.approx(32.0) # 上限 = ceiling
def test_cut_and_growth_math(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
pacer.on_backpressure("s1")
assert pacer.limit("s1") == pytest.approx(8.0 * 0.5)
before = pacer.limit("s1")
pacer.on_success("s1")
assert pacer.limit("s1") == pytest.approx(before + 1.0 / before)
def test_inflight_gate(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
for _ in range(200):
pacer.on_backpressure("s1") # limit → 1
assert pacer.admit("s1") is True
pacer.enter("s1")
assert pacer.admit("s1") is False # 在途 1 ≥ limit 1
pacer.leave("s1")
assert pacer.admit("s1") is True
pacer.leave("s1") # 多余 leave 不下穿 0
assert pacer._inflight.get("s1", 0) == 0