feat: expose bare generation time and hedge flags in CallStats
CallStats gains hedges/generation_ms/hedge_won (all defaulted, appended after total_latency_ms); _CallContext counts them via record_generation (overwrite for chat/OCR, accumulate for embedding batches) and register_hedge, and snapshot carries them out. All three _attempt implementations time only the transport call itself on the same injected clock as total_latency_ms; the chat sink is recorded by the orchestrator so hedge winner attribution stays with T3. Hedge counters stay 0/False until the T3 orchestration lands. Red-green evidence: tests/outputs/137/t2/ (10 new tests AttributeError red, then green; full unit+contracts 1621 passed).
This commit is contained in:
@@ -144,6 +144,66 @@ class TestChatEndToEnd:
|
||||
await client.chat([{"role": "user", "content": "hi"}], structured="json")
|
||||
|
||||
|
||||
class TestGenerationMsClient:
|
||||
"""裸生成时间的 client 级口径(1.3.7 批次 C2/F)。
|
||||
|
||||
`_ScriptedGenClockTransport` 在每次 transport 调用内推进注入钟,
|
||||
使"时间花在哪"可断言(同 test_backpressure.ClockAdvancingTransport 范式)。
|
||||
"""
|
||||
|
||||
_MSG = [{"role": "user", "content": "hi"}]
|
||||
|
||||
async def test_generation_ms_structured_last_round_wins(self):
|
||||
"""结构化重问覆盖而非累加: 首轮坏 JSON 推进 1s,重问轮推进 0.25s → 250。
|
||||
|
||||
推进量取二进制可精确表示值: int 截断下非精确值会因浮点误差少 1ms。
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Answer(BaseModel):
|
||||
answer: int
|
||||
|
||||
clock = _StatsClock()
|
||||
transport = _ScriptedGenClockTransport(
|
||||
[_ok("not json at all"), _ok('{"answer": 1}')], [1.0, 0.25], clock
|
||||
)
|
||||
async with _client(transport=transport, structured_max_retries=1, now=clock) as client:
|
||||
resp = await client.chat(self._MSG, structured=Answer)
|
||||
assert resp.content == '{"answer": 1}' and len(transport.calls) == 2
|
||||
assert resp.call_stats is not None
|
||||
assert resp.call_stats.attempts == 2
|
||||
assert resp.call_stats.generation_ms == 250
|
||||
|
||||
async def test_cache_hit_generation_ms_zero(self):
|
||||
"""缓存命中不产生 transport 调用: generation_ms 恒 0(0 是实测,非"未知")。"""
|
||||
client = _client(cache=InMemoryCache(), cache_namespace="proj", cache_ttl_s=3600)
|
||||
async with client:
|
||||
first = await client.chat(self._MSG)
|
||||
second = await client.chat(self._MSG)
|
||||
assert first.cache_hit is False and second.cache_hit is True
|
||||
assert second.call_stats is not None
|
||||
assert second.call_stats.generation_ms == 0
|
||||
assert second.call_stats.attempts == 0
|
||||
|
||||
|
||||
class _ScriptedGenClockTransport:
|
||||
"""脚本化假 transport: 每次成功调用在返回前按脚本推进注入钟(批次 C2/F)。"""
|
||||
|
||||
def __init__(self, results, advances, clock):
|
||||
self._results = list(results)
|
||||
self._advances = list(advances)
|
||||
self._clock = clock
|
||||
self.calls = []
|
||||
|
||||
async def complete(
|
||||
self, *, messages, source, stream, overlay, call_id, reasoning_effort, first_token_event
|
||||
):
|
||||
self.calls.append(call_id)
|
||||
result = self._results.pop(0)
|
||||
self._clock.advance(self._advances.pop(0))
|
||||
return result
|
||||
|
||||
|
||||
class TestSamplingOverlay:
|
||||
"""调用级采样参数入口(issue #4 Task 3)。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user