22"""Dataset run orchestration: per item, K single-turn runs, route by test_kind, aggregate pass@K."""
33
44import time
5+ import traceback
6+ from concurrent .futures import ThreadPoolExecutor , as_completed
57from dataclasses import dataclass , field
68from functools import partial
79from typing import Callable , Protocol
@@ -52,6 +54,7 @@ class EvalReport:
5254 provider_type : str = ""
5355 workspace_id : str = ""
5456 items : list [ItemReport ] = field (default_factory = list )
57+ wall_clock_s : float = 0.0 # actual elapsed time; differs from latency_s under concurrency
5558
5659 @property
5760 def total (self ) -> int :
@@ -153,6 +156,7 @@ def run_items(
153156 on_run_done : Callable [[int , int , int , int , bool , float ], None ] | None = None ,
154157 on_item_done : Callable [[int , int , ItemReport ], None ] | None = None ,
155158 on_langfuse_item_done : Callable [[int , int , ItemReport ], None ] | None = None ,
159+ concurrency : int = 1 ,
156160) -> EvalReport :
157161 """Run every item K times, routing by test_kind, and aggregate pass@K.
158162
@@ -162,19 +166,47 @@ def run_items(
162166 - on_run_done(index, total, run_index, runs, passed, latency) after each individual run
163167 - on_item_done(index, total, report) after an item is fully evaluated
164168 - on_langfuse_item_done(index, total, report) after non-skipped, non-errored items only
169+
170+ concurrency > 1 dispatches items to a ThreadPoolExecutor so multiple
171+ questions are sent to the agent simultaneously. Each item still runs
172+ --runs times sequentially (pass@K). Results are collected in input order.
165173 """
174+ concurrency = max (1 , concurrency )
166175 report = EvalReport (
167176 model = model , provider_name = provider_name , provider_type = provider_type , workspace_id = workspace_id
168177 )
169178 total = len (items )
170- for index , item in enumerate (items , start = 1 ):
171- if on_item_start is not None :
172- on_item_start (index , total , item )
179+
180+ def _process_item (index : int , item : DatasetItem ) -> ItemReport :
181+ try :
182+ if on_item_start is not None :
183+ on_item_start (index , total , item )
184+ except Exception : # non-fatal — callback must not abort a parallel run
185+ traceback .print_exc ()
173186 run_cb = partial (_forward_run_event , on_run_done , index , total ) if on_run_done is not None else None
174187 item_report = _run_one_item (item , backend , runs , on_run_done = run_cb )
175- report .items .append (item_report )
176- if on_item_done is not None :
177- on_item_done (index , total , item_report )
178- if on_langfuse_item_done is not None and not item_report .skipped and item_report .error is None :
179- on_langfuse_item_done (index , total , item_report )
188+ try :
189+ if on_item_done is not None :
190+ on_item_done (index , total , item_report )
191+ if on_langfuse_item_done is not None and not item_report .skipped and item_report .error is None :
192+ on_langfuse_item_done (index , total , item_report )
193+ except Exception : # non-fatal — log but don't abort
194+ traceback .print_exc ()
195+ return item_report
196+
197+ _t0 = time .perf_counter ()
198+ if concurrency <= 1 :
199+ for index , item in enumerate (items , start = 1 ):
200+ report .items .append (_process_item (index , item ))
201+ else :
202+ # Dispatch concurrently; collect in original order.
203+ with ThreadPoolExecutor (max_workers = concurrency ) as pool :
204+ futures = {pool .submit (_process_item , index , item ): index for index , item in enumerate (items , start = 1 )}
205+ results : dict [int , ItemReport ] = {}
206+ for future in as_completed (futures ):
207+ idx = futures [future ]
208+ results [idx ] = future .result ()
209+ for index in range (1 , total + 1 ):
210+ report .items .append (results [index ])
211+ report .wall_clock_s = time .perf_counter () - _t0
180212 return report
0 commit comments