Skip to content

Commit 9782dc7

Browse files
committed
Add latency metrics to the eval command
Time each transcription's wall-clock latency and surface it: a per-row LATENCY column / JSON field, plus pooled p50 and p90 percentiles in the summary. Latency is recorded for every row that ran a request (including failed ones), independent of WER scoring. https://claude.ai/code/session_01SWJQ3VVvR2YLyPDbtrw6tU
1 parent d5b7220 commit 9782dc7

3 files changed

Lines changed: 175 additions & 25 deletions

File tree

‎aai_cli/commands/evaluate/_exec.py‎

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
from __future__ import annotations
1414

15+
import math
16+
import time
1517
from concurrent.futures import ThreadPoolExecutor, as_completed
1618
from dataclasses import dataclass
1719
from enum import StrEnum
@@ -54,56 +56,97 @@ def _pct(value: object) -> str:
5456
return f"{jsonshape.as_float(value):.2%}"
5557

5658

59+
def _secs(value: object) -> str:
60+
"""A latency in seconds, formatted for display."""
61+
return f"{jsonshape.as_float(value):.2f}s"
62+
63+
64+
def _percentile(values: list[float], q: float) -> float:
65+
"""The q-quantile (q in [0, 1]) of ``values``, linearly interpolated between
66+
the two closest ranks (numpy's default method). ``values`` must be non-empty."""
67+
ordered = sorted(values)
68+
pos = q * (len(ordered) - 1)
69+
low = math.floor(pos)
70+
high = math.ceil(pos)
71+
if low == high:
72+
return ordered[low]
73+
return ordered[low] + (ordered[high] - ordered[low]) * (pos - low)
74+
75+
5776
@dataclass(frozen=True)
5877
class _ItemResult:
59-
"""One scored row: the emitted dict plus the score kept for pooling."""
78+
"""One scored row: the emitted dict plus the score and latency kept for pooling."""
6079

6180
row: dict[str, object]
6281
words: wer.Score | None
82+
latency: float
6383

6484

65-
def _failed_result(item: eval_data.EvalItem, err: CLIError) -> _ItemResult:
66-
"""A row whose transcription failed: the error rides along, no scores pooled."""
67-
return _ItemResult(row={"item": item.item_id, "error": err.message}, words=None)
85+
def _failed_result(item: eval_data.EvalItem, err: CLIError, latency: float) -> _ItemResult:
86+
"""A row whose transcription failed: the error and latency ride along, no scores pooled."""
87+
return _ItemResult(
88+
row={"item": item.item_id, "error": err.message, "latency": latency},
89+
words=None,
90+
latency=latency,
91+
)
6892

6993

70-
def _score_item(item: eval_data.EvalItem, transcript: aai.Transcript) -> _ItemResult:
94+
def _score_item(
95+
item: eval_data.EvalItem, transcript: aai.Transcript, latency: float
96+
) -> _ItemResult:
7197
words = wer.score(item.reference, str(transcript.text or ""))
7298
row: dict[str, object] = {
7399
"item": item.item_id,
74100
"words": words.words,
75101
"errors": words.errors,
76102
"wer": words.wer,
103+
"latency": latency,
77104
}
78-
return _ItemResult(row=row, words=words)
105+
return _ItemResult(row=row, words=words, latency=latency)
79106

80107

81108
def _pooled_metrics(results: list[_ItemResult]) -> dict[str, object]:
82-
"""The summary scores pooled over the scored rows (failed rows carry none)."""
109+
"""The summary metrics: WER pooled over the scored rows (failed rows carry none),
110+
and the latency distribution over every row that ran a transcription."""
83111
metrics: dict[str, object] = {}
84112
word_scores = [result.words for result in results if result.words is not None]
85113
if word_scores:
86114
total = wer.pooled(word_scores)
87115
metrics.update({"words": total.words, "errors": total.errors, "wer": total.wer})
116+
latencies = [result.latency for result in results]
117+
if latencies:
118+
metrics["latency_p50"] = _percentile(latencies, 0.5)
119+
metrics["latency_p90"] = _percentile(latencies, 0.9)
88120
return metrics
89121

90122

123+
@dataclass(frozen=True)
124+
class _Timed:
125+
"""One transcription's outcome paired with its wall-clock latency in seconds."""
126+
127+
outcome: aai.Transcript | CLIError
128+
latency: float
129+
130+
91131
def _transcribe_one(
92132
api_key: str, item: eval_data.EvalItem, config: aai.TranscriptionConfig
93-
) -> aai.Transcript | CLIError:
94-
"""One item's outcome: its transcript, or the CLIError it failed with.
133+
) -> _Timed:
134+
"""One item's timed outcome: its transcript (or the CLIError it failed with) and
135+
the wall-clock latency of the request.
95136
96137
A bad item must not discard the other (paid) items, so per-item failures
97138
are recorded rather than raised — except ``NotAuthenticated`` (one rejected
98139
key fails every row identically) and non-CLIError bugs, which propagate and
99140
abort the run.
100141
"""
142+
start = time.perf_counter()
101143
try:
102-
return client.transcribe(api_key, item.audio, config=config)
144+
outcome: aai.Transcript | CLIError = client.transcribe(api_key, item.audio, config=config)
103145
except NotAuthenticated:
104146
raise
105147
except CLIError as err:
106-
return err
148+
outcome = err
149+
return _Timed(outcome=outcome, latency=time.perf_counter() - start)
107150

108151

109152
def _concurrent_transcripts(
@@ -112,7 +155,7 @@ def _concurrent_transcripts(
112155
*,
113156
transcription_config: aai.TranscriptionConfig,
114157
concurrency: int,
115-
) -> list[aai.Transcript | CLIError]:
158+
) -> list[_Timed]:
116159
with ThreadPoolExecutor(max_workers=concurrency) as pool:
117160
futures = [
118161
pool.submit(_transcribe_one, api_key, item, transcription_config) for item in items
@@ -134,15 +177,15 @@ def _transcripts(
134177
concurrency: int,
135178
json_mode: bool,
136179
quiet: bool,
137-
) -> list[aai.Transcript | CLIError]:
138-
"""Each item's transcript — or the CLIError it failed with — in dataset order.
180+
) -> list[_Timed]:
181+
"""Each item's timed transcript — or the CLIError it failed with — in dataset order.
139182
140183
Sequential by default, with a per-item spinner; ``--concurrency`` fans the
141184
API calls out across a thread pool (see ``_transcribe_one`` for which
142185
failures are per-item outcomes and which abort the run).
143186
"""
144187
if concurrency == 1:
145-
outcomes: list[aai.Transcript | CLIError] = []
188+
outcomes: list[_Timed] = []
146189
for index, item in enumerate(items, start=1):
147190
with output.status(
148191
f"[{index}/{len(items)}] Transcribing {item.item_id}…",
@@ -185,6 +228,11 @@ def _summary(payload: dict[str, object]) -> str:
185228
parts.append(
186229
f"WER {_pct(payload.get('wer'))} ({errors} {noun} / {payload.get('words')} words)"
187230
)
231+
if "latency_p50" in payload:
232+
parts.append(
233+
f"latency p50 {_secs(payload.get('latency_p50'))}"
234+
f" · p90 {_secs(payload.get('latency_p90'))}"
235+
)
188236
return output.heading(" ".join(parts))
189237

190238

@@ -197,19 +245,27 @@ def _pct_cell(row: dict[str, object], key: str) -> str:
197245
return _pct(row[key]) if key in row else ""
198246

199247

248+
def _secs_cell(row: dict[str, object], key: str) -> str:
249+
return _secs(row[key]) if key in row else ""
250+
251+
200252
def _render(payload: dict[str, object]) -> RenderableType:
201253
has_wer = "wer" in payload
202254
has_failed = "failed" in payload
255+
has_latency = "latency_p50" in payload
203256
columns = [
204257
"ITEM",
205258
*(["WORDS", "ERRORS", "WER"] if has_wer else []),
259+
*(["LATENCY"] if has_latency else []),
206260
*(["ERROR"] if has_failed else []),
207261
]
208262
table = output.data_table(*columns)
209263
for row in jsonshape.mapping_list(payload.get("rows")):
210264
cells = [str(row.get("item"))]
211265
if has_wer:
212266
cells += [_cell(row, "words"), _cell(row, "errors"), _pct_cell(row, "wer")]
267+
if has_latency:
268+
cells.append(_secs_cell(row, "latency"))
213269
if has_failed:
214270
cells.append(_cell(row, "error"))
215271
table.add_row(*cells)
@@ -245,10 +301,10 @@ def run_evaluate(opts: EvalOptions, state: AppState, *, json_mode: bool) -> None
245301
quiet=state.quiet,
246302
)
247303
results = [
248-
_failed_result(item, outcome)
249-
if isinstance(outcome, CLIError)
250-
else _score_item(item, outcome)
251-
for item, outcome in zip(
304+
_failed_result(item, timed.outcome, timed.latency)
305+
if isinstance(timed.outcome, CLIError)
306+
else _score_item(item, timed.outcome, timed.latency)
307+
for item, timed in zip(
252308
data.items,
253309
outcomes,
254310
strict=True, # pragma: no mutate (defensive invariant; _transcripts returns one outcome per item)

‎tests/test_eval_command.py‎

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ def _payload_of(result):
5555
)
5656

5757

58+
def _without_latency(row):
59+
return {key: value for key, value in row.items() if key != "latency"}
60+
61+
62+
def _fake_perf_counter(mocker, ticks):
63+
"""Pin the eval timer so each row's latency is a known constant.
64+
65+
The sequential path reads perf_counter as start/end per item, so ``ticks``
66+
is consumed two at a time: (start, end) for item 1, then item 2, …
67+
"""
68+
return mocker.patch(
69+
"aai_cli.commands.evaluate._exec.time.perf_counter",
70+
autospec=True,
71+
side_effect=list(ticks),
72+
)
73+
74+
5875
def test_wer_table_with_per_file_and_pooled_scores(tmp_path, mocker):
5976
_auth()
6077
_write_wer_manifest(tmp_path)
@@ -94,8 +111,13 @@ def test_json_payload_shape(tmp_path, mocker):
94111
assert payload["words"] == 4
95112
assert payload["errors"] == 1
96113
assert payload["wer"] == 0.25
97-
assert payload["rows"][0] == {"item": "a.wav", "words": 2, "errors": 0, "wer": 0.0}
98-
assert payload["rows"][1] == {"item": "b.wav", "words": 2, "errors": 1, "wer": 0.5}
114+
assert _without_latency(payload["rows"][0]) == {
115+
"item": "a.wav", "words": 2, "errors": 0, "wer": 0.0
116+
} # fmt: skip
117+
assert _without_latency(payload["rows"][1]) == {
118+
"item": "b.wav", "words": 2, "errors": 1, "wer": 0.5
119+
} # fmt: skip
120+
assert all(isinstance(row["latency"], float) for row in payload["rows"])
99121
assert "failed" not in payload # only present when a row failed
100122

101123

@@ -144,11 +166,20 @@ def _assign(obj, attribute, value):
144166
def test_item_results_are_immutable():
145167
from aai_cli.commands.evaluate._exec import _ItemResult
146168

147-
result = _ItemResult(row={}, words=None)
169+
result = _ItemResult(row={}, words=None, latency=0.0)
148170
with pytest.raises(dataclasses.FrozenInstanceError):
149171
_assign(result, "words", None)
150172

151173

174+
def test_timed_outcome_is_immutable():
175+
from aai_cli.commands.evaluate._exec import _Timed
176+
from aai_cli.core.errors import APIError
177+
178+
timed = _Timed(outcome=APIError("boom"), latency=1.0)
179+
with pytest.raises(dataclasses.FrozenInstanceError):
180+
_assign(timed, "latency", 2.0)
181+
182+
152183
def test_missing_transcript_text_scores_as_all_deletions(tmp_path, mocker):
153184
_auth()
154185
(tmp_path / "a.wav").write_bytes(b"fake-audio")
@@ -247,3 +278,63 @@ def test_unauthenticated_exits_with_auth_code(tmp_path):
247278
(tmp_path / "m.csv").write_text("audio,text\na.wav,hello\n", encoding="utf-8")
248279
result = runner.invoke(app, ["eval", "m.csv"])
249280
assert result.exit_code == 4
281+
282+
283+
def test_per_row_latency_and_percentiles_in_json(tmp_path, mocker):
284+
_auth()
285+
_write_wer_manifest(tmp_path)
286+
_mock_transcribe(mocker, [_transcript("hello there"), _transcript("goodbye now")])
287+
# (start, end) per item: row a takes 1.5s, row b takes 0.5s (starts nonzero so a
288+
# mutated `end + start` would diverge from `end - start`).
289+
_fake_perf_counter(mocker, [10.0, 11.5, 20.0, 20.5])
290+
payload = _payload_of(runner.invoke(app, ["eval", "manifest.csv", "--json"]))
291+
assert payload["rows"][0]["latency"] == 1.5
292+
assert payload["rows"][1]["latency"] == 0.5
293+
# Pooled over [0.5, 1.5]: p50 = 1.0, p90 = 0.5 + 1.0*0.9 = 1.4.
294+
assert payload["latency_p50"] == pytest.approx(1.0)
295+
assert payload["latency_p90"] == pytest.approx(1.4)
296+
297+
298+
def test_human_output_shows_latency_column_and_summary(tmp_path, mocker):
299+
_auth()
300+
_write_wer_manifest(tmp_path)
301+
_mock_transcribe(mocker, [_transcript("hello there"), _transcript("goodbye now")])
302+
_fake_perf_counter(mocker, [10.0, 11.5, 20.0, 20.5])
303+
result = runner.invoke(app, ["eval", "manifest.csv"])
304+
assert result.exit_code == 0
305+
assert "LATENCY" in result.output # the per-row column header
306+
assert "1.50s" in result.output # row a's latency, seconds-formatted
307+
assert "0.50s" in result.output # row b's latency
308+
assert "latency p50 1.00s · p90 1.40s" in result.output # the pooled summary
309+
310+
311+
def test_failed_row_still_carries_latency(tmp_path, mocker):
312+
from aai_cli.core.errors import APIError
313+
314+
_auth()
315+
_write_wer_manifest(tmp_path)
316+
_mock_transcribe(mocker, [_transcript("hello there"), APIError("rate limited")])
317+
_fake_perf_counter(mocker, [10.0, 11.0, 20.0, 20.25])
318+
payload = _payload_of(runner.invoke(app, ["eval", "manifest.csv", "--json"]))
319+
failed_row = next(row for row in payload["rows"] if "error" in row)
320+
assert failed_row["latency"] == 0.25 # the timer wraps the failing call too
321+
# The latency distribution pools the failed row alongside the scored one.
322+
assert payload["latency_p50"] == pytest.approx(0.625)
323+
324+
325+
@pytest.mark.parametrize(
326+
("values", "q", "expected"),
327+
[
328+
([5.0], 0.5, 5.0), # single value: every quantile is that value
329+
([1.0, 2.0, 3.0], 0.5, 2.0), # odd count, exact rank -> the median element
330+
([1.0, 2.0, 3.0, 4.0], 0.5, 2.5), # even count -> interpolated midpoint
331+
([0.0, 10.0], 0.9, 9.0), # interpolation between the two ranks
332+
([1.0, 2.0, 3.0, 4.0], 0.0, 1.0), # q=0 -> minimum
333+
([1.0, 2.0, 3.0, 4.0], 1.0, 4.0), # q=1 -> maximum
334+
],
335+
)
336+
def test_percentile_interpolates_between_ranks(values, q, expected):
337+
from aai_cli.commands.evaluate._exec import _percentile
338+
339+
# Pass values out of order to prove _percentile sorts before interpolating.
340+
assert _percentile(list(reversed(values)), q) == pytest.approx(expected)

‎tests/test_eval_failures.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
_mock_transcribe,
2222
_payload_of,
2323
_transcript,
24+
_without_latency,
2425
_write_wer_manifest,
2526
)
2627

@@ -172,9 +173,11 @@ def test_failed_row_keeps_completed_rows_and_summary_pools_scored_only(tmp_path,
172173
payload = _payload_of(result)
173174
assert payload["items"] == 3
174175
assert payload["failed"] == 1
175-
assert payload["rows"][0] == {"item": "a.wav", "words": 2, "errors": 0, "wer": 0.0}
176-
assert payload["rows"][1] == {"item": "b.wav", "error": "rate limited"}
177-
assert payload["rows"][2] == {"item": "c.wav", "words": 2, "errors": 0, "wer": 0.0}
176+
rows = [_without_latency(row) for row in payload["rows"]]
177+
assert rows[0] == {"item": "a.wav", "words": 2, "errors": 0, "wer": 0.0}
178+
assert rows[1] == {"item": "b.wav", "error": "rate limited"}
179+
assert rows[2] == {"item": "c.wav", "words": 2, "errors": 0, "wer": 0.0}
180+
assert all(isinstance(row["latency"], float) for row in payload["rows"])
178181
# Pooled over the two scored rows only — the failed row contributes no words.
179182
assert payload["words"] == 4
180183
assert payload["errors"] == 0

0 commit comments

Comments
 (0)