Skip to content

Fix remote eval reruns by preserving upsert IDs - #763

Merged
Abhijeet Prasad (AbhiPrasad) merged 2 commits into
mainfrom
fix/remote-eval-upsert-id
Sep 14, 2026
Merged

Abhijeet Prasad (AbhiPrasad) merged 2 commits into
mainfrom
fix/remote-eval-upsert-id

Conversation

@paultancre-bt

@paultancre-bt paultancre-bt commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Why

Fixes COR-84.

The playground sends a stable upsert_id for each row/eval column so reruns replace the existing result. Python's EvalCase.from_dict() drops this field, and the eval runner creates a fresh root record on every run. The resulting records appear as duplicate grid rows with stale outputs. The JS SDK already honors upsert_id.

This addresses the SDK cause identified during review of the earlier UI workaround, braintrust#20332.

Repro

  1. Configure a dataset-backed playground with two Python remote evals.
  2. Run a row, then rerun it in either eval column.
  3. Although the request supplies the same upsert_id, Python writes a different root record ID, leaving both results in the grid.

The regression test reproduces this by running an evaluator twice with the same upsert_id and checking the logged root record IDs. It fails before the fix.

Fix

Preserve optional upsert_id in EvalCase and its input TypedDicts. The first trial uses that value as its root record ID; additional trials derive deterministic UUIDs from the upsert ID and trial index. This keeps the single-trial playground behavior and lets each additional trial replace its own result on rerun without overwriting other trials. Both experiment-backed and parent-context evaluations use this logic. Missing or empty IDs continue to generate fresh record IDs; dataset row IDs and origins retain their existing meaning.

This prevents future duplicates after SDK upgrade; it does not remove historical duplicate records. This PR changes Python only; the analogous JS multi-trial behavior needs a separate fix.

Test

  • All 48 rerun regression cases pass, covering dictionary/dataclass inputs, experiment/parent-context execution, supplied/missing/empty upsert IDs, and global/per-row trial counts. The tests verify distinct stable IDs per trial, updated output, preserved origin, and matching root/child outputs. The multi-trial cases fail with the bare upsert ID reused across trials.
  • Full core suite: 850 passed, 63 skipped, 12 expected failures.
  • All 35 type tests pass, including the optional field on dataclass and TypedDict inputs; pyright and mypy pass.
  • Ruff lint, formatting, and commit hooks pass.
  • No manual browser run with the patched SDK yet.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8715337d74

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread py/src/braintrust/framework.py Outdated
Comment on lines +1652 to +1653
if datum.upsert_id:
base_event["id"] = datum.upsert_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve distinct IDs for repeated trials

When an eval case supplies upsert_id and its effective trial_count is greater than one, every concurrently scheduled trial receives this same root ID. Because each new SpanImpl initially emits a replacement rather than a merge, the trials race to overwrite one logical row instead of producing the distinct results promised by trial_count, potentially leaving a root and child spans from different trials. Derive an ID that is stable per trial (for example from upsert_id and trial_index) rather than applying the bare ID to every trial.

Useful? React with 👍 / 👎.

@AbhiPrasad Abhijeet Prasad (AbhiPrasad) Sep 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have the same issue in the js sdk?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from what I found no, the JS is not impacted, Python drops upsert_id; JS already preserves it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(yes for the P1),JS SDK also assigns the same upsert_id to every trial, so with trialCount > 1, trials can overwrite each other’s root record. Let me make another PR to correct this is the JS SDK as well (fix here)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Luca Forstner (lforst) added a commit to braintrustdata/braintrust-sdk-javascript that referenced this pull request Sep 14, 2026
### Why

Related to
[COR-84](https://linear.app/braintrustdata/issue/COR-84/remote-eval-playground-re-running-a-row-leaves-a-duplicate-grid-row).
Addresses the JS counterpart of the [P1 raised on the Python SDK
fix](braintrustdata/braintrust-sdk-python#763 (comment)).

The intent is to keep one independently updatable result per evaluation
trial. The JS SDK already preserves `upsert_id`, but currently reuses
that exact record ID for every trial. With multiple trials, their root
records overwrite each other, losing trial results and potentially
leaving task spans attached to the wrong surviving root. Generating
fresh random IDs would avoid collisions but break rerun upserts.

This fixes an existing JS multi-trial bug, not Python's original
dropped-`upsert_id` bug. It matches the per-trial ID scheme in
[braintrust-sdk-python#763](braintrustdata/braintrust-sdk-python#763).

### Repro

1. Run an evaluator with a row containing `upsert_id: "eval-row"` and
`trialCount: 3`, with a task returning a different output for each trial
index.
2. Inspect the logged records after row merging: only one root record
remains instead of three because all trials use `id: "eval-row"`.
3. Rerun with the same upsert ID: the trials collide again instead of
each updating their own result.

The regression reproduces this for both experiment-backed and
remote-parent execution, and with global or per-row trial counts. Before
the fix, four multi-trial cases fail with one logged root instead of
three; the other 20 cases pass. Two remote eval columns are not required
to trigger this P1.

### Fix

- Keep the original `upsert_id` for trial zero, preserving single-trial
behavior.
- For later trials, derive a deterministic UUID v5 in the URL namespace
from `braintrust:eval:<upsert_id>:trial:<trial_index>`. Each trial has a
distinct ID that stays stable on rerun, matching Python.
- Leave missing/empty upsert IDs on the existing fresh-ID path. Do not
change dataset origins, trial scheduling, or public APIs; reuse the
existing UUID dependency.
- Include the required patch changeset for `braintrust`.

The behavior change is limited to additional trials with a non-empty
upsert ID. This prevents future trial collisions after upgrading; it
does not restore overwritten results or clean up historical records.

### Test

- `pnpm test src/framework.test.ts --reporter=dot`: all 104 tests pass,
including 24 regression cases covering supplied/missing/empty IDs,
global/per-row trial counts, and experiment/parent-context execution.
- Regression assertions check separate roots, matching root/task
outputs, stable IDs across reruns, and UUID values matching Python's
implementation.
- `pnpm run check:typings`: passes for production and test code.
- Targeted ESLint: no errors; existing warnings remain and the
repository config excludes test files.
- `pnpm run fix:formatting` and `git diff --check`: pass.

---------

Co-authored-by: Paul Tancre <paultancre@p-tancre.home>
Co-authored-by: lforst <8118419+lforst@users.noreply.github.com>
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) merged commit f8852fa into main Sep 14, 2026
83 checks passed
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) deleted the fix/remote-eval-upsert-id branch September 14, 2026 13:29
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.

2 participants