feat: add health-aware P2C selector as default

Score is success-rate EWMA over (1 + inflight) with a 0.05 exploration
floor so quarantined sources can prove recovery; EWMA climb doubles as
slow-start. New optional OutcomeAwareSelector port feeds attempt
outcomes.
This commit is contained in:
2026-07-21 09:22:37 -04:00
parent e1678e4ff5
commit e69dc05fd5
4 changed files with 141 additions and 4 deletions
+74
View File
@@ -0,0 +1,74 @@
"""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_missing_stats_defaults_to_zero_inflight(self):
sel = HealthAwareSelector(rng=lambda: 0.0)
assert [s.name for s in sel.order([_src("s1")], {})] == ["s1"]