Skip to content

fix(eval): make error score handler calls reliable for every failed row - #909

Merged
Abhijeet Prasad (AbhiPrasad) merged 6 commits into
braintrustdata:mainfrom
RaphaelFakhri:fix/eval-error-score-handler-per-row
Oct 14, 2026
Merged

Abhijeet Prasad (AbhiPrasad) merged 6 commits into
braintrustdata:mainfrom
RaphaelFakhri:fix/eval-error-score-handler-per-row

Conversation

@RaphaelFakhri

@RaphaelFakhri Raphael Fakhri (RaphaelFakhri) commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes several problems with how error_score_handler is called when a row's task or scorers fail.

  • Unhandled scores leaked across rows. _run_evaluator_internal_impl kept unhandled_scores in the enclosing function and updated it with nonlocal from 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 was None, 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. With default_error_score_handler, failed rows dropped out of the score averages instead of counting as 0. unhandled_scores is now local to each row, which matches the JavaScript SDK.
  • A raising handler aborted the whole eval. The handler was called while building the EvalResult, outside the row's try/except, so an exception from it cancelled every remaining row. It is now caught and reported with eprint, and the row keeps its other results without error scores.
  • A mutating handler could corrupt later rows. The handler received the evaluator-wide scorer_names list itself. A handler that changed its argument changed which scorers ran on every later row. The handler now gets a copy.
  • Error scores were logged after the root span ended. The handler ran after with root_span: exited, so the scores default_error_score_handler logs to the root span were written after metrics.end. It now runs inside the span block.

Tests

Three tests in test_framework.py, sharing an _error_score_evaluator builder:

  • test_error_score_handler_gets_all_scorers_for_each_failed_row runs 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_eval checks that a raising handler leaves the other rows scored.
  • test_error_score_handler_logs_before_root_span_ends reads the raw memory-logger writes and checks that the error scores come before the root span's metrics.end.

Each test fails against framework.py from before its fix and passes after it.

cd py
python -m pytest src/braintrust/test_framework.py -k error_score_handler
# 3 passed
python -m pytest src/braintrust/test_framework.py src/braintrust/test_framework2.py src/braintrust/test_workflow_eval.py
# 187 passed, 4 skipped
nox -s test_core
# 985 passed, 69 skipped, 12 xfailed

🤖 Generated with Claude Code

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.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T20:14:12.916754Z 4b5e887 New commits
ℹ️ About Codex in GitHub

Your 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.

Copy link
Copy Markdown
Contributor

Raphael Fakhri (@RaphaelFakhri), I found an adjacent entry-point issue while checking the error-score handler: EvalAsync accepts error_score_handler but omits it from its _EvalCommon(...) call. This reproduces with a single row on main (457aaa8):

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 error_score_handler=error_score_handler in that call. This single-row omission is independent of the per-row state issue and remains outside the current diff.

AI-assisted investigation; reproduced locally without backend calls.

@RaphaelFakhri

Copy link
Copy Markdown
Contributor Author

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 error_score_handler from EvalAsync makes sense on its own.

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>
@AbhiPrasad Abhijeet Prasad (AbhiPrasad) changed the title fix(eval): track unhandled scores per row for the error score handler fix(eval): make error score handler calls reliable for every failed row Oct 14, 2026
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) merged commit 1faaca1 into braintrustdata:main Oct 14, 2026
98 checks passed
@AbhiPrasad

Copy link
Copy Markdown
Member

made some changes, but thanks Raphael Fakhri (@RaphaelFakhri)!

Abhijeet Prasad (AbhiPrasad) pushed a commit that referenced this pull request Oct 14, 2026
## 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.
@AbhiPrasad

Copy link
Copy Markdown
Member

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants