feat: gate in-call demotion on credible alternative health

Round 3 showed unconditional yield-after-two-failures pushes the third
attempt onto known-bad sources in heterogeneous pools (83% vs 10%
expected success). OutcomeAwareSelector now exposes health(); a failed
source only yields when some untried candidate scores at least half its
health. Health-blind selectors keep the unconditional rule.
This commit is contained in:
2026-07-21 11:24:52 -04:00
parent 58061c7536
commit 0e17f71482
6 changed files with 95 additions and 4 deletions
+46
View File
@@ -370,11 +370,17 @@ class RecordingSelector(StaticSelector):
def record_outcome(self, source_name, ok):
self.outcomes.append((source_name, ok))
def health(self, source_name):
return 1.0
class ExplodingSelector(StaticSelector):
def record_outcome(self, source_name, ok):
raise RuntimeError("sink boom")
def health(self, source_name):
return 1.0
class TestM25Orchestration:
"""M2.5 设计 §3.3: 调用内失败降权 + 健康喂数(先红后绿)。"""
@@ -491,3 +497,43 @@ class TestAdaptivePacing:
await mw(_REQ)
assert pacer._inflight.get("a", 0) == 0
assert pacer._inflight.get("b", 0) == 0
class HealthySink(StaticSelector):
"""带健康视图的选源器桩(OutcomeAwareSelector 全量实现)。"""
def __init__(self, health):
self._health = health
self.outcomes = []
def record_outcome(self, source_name, ok):
self.outcomes.append((source_name, ok))
def health(self, source_name):
return self._health.get(source_name, 1.0)
class TestHealthGatedDemotion:
"""迭代 2(设计 §3.36): 降权需可信替代,否则原地重试。"""
async def test_no_credible_alternative_stays_on_healthy(self):
# 替补健康分 0.08 < 0.5×0.9 → 不让位,第三次仍打 a
sel = HealthySink({"a": 0.9, "b": 0.08})
mw, _, _, transport, _, _ = _harness(
[_src("a"), _src("b")],
[TransientError("1"), TransientError("2"), _ok()],
selector=sel,
)
resp = await mw(_REQ)
assert [n for n, _ in transport.calls] == ["a", "a", "a"]
assert resp.source_name == "a"
async def test_credible_alternative_still_yields(self):
sel = HealthySink({"a": 0.9, "b": 0.9})
mw, _, _, transport, _, _ = _harness(
[_src("a"), _src("b")],
[TransientError("1"), TransientError("2"), _ok()],
selector=sel,
)
await mw(_REQ)
assert [n for n, _ in transport.calls] == ["a", "a", "b"]