feat: redefine generation_ms as net generation time with stall attribution
CallStats gains stall_ms/stall_count (None = no stall observation); winner sample triple (wall, stall_ms, stall_count) converted once in _attempt success branch; net = max(0, wall - stall) converged into the shared _record_winner_sample used by both the single-path guard and hedge winner registration. Two-fail hedge rounds record nothing (no winner to attribute). Design section 4.2; issues #27.
This commit is contained in:
@@ -569,6 +569,27 @@ class TestHedgeFailureCombination:
|
||||
# 两败轮次同样登记对冲路数(设计 §4.5: 实际并发发出即计)
|
||||
assert ctx.snapshot().hedges == 2
|
||||
|
||||
async def test_stall_attribution_none_when_no_winner(self):
|
||||
"""两败轮次无赢家(1.3.8 排除条款): generation 不记、stall 归因恒 None。
|
||||
|
||||
对冲已实际发出(hedges 照登),但无赢家路 → 裸生成时间与沉默归因都
|
||||
无归属;两路各自的沉默归属各自 attempt 遥测行(那是 T3 的事)。
|
||||
"""
|
||||
transport = HedgeTransport(
|
||||
{
|
||||
"s1": [("fail_after", 0.1, lambda: TransientError("p", source_name="s1"))],
|
||||
"s2": [("fail_after", 0.12, lambda: TransientError("h", source_name="s2"))],
|
||||
}
|
||||
)
|
||||
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, max_attempts=1)
|
||||
ctx = _ctx()
|
||||
with pytest.raises(AllSourcesExhausted):
|
||||
await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
|
||||
stats = ctx.snapshot()
|
||||
assert stats.hedges == 1 and stats.hedge_won is False
|
||||
assert stats.generation_ms == 0
|
||||
assert stats.stall_ms is None and stats.stall_count is None
|
||||
|
||||
async def test_both_429_refund_no_budget(self):
|
||||
"""两路皆 429: 免预算且耗时退 stall 账——小 stall 窗下终局 stalled 而非耗尽。"""
|
||||
|
||||
|
||||
@@ -107,12 +107,15 @@ class _GenClockTransport:
|
||||
|
||||
generation_ms 的口径是"只计 transport 调用本身",故推进必须发生在被包
|
||||
transport 内部;退避耗时由用例自带的 sleep 闭包推进,与本包装无关。
|
||||
`stalls`(1.3.8 计划 §3.3): 逐次自控上报的 (stall_ms, stall_count),
|
||||
fake 自己报数、不经真实计时;None(默认)= 该次调用无沉默观测。
|
||||
"""
|
||||
|
||||
def __init__(self, script, advances, clock):
|
||||
def __init__(self, script, advances, clock, stalls=None):
|
||||
self._inner = FakeTransport(script)
|
||||
self._advances = list(advances)
|
||||
self._clock = clock
|
||||
self._stalls = list(stalls) if stalls is not None else None
|
||||
|
||||
@property
|
||||
def calls(self):
|
||||
@@ -131,6 +134,7 @@ class _GenClockTransport:
|
||||
content_event,
|
||||
):
|
||||
advance = self._advances.pop(0)
|
||||
stall = self._stalls.pop(0) if self._stalls is not None else None
|
||||
result = await self._inner.complete(
|
||||
messages=messages,
|
||||
source=source,
|
||||
@@ -141,6 +145,8 @@ class _GenClockTransport:
|
||||
first_token_event=first_token_event,
|
||||
content_event=content_event,
|
||||
)
|
||||
if stall is not None:
|
||||
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
|
||||
self._clock.advance(advance)
|
||||
return result
|
||||
|
||||
@@ -1017,6 +1023,46 @@ class TestGenerationMs:
|
||||
stats = ctx.snapshot()
|
||||
assert stats.hedges == 0 and stats.hedge_won is False
|
||||
|
||||
async def test_generation_ms_net_of_stall(self):
|
||||
"""净口径(1.3.8 设计 §4.2): generation_ms = 墙钟 − Σ(超阈值中段沉默)。
|
||||
|
||||
FakeClock 推进 5.0s 墙钟、fake 自报 stall 3200ms/2 段 → 净 1800ms;
|
||||
归因两值进快照,墙钟还原公式 generation_ms + stall_ms == 5000 成立。
|
||||
推进量取二进制可精确表示值(同类既有用例惯例,防 int 截断抖动)。
|
||||
"""
|
||||
clock = FakeClock()
|
||||
transport = _GenClockTransport([_ok()], [5.0], clock, stalls=[(3200.0, 2)])
|
||||
mw, *_ = _harness([_src("a")], [], clock=clock, transport=transport)
|
||||
ctx = self._ctx(clock)
|
||||
resp = await mw(dataclasses.replace(_REQ, call_context=ctx))
|
||||
assert resp.content == "ok"
|
||||
stats = ctx.snapshot()
|
||||
assert stats.generation_ms == 1800
|
||||
assert stats.stall_ms == 3200 and stats.stall_count == 2
|
||||
assert stats.generation_ms + stats.stall_ms == 5000 # 墙钟还原公式
|
||||
|
||||
async def test_generation_ms_clamps_negative_to_zero(self):
|
||||
"""沉默上报超过墙钟(异常数据/钟偏差防御) → 净口径 clamp 到 0(N4),归因原样保留。"""
|
||||
clock = FakeClock()
|
||||
transport = _GenClockTransport([_ok()], [5.0], clock, stalls=[(6000.0, 3)])
|
||||
mw, *_ = _harness([_src("a")], [], clock=clock, transport=transport)
|
||||
ctx = self._ctx(clock)
|
||||
await mw(dataclasses.replace(_REQ, call_context=ctx))
|
||||
stats = ctx.snapshot()
|
||||
assert stats.generation_ms == 0
|
||||
assert stats.stall_ms == 6000 and stats.stall_count == 3
|
||||
|
||||
async def test_non_stream_stall_none_no_deduction(self):
|
||||
"""stall None(非流式/增量 chunk<2,排除条款) → 净口径不退减(=墙钟),归因 None。"""
|
||||
clock = FakeClock()
|
||||
transport = _GenClockTransport([_ok()], [5.0], clock) # 不报 stall = 无观测
|
||||
mw, *_ = _harness([_src("a")], [], clock=clock, transport=transport)
|
||||
ctx = self._ctx(clock)
|
||||
await mw(dataclasses.replace(_REQ, call_context=ctx))
|
||||
stats = ctx.snapshot()
|
||||
assert stats.generation_ms == 5000
|
||||
assert stats.stall_ms is None and stats.stall_count is None
|
||||
|
||||
|
||||
class TestLogicalAttemptCounting:
|
||||
"""尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。
|
||||
|
||||
@@ -690,6 +690,36 @@ class TestCallStatsAndContext:
|
||||
stats = ctx.snapshot()
|
||||
assert (stats.hedges, stats.generation_ms, stats.hedge_won) == (1, 200, True)
|
||||
|
||||
def test_callstats_stall_fields_default_none(self):
|
||||
"""1.3.8 归因两字段默认 None: 仅旧参数构造不炸(1.3.7 及以前兼容形态)。"""
|
||||
from polygateway.types import CallStats
|
||||
|
||||
stats = CallStats(logical_call_id="lc-1", attempts=2, total_latency_ms=15)
|
||||
assert stats.stall_ms is None
|
||||
assert stats.stall_count is None
|
||||
explicit = CallStats(
|
||||
logical_call_id="lc-2",
|
||||
attempts=2,
|
||||
total_latency_ms=15,
|
||||
stall_ms=3200,
|
||||
stall_count=2,
|
||||
)
|
||||
assert (explicit.stall_ms, explicit.stall_count) == (3200, 2)
|
||||
|
||||
def test_callcontext_record_generation_with_stall_and_snapshot(self):
|
||||
"""stall 两值经 record_generation 进 snapshot;不传恒 None(embedding/ocr 形态)。"""
|
||||
from polygateway.types import _CallContext
|
||||
|
||||
ctx = _CallContext(now=_FakeMonotonic())
|
||||
ctx.record_generation(1800, accumulate=False, stall_ms=3200, stall_count=2)
|
||||
stats = ctx.snapshot()
|
||||
assert (stats.generation_ms, stats.stall_ms, stats.stall_count) == (1800, 3200, 2)
|
||||
# 不传(embedding/ocr 既有调用形态): 归因恒 None,None 是"无观测"不是 0
|
||||
plain = _CallContext(now=_FakeMonotonic())
|
||||
plain.record_generation(500, accumulate=False)
|
||||
plain_stats = plain.snapshot()
|
||||
assert plain_stats.stall_ms is None and plain_stats.stall_count is None
|
||||
|
||||
def test_context_counts_attempts_and_freezes_elapsed(self):
|
||||
"""快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。"""
|
||||
from polygateway.types import _CallContext
|
||||
|
||||
Reference in New Issue
Block a user