Repository navigation
fix(eval): make error score handler calls reliable for every failed row - #909
Conversation
unhandled_scores was shared across every row of an eval, so a row whose task raised used whatever the last finished row left behind. The error score handler then ran with a partial scorer list or not at all. Track it per row, matching the JavaScript SDK.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Raphael Fakhri (@RaphaelFakhri), I found an adjacent entry-point issue while checking the error-score handler: import asyncio
from braintrust import Eval, EvalAsync
from braintrust.framework import default_error_score_handler
def task(input):
raise ValueError("task failed")
def quality(output):
return 1.0
kwargs = dict(
name="handler-repro",
data=[{"input": 1}],
task=task,
scores=[quality],
error_score_handler=default_error_score_handler,
no_send_logs=True,
enable_cache=False,
)
print(Eval(**kwargs).results[0].scores) # {'quality': 0}
print(asyncio.run(EvalAsync(**kwargs)).results[0].scores) # {}A possible follow-up is forwarding AI-assisted investigation; reproduced locally without backend calls. |
|
Good catch. That's a separate omission in a different entry point, so I'd keep this PR scoped to the per-row state. A one-line follow-up that forwards |
The error score handler was called while building the EvalResult, outside the row's try/except, so an exception from a user handler escaped run_evaluator_task and cancelled the whole eval. Catch it, report it, and keep the row without error scores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each row started from the evaluator-wide scorer_names list itself, and that same list was handed to the user's error score handler. A handler that mutated its argument changed which scorers ran on every later row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The handler ran after `with root_span:` exited, so the scores that default_error_score_handler logs to the root span were written after metrics.end. Call it inside the span block instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge the per-row leak test and the handler-mutation test into one test, since both check that each row's handler gets the right scorer names; the merged test also covers the scorer-error path. Share an evaluator builder across the three remaining tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ore handler Copy the list at the point it is handed to user code instead of at the start of every row. Only failed rows pay for the copy, and a handler that drains its list before raising no longer leaves the warning without scorer names. Collapse the per-row score asserts in the consolidated test into one dict comparison. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1faaca1
into
braintrustdata:main
|
made some changes, but thanks Raphael Fakhri (@RaphaelFakhri)! |
## Summary Forward the existing `error_score_handler` parameter from `EvalAsync()` to `_EvalCommon`, as `Eval()` already does. Previously, passing a custom handler to the async public API silently discarded it. Even for one evaluation row, a task or scorer failure produced no fallback scores and never called the handler. The synchronous API returned the configured fallback score for the same input. This is a one-line forwarding fix. Public signatures, default values, runner behavior and exception handling are unchanged. ## Regression coverage 18 parameterized cases exercise the real public `Eval` and `EvalAsync` APIs across: - task failure, scorer failure and successful evaluation; - handlers returning fallback scores, handlers returning `None`, and no custom handler. Tests verify callback invocation, scores and retained task/scorer error semantics. Successful evaluations do not invoke the handler. ## Validation Python 3.12.14, existing local test environment, offline socket guard, `no_send_logs=True`, cache disabled for the new regressions: - New regressions on the unchanged base: **4 failed, 14 passed**. - After the fix: **18 passed**. - `src/braintrust/test_framework.py`: **139 passed, 2 skipped**, independently repeated with the same result. The skips concern optional Pydantic and Python 3.14 lazy annotations. - Runtime assertions in `type_tests/test_eval_generics.py` and `type_tests/test_dict_scorers.py`: **17 passed**. This is not a static type-check result. - Ruff check/format, `git diff --check`, and the configured pre-commit checks for these files pass (two unrelated file-filtered hooks skipped). - All test runs recorded zero attempted network connections. No live provider calls or cassette recording. - Full nox core/provider/Python-version CI matrix and pyright/mypy/pylint were not run. ## Related work and scope Related to #909, which fixes per-row state and handler execution inside the runner. This PR only fixes the public `EvalAsync` argument forwarding, reproducible with one row; it does not modify or duplicate that runner work. AI assistance was used for analysis, implementation and the listed local verification. No human approval or hosted CI success is claimed. ## Hosted checks At head `12484d1684c1f3946f91a4780b95fbf3b3cb87b8`, [checks](https://github.com/braintrustdata/braintrust-sdk-python/actions/runs/38631127645) and [integration-tests](https://github.com/braintrustdata/braintrust-sdk-python/actions/runs/38631127636) are both completed with `action_required`, each with zero jobs. There are zero check runs. Hosted tests have not executed; local results above are not a claim of passing hosted CI.
|
released with https://github.com/braintrustdata/braintrust-sdk-python/releases/tag/py-sdk-v0.46.1 - thanks again Raphael Fakhri (@RaphaelFakhri) |
Summary
Fixes several problems with how
error_score_handleris called when a row's task or scorers fail._run_evaluator_internal_implkeptunhandled_scoresin the enclosing function and updated it withnonlocalfrom every row. A row whose task raised read whatever the last finished row stored there. If an earlier row finished all its scorers, the value wasNone, so the handler never ran. If an earlier row had a failing scorer, the failed row only got an error score for that one scorer. Withdefault_error_score_handler, failed rows dropped out of the score averages instead of counting as 0.unhandled_scoresis now local to each row, which matches the JavaScript SDK.EvalResult, outside the row'stry/except, so an exception from it cancelled every remaining row. It is now caught and reported witheprint, and the row keeps its other results without error scores.scorer_nameslist itself. A handler that changed its argument changed which scorers ran on every later row. The handler now gets a copy.with root_span:exited, so the scoresdefault_error_score_handlerlogs to the root span were written aftermetrics.end. It now runs inside the span block.Tests
Three tests in
test_framework.py, sharing an_error_score_evaluatorbuilder:test_error_score_handler_gets_all_scorers_for_each_failed_rowruns a scorer-error row, a task-error row, a clean row and another task-error row. It uses a handler that empties its list, and checks that each row's handler gets exactly the scorers that row is missing and that every row's scores are correct.test_error_score_handler_exception_does_not_abort_evalchecks that a raising handler leaves the other rows scored.test_error_score_handler_logs_before_root_span_endsreads the raw memory-logger writes and checks that the error scores come before the root span'smetrics.end.Each test fails against
framework.pyfrom before its fix and passes after it.🤖 Generated with Claude Code