1717import io .opentelemetry .api .trace .SpanKind ;
1818import io .opentelemetry .api .trace .StatusCode ;
1919import io .opentelemetry .api .trace .Tracer ;
20+ import io .opentelemetry .context .Context ;
2021import java .util .*;
22+ import java .util .concurrent .Executor ;
23+ import java .util .concurrent .ExecutorService ;
24+ import java .util .concurrent .Executors ;
25+ import java .util .concurrent .Semaphore ;
26+ import java .util .concurrent .atomic .AtomicInteger ;
2127import java .util .function .Function ;
2228import javax .annotation .Nonnull ;
2329import javax .annotation .Nullable ;
2632/**
2733 * An evaluation framework for testing AI models.
2834 *
35+ * <p><b>Cases are evaluated concurrently.</b> By default up to {@value #DEFAULT_MAX_CONCURRENCY}
36+ * cases run at once, so the {@link Task}, {@link Scorer}s and {@link Classifier}s supplied to an
37+ * eval must be safe to invoke from multiple threads. Use {@link Builder#maxConcurrency(int)} to
38+ * change the bound, or {@code maxConcurrency(1)} to evaluate cases one at a time. Use {@link
39+ * Builder#executor(Executor)} to supply the threads the cases run on.
40+ *
2941 * @param <INPUT> The type of input data for the evaluation
3042 * @param <OUTPUT> The type of output produced by the task
3143 */
3244@ Slf4j
3345public final class Eval <INPUT , OUTPUT > {
46+ /** Default number of eval cases evaluated concurrently. */
47+ public static final int DEFAULT_MAX_CONCURRENCY = 10 ;
48+
3449 private static final AttributeKey <String > PARENT =
3550 AttributeKey .stringKey (BraintrustTracing .PARENT_KEY );
3651 private final @ Nonnull String experimentName ;
@@ -47,6 +62,8 @@ public final class Eval<INPUT, OUTPUT> {
4762 private final @ Nonnull Map <String , Object > metadata ;
4863 private final @ Nonnull Parameters parameters ;
4964 private final boolean ensureNew ;
65+ private final int maxConcurrency ;
66+ private final @ Nullable Executor executor ;
5067
5168 private Eval (Builder <INPUT , OUTPUT > builder ) {
5269 this .experimentName = builder .experimentName ;
@@ -65,11 +82,39 @@ private Eval(Builder<INPUT, OUTPUT> builder) {
6582 this .metadata = Map .copyOf (builder .metadata );
6683 this .parameters = builder .buildParameters ();
6784 this .ensureNew = builder .ensureNew ;
85+ this .maxConcurrency = builder .maxConcurrency ;
86+ this .executor = builder .executor ;
6887 }
6988
70- /** Runs the evaluation and returns results. */
89+ /**
90+ * Runs the evaluation to completion and returns the results.
91+ *
92+ * <p>Cases are evaluated concurrently; see {@link Builder#maxConcurrency(int)}.
93+ */
7194 public EvalResult run () {
72- try (var cursor = dataset .openCursor ()) {
95+ var result = start ();
96+ result .awaitCompletion ();
97+ return result ;
98+ }
99+
100+ /**
101+ * Creates the experiment and begins evaluating cases in the background, returning as soon as
102+ * the experiment exists.
103+ *
104+ * <p>The returned {@link EvalResult} carries the experiment id, name and url immediately — so
105+ * callers can surface the link right away — while its cases are still being evaluated. Use
106+ * {@link EvalResult#isDone()} and {@link EvalResult#awaitCompletion()} to observe the run.
107+ *
108+ * <p>Errors raised while creating the experiment are thrown from this method. Errors raised
109+ * once the run is underway are reported through {@link EvalResult#awaitCompletion()}.
110+ */
111+ public EvalResult start () {
112+ var state = new EvalRunState ();
113+ var cursor = dataset .openCursor ();
114+ final EvalResult result ;
115+ final ExecutorService ownedExecutor ;
116+ final Executor caseExecutor ;
117+ try {
73118 Optional <String > datasetVersion = Optional .empty ();
74119 Optional <String > datasetId = Optional .empty ();
75120 if (dataset instanceof DatasetBrainstoreImpl <INPUT , OUTPUT >) {
@@ -94,8 +139,6 @@ public EvalResult run() {
94139
95140 var experiment = new ExperimentsApi (client ).postExperiment (createExperiment );
96141
97- cursor .forEach (datasetCase -> evalOne (experiment .getId ().toString (), datasetCase ));
98-
99142 // Use the experiment's actual name from the response: with ensure_new the backend may
100143 // dedupe a conflicting name (e.g. "foo" -> "foo-2f8ca776"), and the URL must point at
101144 // the real, created experiment.
@@ -109,11 +152,143 @@ public EvalResult run() {
109152 project .getName ())
110153 .toASCIIString (),
111154 resolvedName );
112- return new EvalResult (experiment .getId ().toString (), resolvedName , experimentUrl );
155+ result =
156+ new EvalResult (
157+ experiment .getId ().toString (), resolvedName , experimentUrl , state );
158+
159+ if (executor != null ) {
160+ ownedExecutor = null ;
161+ caseExecutor = executor ;
162+ } else {
163+ ownedExecutor = createDefaultExecutor ();
164+ caseExecutor = ownedExecutor ;
165+ }
166+ } catch (Throwable t ) {
167+ cursor .close ();
168+ throw t ;
169+ }
170+
171+ // Each case re-establishes the context that was current when the run was started. The eval
172+ // span itself is created with setNoParent(), so this carries baggage rather than parentage.
173+ var callerContext = Context .current ();
174+ var experimentId = Objects .requireNonNull (result .getExperimentId ());
175+ var coordinator =
176+ new Thread (
177+ () ->
178+ evalAllCases (
179+ cursor ,
180+ caseExecutor ,
181+ ownedExecutor ,
182+ experimentId ,
183+ callerContext ,
184+ state ),
185+ "braintrust-eval-coordinator" );
186+ coordinator .setDaemon (true );
187+ coordinator .start ();
188+ return result ;
189+ }
190+
191+ /**
192+ * Drains the dataset cursor on this (coordinator) thread, submitting each case to {@code
193+ * caseExecutor}. A semaphore bounds the number of cases in flight so the whole dataset is never
194+ * materialized in memory, and so the executor's queue can't grow without limit.
195+ *
196+ * <p>Runs on the coordinator thread, never on a worker: waiting for cases to finish from inside
197+ * the pool that runs them would deadlock once the pool is saturated.
198+ */
199+ private void evalAllCases (
200+ Dataset .Cursor <DatasetCase <INPUT , OUTPUT >> cursor ,
201+ Executor caseExecutor ,
202+ @ Nullable ExecutorService ownedExecutor ,
203+ String experimentId ,
204+ Context callerContext ,
205+ EvalRunState state ) {
206+ var inFlight = new Semaphore (maxConcurrency );
207+ Throwable fatal = null ;
208+ try (cursor ) {
209+ for (var next = cursor .next (); next .isPresent (); next = cursor .next ()) {
210+ var datasetCase = next .get ();
211+ inFlight .acquire ();
212+ try {
213+ caseExecutor .execute (
214+ () -> {
215+ try (var unused = callerContext .makeCurrent ()) {
216+ if (evalOne (experimentId , datasetCase )) {
217+ state .caseSucceeded ();
218+ } else {
219+ state .caseFailed ();
220+ }
221+ } catch (Throwable t ) {
222+ // Contain the failure to this case: one bad case must not
223+ // abort the rest of the run.
224+ state .caseFailed ();
225+ log .warn (
226+ "Eval case failed for input: {}" ,
227+ datasetCase .input (),
228+ t );
229+ } finally {
230+ inFlight .release ();
231+ }
232+ });
233+ } catch (RuntimeException e ) {
234+ // e.g. RejectedExecutionException from a caller-supplied executor. Release the
235+ // permit we took so the drain below can't hang.
236+ inFlight .release ();
237+ throw e ;
238+ }
239+ }
240+ } catch (InterruptedException e ) {
241+ Thread .currentThread ().interrupt ();
242+ fatal = e ;
243+ } catch (Throwable t ) {
244+ fatal = t ;
245+ } finally {
246+ // Wait for every in-flight case, including when the drain above aborted.
247+ try {
248+ inFlight .acquire (maxConcurrency );
249+ } catch (InterruptedException e ) {
250+ Thread .currentThread ().interrupt ();
251+ if (fatal == null ) {
252+ fatal = e ;
253+ }
254+ }
255+ if (ownedExecutor != null ) {
256+ ownedExecutor .shutdown ();
257+ }
258+ // TODO: force-flush spans here so that completion means "results are in Braintrust".
259+ // Needs a blocking flush on the eval's OpenTelemetry instance —
260+ // BraintrustTracing.attemptForceFlush is package-private and non-blocking.
261+ state .complete (fatal );
113262 }
114263 }
115264
116- private void evalOne (String experimentId , DatasetCase <INPUT , OUTPUT > datasetCase ) {
265+ /**
266+ * The executor used when the caller did not supply one: a fixed pool of daemon threads sized to
267+ * {@link #maxConcurrency}, owned by this run and shut down when it finishes.
268+ *
269+ * <p>On Java 21+, pass {@code Executors.newVirtualThreadPerTaskExecutor()} to {@link
270+ * Builder#executor(Executor)} if you want to run many cases concurrently.
271+ */
272+ private ExecutorService createDefaultExecutor () {
273+ var counter = new AtomicInteger ();
274+ return Executors .newFixedThreadPool (
275+ maxConcurrency ,
276+ r -> {
277+ var thread =
278+ new Thread (r , "braintrust-eval-worker-" + counter .incrementAndGet ());
279+ thread .setDaemon (true );
280+ return thread ;
281+ });
282+ }
283+
284+ /**
285+ * Evaluates a single case. Runs entirely on one thread so that the OpenTelemetry scopes it
286+ * opens stay thread-confined.
287+ *
288+ * @return false if the task threw (scorers fell back to {@link Scorer#scoreForTaskException}),
289+ * true otherwise
290+ */
291+ private boolean evalOne (String experimentId , DatasetCase <INPUT , OUTPUT > datasetCase ) {
117292 var rootSpan =
118293 tracer .spanBuilder ("eval" ) // TODO: allow names for eval cases
119294 .setNoParent () // each eval case is its own trace
@@ -164,7 +339,7 @@ private void evalOne(String experimentId, DatasetCase<INPUT, OUTPUT> datasetCase
164339 for (var scorer : scorers ) {
165340 runScoreForTaskException (experimentId , rootSpan , scorer , e , datasetCase );
166341 }
167- return ;
342+ return false ;
168343 }
169344 taskSpan .end ();
170345 }
@@ -221,6 +396,7 @@ private void evalOne(String experimentId, DatasetCase<INPUT, OUTPUT> datasetCase
221396 } finally {
222397 rootSpan .end ();
223398 }
399+ return true ;
224400 }
225401
226402 /**
@@ -406,6 +582,8 @@ public static final class Builder<INPUT, OUTPUT> {
406582 private @ Nonnull List <String > tags = List .of ();
407583 private @ Nonnull Map <String , Object > metadata = Map .of ();
408584 private boolean ensureNew = false ;
585+ private int maxConcurrency = DEFAULT_MAX_CONCURRENCY ;
586+ private @ Nullable Executor executor ;
409587
410588 public Eval <INPUT , OUTPUT > build () {
411589 if (config == null ) {
@@ -453,6 +631,40 @@ public Builder<INPUT, OUTPUT> apiClient(BraintrustApiClient apiClient) {
453631 return apiClient (apiClient .openApiClient ());
454632 }
455633
634+ /**
635+ * Sets the maximum number of eval cases evaluated concurrently. Defaults to {@value
636+ * Eval#DEFAULT_MAX_CONCURRENCY}.
637+ *
638+ * <p>Because cases run concurrently, the {@link Task}, {@link Scorer}s and {@link
639+ * Classifier}s must be safe to invoke from multiple threads. Pass {@code 1} to evaluate
640+ * cases one at a time.
641+ *
642+ * <p>This bounds how many cases are in flight at once. If you also supply an {@link
643+ * #executor(Executor)} with fewer threads than this, that executor is the real limit and
644+ * the remaining cases queue.
645+ */
646+ public Builder <INPUT , OUTPUT > maxConcurrency (int maxConcurrency ) {
647+ if (maxConcurrency < 1 ) {
648+ throw new IllegalArgumentException (
649+ "maxConcurrency must be at least 1, got " + maxConcurrency );
650+ }
651+ this .maxConcurrency = maxConcurrency ;
652+ return this ;
653+ }
654+
655+ /**
656+ * Sets the executor that eval cases run on. Defaults to a fixed pool of daemon threads
657+ * sized to {@link #maxConcurrency(int)}, created and shut down by the eval.
658+ *
659+ * <p>An executor supplied here is never shut down by the SDK — the caller owns its
660+ * lifecycle. On Java 21+, pass {@code Executors.newVirtualThreadPerTaskExecutor()} if you
661+ * want to run many cases concurrently.
662+ */
663+ public Builder <INPUT , OUTPUT > executor (@ Nonnull Executor executor ) {
664+ this .executor = Objects .requireNonNull (executor );
665+ return this ;
666+ }
667+
456668 public Builder <INPUT , OUTPUT > tracer (Tracer tracer ) {
457669 this .tracer = tracer ;
458670 return this ;
0 commit comments