1212
1313from __future__ import annotations
1414
15+ import math
16+ import time
1517from concurrent .futures import ThreadPoolExecutor , as_completed
1618from dataclasses import dataclass
1719from 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 )
5877class _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
81108def _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+
91131def _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
109152def _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+
200252def _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)
0 commit comments