Skip to content

Eval score summaries: report percentiles, not just the mean - #1636

Merged
tawnymanticore merged 3 commits into
dchiang/eb-v2-mergefrom
mike/eval-score-percentiles
Aug 20, 2026
Merged

Eval score summaries: report percentiles, not just the mean#1636
tawnymanticore merged 3 commits into
dchiang/eb-v2-mergefrom
mike/eval-score-percentiles

Conversation

@tawnymanticore

@tawnymanticore tawnymanticore commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What

Adds six optional distribution fields to ScoreSummary in app/desktop/studio_server/eval_api.py:

min_score, p25_score, median_score, p75_score, p90_score, max_score — alongside the existing mean_score.

Both aggregation sites now route through one shared helper, score_summary_from_values(values, n_excluded):

  • compute_score_summary()
  • the run-config eval_scores endpoint

Both previously kept a running total; they now accumulate the individual scores into a list so the summary can report the distribution. Skipped runs are excluded exactly as they already were for the mean, and the count > 0 or n_excluded > 0 emit condition is unchanged.

Why

Eval score aggregation currently reports only a mean. For numeric (custom-type) scores — tool-call counts, tokens per call, per-turn latency — the distribution is heavily right-skewed, and the mean badly misrepresents it.

Measured on a 140-conversation sweep: on one lane the mean and median diverged by 1.70×224.5s mean vs 132.2s median — driven by a single ~30-minute outlier conversation. Reading the mean alone, that lane looks uniformly slow; it is actually a well-behaved lane with one pathological run. The mean is the wrong summary for deciding whether a regression is broad or a tail event, and the tail is what dominates cost and risk.

Sharing one helper between the two sites is deliberate: it makes it impossible for them to drift onto different percentile definitions.

Percentile definition

Reuses the existing kiln_server.statistics_lib.percentilelinear interpolation between the two nearest order statistics, matching the numpy.percentile / statistics.quantiles(method="inclusive") default. Chosen because it is the definition already in the codebase and the one most readers assume, so an even-length list's median is the average of the two middle values rather than the lower one, and p90 of a short list is interpolated rather than snapped to an existing datum. No new dependency.

Empty input yields None for every statistic, never 0.0 — a 0 would flow into downstream aggregation as if it were a real datum. The percentiles follow mean_score's existing convention here exactly.

Merge with the run/score split

scosman/evals_v2 merged in. Production code and api_schema.d.ts merged cleanly; only test_eval_api.py conflicted, in two places:

  • Import list — union of the base's new exports (scored_trace_usage_for_run_config, split_size, summary_eval_config) with this branch's score_summary_from_values.
  • test_score_summary_percentilescompute_score_summary now takes a ResolvedSplit rather than a bare id set, so the split is built with the base's stub_split helper. The item set (four scored + one skipped) and every distribution assertion are unchanged.

Both aggregation sites still route through the shared helper afterwards, including the eval_scores endpoint, which the base reworked around eval_run_item_key / ResolvedSplit but left on the same score-accumulation shape.

Back-compat

Strictly additive:

  • mean_score, n_used, n_excluded are unchanged in name and semantics.
  • Every new field is optional with a None default, so older stored payloads and existing API/TypeScript consumers keep working untouched.
  • api_schema.d.ts was regenerated with the sanctioned app/web_ui/src/lib/generate_schema.sh (never hand-edited) — a 30-line additive diff, all fields emitted as optional (?). Re-verified after the merge: the regenerated file byte-matches the checked-in one on every ScoreSummary field.

Tests

  • libs/server/kiln_server/test_statistics_lib.py — new TestPercentile covering the previously untested percentile(): empty, single value, odd/even length, unsorted input, all-equal, out-of-range raises, and a parametrized case pinned to numpy's default gold values.
  • app/desktop/studio_server/test_eval_api.pytest_score_summary_percentiles at compute_score_summary; all-skipped assertions confirming percentiles are None rather than 0.0 at both the helper and the endpoint; and endpoint-level distribution assertions over three scores (3.5 / 4.0 / 4.5), which exercise both the exact-index and interpolation branches and pin a case the mean alone cannot distinguish from any other set summing to 12.0.

Post-merge: test_eval_api.py + test_statistics_lib.py224 passed. ruff format --check clean on both touched files.

Numeric (custom-type) eval scores - tool-call counts, tokens per call,
per-turn latency - are heavily right-skewed over a large sweep, and a
mean alone hides the tail that dominates cost and risk. ScoreSummary now
carries min / p25 / median / p75 / p90 / max alongside mean_score.

Strictly additive: mean_score, n_used and n_excluded are unchanged in
name and semantics, and every new field is optional with a None default,
so existing API and TypeScript consumers keep working untouched.

Both aggregation sites (compute_score_summary and the run-config
eval_scores endpoint) now accumulate the individual scores rather than a
running total, and share one score_summary_from_values helper - so the
two sites cannot drift on the percentile definition. Skipped runs are
excluded exactly as they already were for the mean.

Percentiles reuse the existing kiln_server.statistics_lib.percentile:
linear interpolation between the two nearest order statistics, matching
the numpy.percentile / statistics.quantiles default. Empty input yields
None for every statistic, never 0.0 - a 0 would flow into downstream
aggregation as if it were a real datum.

Tests cover empty / single / odd / even / all-skipped at the helper, at
compute_score_summary and through the eval_scores endpoint, plus the
previously untested statistics_lib.percentile. api_schema.d.ts is
regenerated with generate_schema.sh (additive, all fields optional).

Committed with --no-verify: the committed .git/hooks/pre-commit cd's to
the main checkout's hook directory, so from a worktree it lints the wrong
tree. The checks were instead run directly against this tree: ruff
check and ruff format --check pass repo-wide, check_schema.sh reports the
OpenAPI schema up to date, and the touched test modules pass. The one
failing test under checks.sh (test_document_api content-type detection for
.md) reproduces on the pristine branch with these changes stashed, and the
web checks fail only because node_modules is not installed in a fresh
worktree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 790ac33e-b01b-45b2-8e45-49f0d039ad02

📥 Commits

Reviewing files that changed from the base of the PR and between 2925037 and ff5adc0.

📒 Files selected for processing (3)
  • app/desktop/studio_server/eval_api.py
  • app/desktop/studio_server/test_eval_api.py
  • app/web_ui/src/lib/api_schema.d.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


Walkthrough

The evaluation API now calculates minimum, percentile, median, maximum, and mean scores from retained values. Task-level and run-config-level summaries use the shared aggregation helper. Tests and the web API schema cover populated and empty summaries.

Changes

Score distribution aggregation

Layer / File(s) Summary
Summary contract and aggregation helper
app/desktop/studio_server/eval_api.py, app/desktop/studio_server/test_eval_api.py, libs/server/kiln_server/test_statistics_lib.py
ScoreSummary includes nullable distribution fields. score_summary_from_values computes the statistics and preserves empty-data handling. Percentile behavior has direct test coverage.
Task-level score aggregation
app/desktop/studio_server/eval_api.py, app/desktop/studio_server/test_eval_api.py
Task-level aggregation retains non-skipped scores and builds distribution-aware summaries. Tests cover populated, skipped, and empty results.
Run-config aggregation and API schema
app/desktop/studio_server/eval_api.py, app/desktop/studio_server/test_eval_api.py, app/web_ui/src/lib/api_schema.d.ts
Run-config aggregation uses the shared helper. The TypeScript schema exposes nullable distribution fields. Tests verify populated and entirely skipped results.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to ff5ad

This PR adds optional percentile fields while preserving existing score-summary behavior and reports passing targeted tests. No actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: chiang-daniel, claude

Poem

A rabbit sorts scores in a row,
Finds p25 and p90 in the flow.
Empty runs show None with care,
Skipped runs leave scores there.
The summary now shows the spread below.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding percentile reporting to evaluation score summaries.
Description check ✅ Passed The description thoroughly explains the change, rationale, implementation, compatibility, percentile definition, tests, and validation results.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mike/eval-score-percentiles

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

📊 Coverage Report

Overall Coverage: 92%

Diff: origin/scosman/evals_v2...HEAD

  • app/desktop/studio_server/eval_api.py (100%)

Summary

  • Total: 27 lines
  • Missing: 0 lines
  • Coverage: 100%

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
app/desktop/studio_server/eval_api.py (1)

651-681: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Sort values once in score_summary_from_values.

statistics_lib.percentile() sorts its input internally, so the four percentile calls re-sort values. Sorting once in score_summary-from_values lets min_score/max_score come from the already-sorted array and removes the extra O(n log n) passes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/desktop/studio_server/eval_api.py` around lines 651 - 681, Update
score_summary_from_values to sort values once after the empty-input check, then
use the sorted sequence for min_score, max_score, and all percentile
calculations. Ensure the percentile calls use the sorting-bypassing option
supported by statistics_lib.percentile so they do not re-sort the data.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/desktop/studio_server/eval_api.py`:
- Around line 651-681: Update score_summary_from_values to sort values once
after the empty-input check, then use the sorted sequence for min_score,
max_score, and all percentile calculations. Ensure the percentile calls use the
sorting-bypassing option supported by statistics_lib.percentile so they do not
re-sort the data.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 85bc21f9-21ad-4d7e-8789-d5b24bfa32b9

📥 Commits

Reviewing files that changed from the base of the PR and between bb1a2e5 and 7a81689.

📒 Files selected for processing (4)
  • app/desktop/studio_server/eval_api.py
  • app/desktop/studio_server/test_eval_api.py
  • app/web_ui/src/lib/api_schema.d.ts
  • libs/server/kiln_server/test_statistics_lib.py

Production code and the generated schema merged cleanly. Two test-side
conflicts with the run/score split that landed on the base:

- import list: union the new base exports with score_summary_from_values.
- test_score_summary_percentiles: compute_score_summary now takes a
  ResolvedSplit rather than a bare id set, so build the split with the
  base's stub_split helper. The item set (four scored + one skipped) and
  every distribution assertion are unchanged.

app/desktop/studio_server/test_eval_api.py + libs/server/kiln_server/test_statistics_lib.py: 224 passed.
@tawnymanticore

Copy link
Copy Markdown
Collaborator Author

Merged scosman/evals_v2 in — conflicts resolved, PR is mergeable again.

Production code (eval_api.py) and api_schema.d.ts merged cleanly; the run/score split only collided in test_eval_api.py:

  • Import list — union of the base's new exports (scored_trace_usage_for_run_config, split_size, summary_eval_config) and this branch's score_summary_from_values.
  • test_score_summary_percentilescompute_score_summary now takes a ResolvedSplit instead of a bare id set, so the split is built with the base's stub_split helper. The item set (four scored + one skipped) and every distribution assertion are unchanged.

Both aggregation sites still route through score_summary_from_values after the merge — including the run-config eval_scores endpoint, which the base reworked around eval_run_item_key / ResolvedSplit but left on the same score-accumulation shape.

test_eval_api.py + test_statistics_lib.py: 224 passed. ruff format --check clean on both touched files. The regenerated api_schema.d.ts byte-matches the checked-in one on every ScoreSummary field.


Generated by Claude Code

@tawnymanticore
tawnymanticore requested review from chiang-daniel and removed request for scosman and sfierro August 19, 2026 16:11
The branch already carried evals_v2 at 4ee3cb9, the merge-base with
eb-v2-merge, so this brings eb-v2's 197 commits on top rather than
reconciling two lineages.

One conflict, an import-list collision in test_eval_api.py: eb-v2 added
`scored_trace_usage` where this branch added `score_summary_from_values`.
Union of the two.

Both aggregation sites still route through score_summary_from_values after
the merge — compute_score_summary and the run-config eval_scores endpoint —
and the only ScoreSummary constructions left in the file are the two inside
the helper, so eb-v2's rework of eval_api didn't introduce a third site that
would bypass the shared percentile definition.

api_schema.d.ts regenerated against the new base: the same 30-line additive
diff, every field optional. No new endpoints, so the agent-policy annotations
are byte-identical to the base's.

app/desktop/studio_server/test_eval_api.py + test_statistics_lib.py: 240
passed. Full libs/ + app/desktop: 8146 passed, with 5 failures and 5 errors
that all reproduce on the base (vector-store, chunker, model-cache benchmark,
document-api, and tkinter-less desktop imports). ruff format clean on all
three touched files.
@tawnymanticore
tawnymanticore changed the base branch from scosman/evals_v2 to dchiang/eb-v2-merge August 19, 2026 17:39
@tawnymanticore

Copy link
Copy Markdown
Collaborator Author

Retargeted to dchiang/eb-v2-merge (was scosman/evals_v2).

Clean merge, same shape as #1591's retarget: the branch already carried evals_v2 at 4ee3cb97, which is the merge-base with eb-v2-merge, so this brings eb-v2's 197 commits on top rather than reconciling two lineages.

One conflict, an import-list collision in test_eval_api.py — eb-v2 added scored_trace_usage where this branch added score_summary_from_values. Union of the two.

The thing worth checking on a base that reworked eval_api this heavily is whether a third aggregation site appeared that would bypass the shared percentile definition. It didn't: both sites still route through score_summary_from_values (compute_score_summary and the run-config eval_scores endpoint), and the only ScoreSummary(...) constructions left in the file are the two inside the helper itself.

api_schema.d.ts regenerated against the new base — the same 30-line additive diff, every field optional. No new endpoints, so the agent-policy annotations are byte-identical to the base's.

Verification on the new base: test_eval_api.py + test_statistics_lib.py 240 passed; full libs/ + app/desktop 8146 passed. The diff is unchanged in shape from where it was on evals_v2 — 4 files, 290 insertions. ruff format --check clean on all three touched files.

⚠️ Heads up: dchiang/eb-v2-merge is currently red in its own CI — its last three runs all fail TestValidateMultiTurnDriveReadiness::test_two_message_chain_of_thought_run_config_rejected with DID NOT RAISE. That test arrives with the merge, so this PR will now inherit the failure where it was previously green on evals_v2. Nothing this PR touches — the assertion resolves gpt_4o's reasoning capability through the remote model list, and _splits_a_turn_into_two_messages swallows any lookup failure into False, which produces exactly that symptom. Same inherited failure as #1591 and #1645.


Generated by Claude Code

@tawnymanticore
tawnymanticore merged commit d0668b0 into dchiang/eb-v2-merge Aug 20, 2026
6 of 10 checks passed
@tawnymanticore
tawnymanticore deleted the mike/eval-score-percentiles branch August 20, 2026 16:10
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