Skip to content

feat(experiments-v3): pairwise compare end-to-end with winner-by-id label#5142

Merged
Aryansharma28 merged 44 commits into
mainfrom
worktree-pairwise-mvp
Jun 29, 2026
Merged

feat(experiments-v3): pairwise compare end-to-end with winner-by-id label#5142
Aryansharma28 merged 44 commits into
mainfrom
worktree-pairwise-mvp

Conversation

@Aryansharma28

@Aryansharma28 Aryansharma28 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Closes #5100
Closes #5130
Supersedes #5106

Summary

  • Ships pairwise compare as its own column in the EvaluationsV3 workbench (feat(experiments-v3): pairwise compare as a column-target (#5130) #5131, parent Native pairwise LLM-as-judge evaluator (V3, built-in) — MVP #5100), with the langevals pairwise_compare evaluator returning the winning candidate's identifier (or "tie") in result.label so SDK / REST / MCP consumers read the winner directly — no slot-letter dereference.
  • Orchestrator resolves prompt variants to their human handle (via loadedPrompts.get(promptId).handle) before dispatch so what lands in ClickHouse (Label) is e.g. "say-hi" instead of "target_…".
  • Fixes the 4-layer dispatch chain that was silently dropping candidate_a_* extras (orchestrator → route → worker → client), and adds per-variant evaluator-score injection into the judge prompt.

What this changes

Backend / data contract

  • langevals/pairwise_compare.py: label is the winner's candidate id (entry.candidate_a_id / entry.candidate_b_id) or "tie". Regenerated ts-integration/evaluators.generated.ts and synced via pnpm copy:langevals-types.
  • orchestrator.ts: new variantIdentifierFor() resolves prompt → handle → promptId → target id (chip-style + column-target paths). completedTargetEvaluatorScores map captures Phase 1 evaluator results per (row, target); withEvaluatorScores() appends them to the candidate output text fed to the judge.
  • evaluations-legacy.ts: stops stripping non-canonical fields from data; surfaces real error messages instead of "Internal error".
  • evaluationsWorker.ts: forwards candidate_* extras to the langevals proxy call.
  • useExecuteEvaluation.ts: client sends target.pairwise in the execute payload.

UI

  • Purple Swords icon on the Pairwise Compare column header (matches the picker card).
  • Column-header scoreboard: langy-test-pr… wins 2 · 1 tie / say-hi wins 3 / Tied / Tied · 1 tie. Names truncated to 18 chars; tooltip shows full breakdown.
  • Per-row cell + row strip: 🏆 winner (bold green) vs loser (muted), = icon for ties. Judge "Call 1 (A in slot A, B in slot B):" preamble stripped from reasoning preview.
  • friendlyError() maps auth / 404 / rate-limit / missing-candidate errors to actionable headlines + hints; raw stack behind "show details" popover.
  • Removed the top aggregate bar (redundant with the column scoreboard); dropped vaporware Promote and the cryptic "Bias-corrected" badge.

Programmatic contract

GET /api/trace/{traceId}
{
  "evaluations": [{
    "evaluatorType": "langevals/pairwise_compare",
    "status": "processed",
    "score": 1.0,
    "label": "langy-test-prompt-1779807355405",
    "details": "Candidate A does not answer the question about hours …"
  }]
}

Both legacy "A" / "B" / "tie" and the new candidate-id label are accepted by the UI matchers — historical rows keep rendering.

Verified

  • Direct curl to /langevals/pairwise_compare/evaluate returns label: <winner candidate id>
  • Zero TS diagnostics on every touched file.

Test plan

  • Workbench → click Run: per-row cells show 🏆 winner vs loser; ties render as = Tie
  • Column header reads <winner> wins N · M ties with Swords icon
  • SELECT Label FROM evaluation_runs WHERE EvaluatorType='langevals/pairwise_compare' — values are prompt handles or "tie", not A/B
  • Trigger an error (bad model) — cell shows human headline + hint, raw behind "show details"
  • Attach an LLM Boolean evaluator to one variant + re-run — judge "why?" popover references the score

Refs: #5131 #5100

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a native Pairwise Compare (LLM-as-judge) evaluator to compare two candidates against a golden reference, producing per-row A / B / tie verdicts with scoring and reasoning.
    • Extended Pairwise compare in the workbench: evaluator setup, verdict rendering, aggregate header, CSV export, and copy as bug report, plus Losses filtering.
    • Introduced a pairwise target flow in the UI (including pairwise-specific configuration).
  • Bug Fixes
    • Improved swap-and-confirm behavior, including tie when judges disagree, and skipping incomplete rows; cost is reported when available.
  • Tests
    • Added unit and browser coverage for evaluator, orchestration, aggregation, and UI behavior.

@mintlify

mintlify Bot commented Jun 26, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
langwatch 🟢 Ready View Preview Jun 26, 2026, 11:06 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds pairwise compare evaluator support end to end: backend contracts and payload passthrough, native judge execution, phase-2 orchestrator flow, aggregates and exports, and V3 editor/table/header rendering for pairwise verdicts.

Changes

Pairwise compare feature

Layer / File(s) Summary
Backend field passthrough
langevals/langevals_core/.../base_evaluator.py, langevals/scripts/generate_evaluators_ts.py, langwatch/src/server/evaluations/evaluators.ts, langwatch/src/server/.../evaluationsWorker.ts, langwatch/src/server/.../evaluations-legacy.ts, langwatch/src/server/routes/__tests__/evaluator-input-coercion.unit.test.ts
BaseEvaluator accepts extra subclass fields, evaluator field-name typing widens, and legacy/worker request paths plus coercion tests preserve non-canonical evaluator payload keys.
Native pairwise judge
langevals/evaluators/.../pairwise_compare.py, langevals/evaluators/.../tests/test_pairwise_compare.py
The new LLM judge evaluator compares two candidates against a golden reference, supports swap-and-confirm, maps slot winners to candidate IDs, and returns normalized score, label, reasoning, and cost; tests cover skip, swap behavior, and verdict mapping.
Pairwise config contract
langwatch/src/experiments-v3/types.ts, langwatch/src/experiments-v3/utils/mappingInference.ts, langwatch/src/experiments-v3/utils/mappingValidation.ts, langwatch/src/experiments-v3/utils/evaluatorEditorCallbacks.ts, langwatch/src/experiments-v3/hooks/useEvaluationsV3Store.ts, langwatch/src/experiments-v3/hooks/useExecuteEvaluation.ts
Pairwise config schemas, target-mapping derivation, validation, callback typing, store updates, and execution payloads add pairwise fields to evaluator and target state.
Pairwise editor wiring
langwatch/src/components/evaluators/EvaluatorTypeSelectorContent.tsx, langwatch/src/components/targets/TargetTypeSelectorDrawer.tsx, langwatch/src/experiments-v3/hooks/useOpenEvaluatorEditor.ts, langwatch/src/experiments-v3/hooks/useOpenTargetEditor.ts
The selector and editor hooks open pairwise compare as a dedicated evaluator path and pass variant targets plus dataset columns into the drawer flow.
Pairwise config form
langwatch/src/components/evaluators/EvaluatorEditorShared.tsx, langwatch/src/components/checks/DynamicZodForm.tsx, langwatch/src/experiments-v3/components/EvaluatorPanel/PairwiseConfigForm.tsx
The evaluator editor body renders pairwise-specific config UI, suppresses the generic metrics field in pairwise mode, and the pairwise form keeps variant and metric selections in sync.
Phase 2 pairwise orchestration
langwatch/src/server/experiments-v3/execution/types.ts, langwatch/src/server/experiments-v3/execution/orchestrator.ts, langwatch/src/server/experiments-v3/execution/__tests__/orchestrator.test.ts
Execution cells gain a pairwise payload, phase 1 skips pairwise work, phase 2 generates synthetic pairwise cells, and tests cover the new staging flow.
Pairwise aggregates and handoffs
langwatch/src/experiments-v3/utils/computeAggregates.ts, langwatch/src/experiments-v3/utils/pairwiseHandoffs.ts, langwatch/src/experiments-v3/utils/__tests__/computeAggregates.test.ts, langwatch/src/experiments-v3/utils/__tests__/pairwiseHandoffs.test.ts
Pairwise verdict counts, totals, per-row aggregates, and Markdown bug/export/promote payloads are computed and tested.
Pairwise table UI
langwatch/src/experiments-v3/components/EvaluationsV3Table.tsx, langwatch/src/experiments-v3/components/TableMetaWrappers.tsx, langwatch/src/experiments-v3/components/PairwiseCompareCell.tsx, langwatch/src/experiments-v3/components/PairwiseVerdictRow.tsx, langwatch/src/experiments-v3/components/RowVerdictStrip.tsx, langwatch/src/experiments-v3/components/TargetSection/TargetCell.tsx, langwatch/src/experiments-v3/components/TargetSection/EvaluatorChip.tsx
The table seeds pairwise target config, maps pairwise verdicts into row data, and renders pairwise result cells, verdict strips, and chip states in collapsed target rows.
Pairwise header UI
langwatch/src/experiments-v3/components/AggregateHeaderBar.tsx, langwatch/src/experiments-v3/components/PairwiseAggregateHeader.tsx, langwatch/src/experiments-v3/components/TargetSection/TargetHeader.tsx, langwatch/src/experiments-v3/components/__tests__/pairwise-preview.browser.test.tsx, specs/experiments/pairwise-compare-mvp.feature
The aggregate header, target header, preview screenshots, and feature spec render pairwise scoreboard counts, filters, export actions, and preview states.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related issues

Possibly related PRs

  • langwatch/langwatch#4892 — This change extends the evaluator editor opening flow and pairwise configuration handling built on that earlier editor/mapping work.

Suggested reviewers

  • sergioestebance
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.10% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: end-to-end pairwise compare support in Experiments V3.
Linked Issues check ✅ Passed The PR covers the linked pairwise column MVP and the own-column UX in #5100/#5130.
Out of Scope Changes check ✅ Passed No clear unrelated code paths were added; the changes all support pairwise compare end-to-end.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-pairwise-mvp

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.

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

Actionable comments posted: 12

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

Inline comments:
In `@langevals/evaluators/langevals/langevals_langevals/pairwise_compare.py`:
- Around line 189-194: The prompt rendering in PairwiseCompareEvaluator
currently uses self.settings.prompt.format with a fixed key set, so unknown
user-defined placeholders can raise KeyError and crash evaluation. Update the
prompt rendering logic in PairwiseCompareSettings/PairwiseCompareEvaluator to
handle missing placeholders safely by either switching to a forgiving
substitution approach that leaves unknown fields intact or wrapping the format
call in a KeyError guard and falling back gracefully. Keep the existing input,
golden, and candidate output substitutions, but ensure custom prompts with extra
tokens do not fail.

In `@langwatch/src/components/checks/DynamicZodForm.tsx`:
- Around line 484-491: The `DynamicZodForm` logic uses an explicit `any` in the
`isLiteralUnion` check, which violates the no-explicit-any rule. Replace the
`any` annotation on the `ZodUnion` options callback with a safer type such as
`unknown`, then narrow it with the existing `instanceof z.ZodLiteral` check in
the `element.options.every(...)` path. Keep the fix localized to the
`fieldSchema_`, `element`, and `isLiteralUnion` block so the `include_metrics`
handling still infers literal option values correctly.

In `@langwatch/src/components/evaluators/EvaluatorEditorShared.tsx`:
- Around line 133-141: `pairwiseContext.targets` in `EvaluatorEditorShared`
expects full `TargetConfig[]`, but `useOpenEvaluatorEditor` is currently
stripping each target down to only `{ id }`, which breaks name resolution in
`useTargetName`. Update the `pairwiseContext` construction in
`useOpenEvaluatorEditor` to pass the original `targets` array through unchanged
instead of mapping it to id-only objects, so pairwise editor forms can display
human-readable target names.

In
`@langwatch/src/experiments-v3/components/EvaluatorPanel/PairwiseConfigForm.tsx`:
- Around line 153-159: The update helper in PairwiseConfigForm should not call
onChange inside the setDraft updater because the updater must stay pure and can
run twice under StrictMode. Move the onChange(next) call out of the setDraft
callback in the update function so PairwiseConfigForm.update only computes and
stores the next draft state, then notifies the parent once with the finalized
next value.

In `@langwatch/src/experiments-v3/components/PairwiseAggregateHeader.tsx`:
- Around line 111-164: The PairwiseAggregateHeader component is dead code and
should be removed since it is not rendered or imported anywhere. Delete the
PairwiseAggregateHeader export and its file, and then check whether
ResolvedAggregateBar or any related aggregate-header component has no remaining
runtime consumers; if so, remove that as well to keep the experiments-v3
components clean.

In `@langwatch/src/experiments-v3/components/PairwiseCompareCell.tsx`:
- Around line 187-229: The verdict rendering in PairwiseCompareCell is
duplicated in both the ResolvedVerdict path and the no-variant fallback,
contributing to the file exceeding the source-line limit. Extract the shared
winner/tie plus Popover markup into a reusable VerdictBody component that takes
winnerSide, winnerName, loserName, and reasoning props, then use that component
in both locations to remove the repeated JSX and bring the file under the
threshold.

In `@langwatch/src/experiments-v3/hooks/useEvaluationsV3Store.ts`:
- Around line 684-694: The pairwise mapping merge in useEvaluationsV3Store is
preserving stale candidate_a/candidate_b/golden/input entries when a pick is
cleared because existingTarget.mappings is spread before the newly derived
mappings. Update the mapping merge so the pairwise-managed keys are removed from
the prior dataset mapping before applying derivePairwiseTargetMappings, using
the existing state.datasets loop and the newDatasetMappings assembly to rebuild
each dataset’s mapping from only the current pairwise selections.

In `@langwatch/src/experiments-v3/utils/__tests__/computeAggregates.test.ts`:
- Around line 700-768: The current pairwise aggregate tests only cover legacy
A/B/tie labels and never exercise candidate-id normalization in
computePairwiseAggregate/normalizePairwiseLabel. Add a test case in
computeAggregates.test.ts where the processed result uses a variant identifier
(the target id or prompt handle) and provide targets with promptId so the label
must be normalized instead of skipped. Assert that the aggregate counts and
perRow entry are recorded correctly for this handle-vs-promptId path.

In `@langwatch/src/experiments-v3/utils/computeAggregates.ts`:
- Around line 339-360: The pairwise aggregate lookup is using promptId as the
“handle,” but orchestrator.ts emits the prompt handle as the label, so labels
never match and rows become null. Update computeAggregates.ts in the
variantAHandle/variantBHandle resolution and the normalizePairwiseLabel call so
the function receives the actual prompt handle (or otherwise align both sides on
the same identifier); if targets only expose id/promptId, thread the real handle
into this function instead of deriving it from promptId.

In `@langwatch/src/server/experiments-v3/execution/orchestrator.ts`:
- Around line 296-309: The verdict identifier emitted by variantIdentifierFor is
the prompt handle when available, but computeAggregates must not assume the
echoed label is promptId. Update the aggregation matching logic to accept the
handle returned by variantIdentifierFor (and only fall back to promptId/id when
needed) so handle-based verdicts are correctly associated; use
variantIdentifierFor and the prompt lookup in loadedPrompts as the reference
points when aligning the contract.

In `@specs/experiments/pairwise-compare-mvp.feature`:
- Around line 24-26: The pairwise-compare MVP spec still asserts the old verdict
labels, which conflicts with the new evaluator contract. Update the scenario
around the row-level verdict result in pairwise-compare-mvp.feature to match the
actual emitted label format from the evaluator, using the winning candidate
identifier or "tie" instead of only "A"/"B"/"tie". Align this wording with the
label handling in TargetCell so the spec and implementation describe the same
result shape.
- Around line 35-39: The scenario text is stale and does not match what
AggregateHeaderBar actually renders. Update the Then steps in the pairwise
compare feature to reflect the shipped scoreboard format from AggregateHeaderBar
and the browser test’s “New scoreboard format” expectation, including the
leader/other wording, ties, and total verdicts. Remove the “Bias-corrected”
indicator assertion since that element is not rendered by the header.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: faa6339e-f283-4915-8534-326f2cfe4621

📥 Commits

Reviewing files that changed from the base of the PR and between 8decd88 and 5edf005.

⛔ Files ignored due to path filters (12)
  • docs/pr5106/01-pairwise-config-form.png is excluded by !**/*.png
  • docs/pr5106/02-aggregate-header-bar.png is excluded by !**/*.png
  • docs/pr5106/03-aggregate-header-losses.png is excluded by !**/*.png
  • docs/pr5106/04-row-verdict-a-wins.png is excluded by !**/*.png
  • docs/pr5106/05-row-verdict-tie.png is excluded by !**/*.png
  • docs/pr5106/06-row-verdict-b-wins.png is excluded by !**/*.png
  • docs/pr5106/07-evaluator-chip-tints.png is excluded by !**/*.png
  • docs/pr5106/08-llm-judge-list-live.png is excluded by !**/*.png
  • docs/pr5106/09-pairwise-settings-form-live.png is excluded by !**/*.png
  • docs/pr5106/10-workbench-pairwise-form-live.png is excluded by !**/*.png
  • docs/pr5106/11-experiments-workbench-live.png is excluded by !**/*.png
  • docs/pr5106/12-mapping-gap-demonstrated.png is excluded by !**/*.png
📒 Files selected for processing (42)
  • langevals/evaluators/langevals/langevals_langevals/pairwise_compare.py
  • langevals/evaluators/langevals/tests/test_pairwise_compare.py
  • langevals/langevals_core/langevals_core/base_evaluator.py
  • langevals/scripts/generate_evaluators_ts.py
  • langevals/ts-integration/evaluators.generated.ts
  • langwatch/src/components/checks/DynamicZodForm.tsx
  • langwatch/src/components/evaluators/EvaluatorEditorShared.tsx
  • langwatch/src/components/evaluators/EvaluatorTypeSelectorContent.tsx
  • langwatch/src/components/targets/TargetTypeSelectorDrawer.tsx
  • langwatch/src/experiments-v3/components/AggregateHeaderBar.tsx
  • langwatch/src/experiments-v3/components/EvaluationsV3Table.tsx
  • langwatch/src/experiments-v3/components/EvaluatorPanel/PairwiseConfigForm.tsx
  • langwatch/src/experiments-v3/components/PairwiseAggregateHeader.tsx
  • langwatch/src/experiments-v3/components/PairwiseCompareCell.tsx
  • langwatch/src/experiments-v3/components/PairwiseVerdictRow.tsx
  • langwatch/src/experiments-v3/components/RowVerdictStrip.tsx
  • langwatch/src/experiments-v3/components/TableMetaWrappers.tsx
  • langwatch/src/experiments-v3/components/TargetSection/EvaluatorChip.tsx
  • langwatch/src/experiments-v3/components/TargetSection/TargetCell.tsx
  • langwatch/src/experiments-v3/components/TargetSection/TargetHeader.tsx
  • langwatch/src/experiments-v3/components/__tests__/pairwise-preview.browser.test.tsx
  • langwatch/src/experiments-v3/hooks/useEvaluationsV3Store.ts
  • langwatch/src/experiments-v3/hooks/useExecuteEvaluation.ts
  • langwatch/src/experiments-v3/hooks/useOpenEvaluatorEditor.ts
  • langwatch/src/experiments-v3/hooks/useOpenTargetEditor.ts
  • langwatch/src/experiments-v3/types.ts
  • langwatch/src/experiments-v3/utils/__tests__/computeAggregates.test.ts
  • langwatch/src/experiments-v3/utils/__tests__/pairwiseHandoffs.test.ts
  • langwatch/src/experiments-v3/utils/computeAggregates.ts
  • langwatch/src/experiments-v3/utils/evaluatorEditorCallbacks.ts
  • langwatch/src/experiments-v3/utils/mappingInference.ts
  • langwatch/src/experiments-v3/utils/mappingValidation.ts
  • langwatch/src/experiments-v3/utils/pairwiseHandoffs.ts
  • langwatch/src/server/background/workers/evaluationsWorker.ts
  • langwatch/src/server/evaluations/evaluators.generated.ts
  • langwatch/src/server/evaluations/evaluators.ts
  • langwatch/src/server/experiments-v3/execution/__tests__/orchestrator.test.ts
  • langwatch/src/server/experiments-v3/execution/orchestrator.ts
  • langwatch/src/server/experiments-v3/execution/types.ts
  • langwatch/src/server/routes/__tests__/evaluator-input-coercion.unit.test.ts
  • langwatch/src/server/routes/evaluations-legacy.ts
  • specs/experiments/pairwise-compare-mvp.feature

Comment thread langevals/evaluators/langevals/langevals_langevals/pairwise_compare.py Outdated
Comment thread langwatch/src/components/checks/DynamicZodForm.tsx
Comment thread langwatch/src/components/evaluators/EvaluatorEditorShared.tsx
Comment thread langwatch/src/experiments-v3/components/PairwiseAggregateHeader.tsx Outdated
Comment thread langwatch/src/experiments-v3/utils/computeAggregates.ts Outdated
Comment thread langwatch/src/server/experiments-v3/execution/orchestrator.ts
Comment thread specs/experiments/pairwise-compare-mvp.feature
Comment thread specs/experiments/pairwise-compare-mvp.feature Outdated
@Aryansharma28

Aryansharma28 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Visual walkthrough — all flows

Inline screenshots removed from this comment after rebase — they were committed under docs/screenshots/pr5142/ and the user asked to keep working screenshots out of the merged codebase. See the verified end-to-end summary below for the full proof of the same flows (CH label evidence + per-row verdicts in the code/test output).

If reviewers want fresh screenshots before merge, ping me and I'll re-render against the latest HEAD and post them as plain image uploads (no codebase commit).

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
langwatch/src/server/experiments-v3/execution/orchestrator.ts (3)

247-556: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Split the new exported generatePairwiseCells.

langwatch/src/server/experiments-v3/execution/orchestrator.ts Line 247 adds generatePairwiseCells, and the function spans Lines 247-556. Break it into focused helpers for dependency resolution, pairwise payload assembly, and synthetic evaluator config creation. As per path instructions: “FAIL when a NEW exported function exceeds 60 source lines or has cyclomatic complexity > 10.”

🤖 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 `@langwatch/src/server/experiments-v3/execution/orchestrator.ts` around lines
247 - 556, The new exported generatePairwiseCells function is too large and
complex, so split it into smaller helpers while keeping the same behavior.
Extract separate helpers for resolving variant targets, building pairwise
candidate payloads, and creating the synthetic evaluator config, then have
generatePairwiseCells orchestrate those helpers. Keep the exported entrypoint
focused and ensure each helper is referenced from generatePairwiseCells so the
logic remains unchanged.

Source: Path instructions


285-289: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use object parameters for new multi-argument helpers.

withEvaluatorScores(output, rowIndex, variantId) at Line 285 and setIfDefined(key, value) at Line 847 violate the TypeScript rule for multi-argument functions. Change the signatures and local call sites to object destructuring. As per coding guidelines: “Use named parameters via object destructuring for functions with multiple arguments: fn({ a, b }) not fn(a, b).”

Also applies to: 847-849

🤖 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 `@langwatch/src/server/experiments-v3/execution/orchestrator.ts` around lines
285 - 289, The new helper signatures use positional parameters, which conflicts
with the TypeScript guideline for multi-argument functions. Update
withEvaluatorScores and setIfDefined to accept a single object parameter using
named destructuring, then adjust every local call site in orchestrator.ts to
pass an object instead of separate arguments. Keep the function names and their
internal usage consistent so the refactor is applied everywhere these helpers
are called.

Source: Coding guidelines


232-235: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Route pairwise evaluator re-runs through Phase 2.

langwatch/src/server/experiments-v3/execution/orchestrator.ts Line 235 says pairwise evaluators run in Phase 2, but scope.type === "evaluator" still schedules [evaluatorConfig] in Phase 1 with no cell.pairwise. The new input builder only fills candidate_a_* / candidate_b_* when cell.pairwise exists; langevals/pairwise_compare requires those fields, so a single pairwise evaluator re-run fails instead of producing a verdict. Route this scope through dependency expansion + generatePairwiseCells, or reject pairwise evaluator scope. As per coding guidelines: “Do not write comments describing behavior that the code doesn't actually implement.”

🤖 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 `@langwatch/src/server/experiments-v3/execution/orchestrator.ts` around lines
232 - 235, The Phase 1 evaluator scheduling in orchestrator.ts still sends
pairwise evaluators through the normal single-cell path, which does not populate
the candidate_a/candidate_b inputs required by pairwise_compare. Update the
scope handling in the orchestrator flow that builds evaluator work for
scope.type === "evaluator" so pairwise configs are either expanded through the
same dependency logic and generatePairwiseCells path used for Phase 2, or
explicitly rejected before scheduling. Make sure the evaluatorConfig filtering
and pairwise cell creation are aligned so pairwise re-runs cannot enter Phase 1
without cell.pairwise data.

Source: Coding guidelines

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

Outside diff comments:
In `@langwatch/src/server/experiments-v3/execution/orchestrator.ts`:
- Around line 247-556: The new exported generatePairwiseCells function is too
large and complex, so split it into smaller helpers while keeping the same
behavior. Extract separate helpers for resolving variant targets, building
pairwise candidate payloads, and creating the synthetic evaluator config, then
have generatePairwiseCells orchestrate those helpers. Keep the exported
entrypoint focused and ensure each helper is referenced from
generatePairwiseCells so the logic remains unchanged.
- Around line 285-289: The new helper signatures use positional parameters,
which conflicts with the TypeScript guideline for multi-argument functions.
Update withEvaluatorScores and setIfDefined to accept a single object parameter
using named destructuring, then adjust every local call site in orchestrator.ts
to pass an object instead of separate arguments. Keep the function names and
their internal usage consistent so the refactor is applied everywhere these
helpers are called.
- Around line 232-235: The Phase 1 evaluator scheduling in orchestrator.ts still
sends pairwise evaluators through the normal single-cell path, which does not
populate the candidate_a/candidate_b inputs required by pairwise_compare. Update
the scope handling in the orchestrator flow that builds evaluator work for
scope.type === "evaluator" so pairwise configs are either expanded through the
same dependency logic and generatePairwiseCells path used for Phase 2, or
explicitly rejected before scheduling. Make sure the evaluatorConfig filtering
and pairwise cell creation are aligned so pairwise re-runs cannot enter Phase 1
without cell.pairwise data.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fccbc943-01af-4a9c-b649-6c2032968b84

📥 Commits

Reviewing files that changed from the base of the PR and between 2f0ae1a and bd8f805.

⛔ Files ignored due to path filters (4)
  • docs/screenshots/pr5142/01-add-comparison-purple-swords.png is excluded by !**/*.png
  • docs/screenshots/pr5142/02-pairwise-drawer-full-form.png is excluded by !**/*.png
  • docs/screenshots/pr5142/03-verdicts-rendered.png is excluded by !**/*.png
  • docs/screenshots/pr5142/04-reasoning-popover.png is excluded by !**/*.png
📒 Files selected for processing (1)
  • langwatch/src/server/experiments-v3/execution/orchestrator.ts

@Aryansharma28

Copy link
Copy Markdown
Contributor Author

✅ Verified end-to-end — code + power-user demo

Tests

  • TS unit tests (pairwise PR surface): 882 pass / 0 fail / 17 skipped across 58 test files
    pnpm test:unit src/experiments-v3 src/server/experiments-v3 src/server/routes src/components/checks src/components/evaluators
    
  • langevals pairwise tests: 10/10 pass
    uv run pytest evaluators/langevals/tests/test_pairwise_compare.py -v
    
  • CI: lint, typecheck, build, e2e, CodeQL, all unit + integration shards, sdk-go, sdk-js, sdk-python, langevals-complete, langwatch-app-complete — all green

Power-user end-to-end demo

Attached an Answers The Question LLM Boolean evaluator to both variants in vast-true-moon, then ran pairwise. The judge consumed the variants' scores via the withEvaluatorScores injection and produced reasoning that weighs correctness/completeness/style:

power-user-end-to-end

In the screenshot:

  • Left say-hi column: variant outputs + Answers The Question (LLM Boolean) chip per row
  • Middle langy-test-prompt-… column: variant outputs + same chip
  • Right Pairwise Compare column: verdicts (langy-test-prompt-… vs say-hi, with one = Tie)
  • Drawer (right side): the LLM Boolean evaluator config that's running on both variants

Live data from this run

  • 24 LLM Boolean scores landed in evaluation_runs (EvaluatorType=langevals/llm_boolean)

  • 3 fresh pairwise verdicts landed with Label = langy-test-prompt-1779807355405 and rich reasoning (Option C still intact)

  • Judge reasoning sample:

    "Candidate A does not answer the question at all; it is polite and harmless but provides no business hours or contact information. Candidate B also fails to provide the specific correct hours and contact channels from the golden answer… A maintains appropriate style while B is abusive and only vaguely informative."

    The judge explicitly evaluates correctness, completeness, and style — exactly the signals an attached evaluator (LLM Boolean) and the golden field provide.

What this proves

  1. Multi-evaluator signal flow works — any non-pairwise evaluator's score reaches the pairwise judge automatically via withEvaluatorScores in the orchestrator. No config required.
  2. Pairwise evaluators are excluded from the injection (no circular reasoning), as designed.
  3. Both client flows (rerun variants when missing / reuse seeded outputs when present) work end-to-end against the live stack.

Ready for review + merge.

@Aryansharma28 Aryansharma28 left a comment

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.

Goated review — verified pass

Treating this as a deep adversarial review rather than a courtesy stamp. Methodology: read existing reviews (CodeRabbit + bots), bucket the 60-file diff by risk, deep-read the high-risk buckets (orchestrator, evaluator dispatch, langevals contract, new client store paths), adversarially verify each finding, drop on uncertainty, dedupe against existing inline comments. Did not re-raise anything CodeRabbit already flagged.

Existing findings still load-bearing (NOT re-raised)

  • @coderabbitai's "handle vs promptId" pair (orchestrator.ts:354 + computeAggregates.ts:360) — confirmed real. variantIdentifierFor emits the prompt handle (e.g. say-hi) as candidate_a_id/candidate_b_id, which langevals echoes back as the verdict label. But computePairwiseAggregateFromResults looks up t.promptId (the KSUID, e.g. prompt_6IFkbb…) and passes it as the "handle" to normalizePairwiseLabel. These will never match → aggregate counts come out 0/0/0 → the column-header reads Tied even when there are real wins. Cell-level rendering in PairwiseCompareCell is unaffected (it uses useTargetName to resolve the handle and compares against that). Column-header summary is wrong. This is the single most important fix on the PR.
  • @coderabbitai's PairwiseAggregateHeader.tsx dead-code call-out — the file is no longer imported (EvaluationsV3Table.tsx strips it when the aggregate bar was removed). Recommend deleting the file + the unused useEvaluationsV3Store/useShallow/PLACEHOLDER_TARGET plumbing inside it.
  • @coderabbitai's StrictMode purity warning on PairwiseConfigForm.tsx:159 — calling onChange(next) inside setDraft's updater is real. Easy fix: compute next outside, set draft, then onChange.

New findings (not flagged elsewhere)

  • A1 — [NON-BLOCKER] orchestrator.ts ~line 1524: skip-reason pushEvent loop doesn't check aborted mid-iteration; if the user aborts during emission, the UI still receives synthetic "Waiting on..." errors. One-line fix: if (await abortManager.isAborted(runId)) break; at the top of the loop.
  • A2 — [NON-BLOCKER] PairwiseCompareCell.tsx friendlyError — the "Waiting on an upstream variant" branch sets the headline to a generic phrase AND uses the raw detail (which already begins with "Waiting on…") as the hint. Reads twice. Drop the hint (the raw is already on the popover) or shorten the headline.
  • A3 — [NON-BLOCKER] orchestrator.ts:333-338 (withEvaluatorScores JSON.stringify catch): the fallback String(output ?? "") produces "[object Object]" for non-serializable outputs and feeds it to the judge prompt — silently corrupts the candidate text. Safer: if JSON.stringify throws AND the output isn't a string, skip the score block entirely and return the original output.

Verdict

Comment — not requesting changes. The handle/promptId aggregate bug is the only "must fix before this is honestly shipped" item and it's CodeRabbit's, not mine. Once that's addressed the rest is polish.

What I couldn't verify from the diff alone

  • Whether any deployed dashboard/runbook references the column-header score format — if monitoring depends on the aggregate counts, the handle/promptId bug has user-visible blast radius that I can't size from here.
  • Whether the seedTargetOutputs round-trip behaves correctly when the client and server disagree on what an output looks like (e.g. legacy non-string outputs from older runs). Sample test pre-merge would buy more confidence.

@Aryansharma28

Copy link
Copy Markdown
Contributor Author

Goated-review findings — resolved

Pushed 62c209f3d addressing every open finding from the earlier deep-review pass + CodeRabbit's outstanding items.

BLOCKER (CodeRabbit)

  • computeAggregates.ts handle/promptId mismatch. The orchestrator emits the prompt handle (e.g. "say-hi") as the verdict label, but the aggregator was looking up promptId (KSUID) and using that as the "handle" — so the column-header summary always saw 0/0/0 and rendered "Tied". Now the aggregator takes a { variantAHandle, variantBHandle } hint object, and TargetHeader passes the names it already resolves via useTargetName. Tests still green (38/38).

NON-BLOCKERS

  • PairwiseAggregateHeader.tsx deleted. Confirmed zero importers (grep -rn PairwiseAggregateHeader empty after the rebase), file removed.
  • PairwiseConfigForm StrictMode purity. onChange was called inside the setDraft updater closure, which React 18 fires twice for purity checks → double-fired the parent callback. Moved to a useRef-backed pattern: refs track the latest draft so update computes next synchronously and calls onChange exactly once.
  • Orchestrator skip-reason abort respect. The MissingVariantOutput burst now checks abortManager.isAborted(runId) between iterations — a user-triggered stop won't keep writing skip events to ClickHouse.
  • friendlyError dedup. The "Waiting on…" surface was rendering the same sentence as headline AND hint. Now splits the orchestrator's "Waiting on X — no output…" detail on the em-dash so headline gets the variant, hint gets the action.
  • withEvaluatorScores non-serializable fallback. If JSON.stringify returns undefined (functions / symbols) or throws (circular refs / BigInt), we now return the candidate output unchanged instead of appending the score block to "[object Object]" — the judge gets the raw output and no scores, which is less misleading than a garbage candidate string.

Verification

  • pnpm typecheck ✅ clean
  • pnpm test:unit src/experiments-v3/utils/__tests__/computeAggregates.test.ts38/38 pass
  • npx biome format --write applied to all touched files

CI is running on the new commit; will keep watching until green and then hand back for Sergio's re-review.

@sergioestebance

Copy link
Copy Markdown
Collaborator

Ruthless review — pairwise compare (54 files / +6.4k)

Verdict: not mergeable as-is. Two P1 defects (one breaks a shipped surface, one can clobber stored verdicts on any scoped rerun) plus a wire-contract regression that hits every langevals evaluator. Core swap-and-confirm logic and the column-target happy path are sound; the failures are at the edges — scope handling, label format across surfaces, and the shared input path. All findings traced to source.


P1 — Evaluator-chip pairwise surface is dead against current langevals output

TargetCell.tsx:336 & :359 gate on label !== "A" && label !== "B" && label !== "tie". But pairwise_compare.py:154-162 emits the candidate id (entry.candidate_a_id, set by the orchestrator to the prompt handle, orchestrator.ts:348-359), never "A"/"B". So resolvePairwiseState returns undefined and renderPairwiseVerdicts continues → chip never tints, per-row strip never renders. The column-target path is correct because it routes through computeAggregates.normalizePairwiseLabel; the chip path does the literal compare. Two surfaces, two label contracts.
Fix: export normalizePairwiseLabel and route both TargetCell checks through it (pass variantA/variantB + handles).

P1 — Phase 2 is scope-blind: any scoped rerun regenerates the whole pairwise column

orchestrator.ts:1517 runs Phase 2 under if (!aborted) with no scope guard, and generatePairwiseCells (:286) takes no scope — it loops all rows. So rerunning one unrelated chip evaluator on one row (evaluator scope), or a single pairwise cell (cell scope), still fires full-column Phase 2. For rows not in seedTargetOutputs, the skip-reason branch (:1535-1563) emits evaluator_result{status:"error","Waiting on variant…"} → recorded to ClickHouse. Since the read model takes the latest version, good verdicts on unseeded rows get overwritten with errors (client only seeds the scoped target's rows; seeding at :1046). Best case (all rows seeded) it's a redundant full-column judge re-run — cost + latency on every single-cell Play.
Fix: thread scope into generatePairwiseCells; gate both the row loop and whether Phase 2 runs.


P2 — Unbounded extras passthrough widens the wire contract for every langevals evaluator

evaluationsWorker.ts:654-665 (and the legacy mirror evaluations-legacy.ts:564-583) spreads all non-canonical data.data keys into the langevals body for every evaluator, not just pairwise. allow_extra is opt-in (base_evaluator.py), which means strict pydantic models exist — a previously-stripped stray field now reaches them and can 422. Blast radius = all evaluators; only pairwise was tested.
Fix: bound the spread to the evaluator's own new Set([...requiredFields, ...optionalFields]), not "everything non-canonical."

P2 — Spec deliverables shipped as tested-but-unwired dead code (PR honesty)

AggregateHeaderBar, buildBugReport, buildExportReport, computePairwiseAggregate, buildPromotePayload — ~430 lines + browser/unit tests, zero non-test importers. So three spec scenarios are not in the live UI: "Copy as bug report", the aggregate "Bias-corrected" indicator (live header TargetHeader.tsx:489 shows only "{winner} wins N · T ties"), and the "Losses" filter chip. EvaluationsV3Table.tsx:1556 documents this as intentional follow-up — fine, but the PR description must not claim them as delivered, and the spec must be marked MVP-staged. Latent: buildExportReport:103 buckets any candidate-id label to tie (same root cause as P1) — undercounts when wired.

P2 — Python judge output parse is unguarded; the headline reasoning field has zero coverage

pairwise_compare.py:267-279: tool_calls[0].function.argumentsjson.loadsarguments["winner"]/["reasoning"] with no guard. tool_choice forces the call on compliant providers, but Azure/older models violate forced schemas in prod → TypeError/IndexError/JSONDecodeError/KeyError, surfaced as a bare row error with no message. No test exercises malformed judge output or the reasoning field.
Fix: wrap the parse; on failure return EvaluationResultError with the raw judge text in details; add a tool-less / missing-key test.

P2 — done summary can exceed 100%; column-style verdict logs as "unknown"

  • totalCells is Phase-1-only (:1011) but completedCells/failedCells include Phase-2 (:1609), so done.summary reports completed > total. Use one grand total (totalCells + pairwiseCells.length) at :1345/:1615/:1707.
  • Column-style cells have event.evaluatorId === target.id; the storage lookup state.evaluators.find(e => e.id === event.evaluatorId) (:1162/:1298) misses the synthetic evaluator → evaluatorType:"unknown", evaluatorName:null into CH. Fall back to state.targets.find(t => t.id === event.evaluatorId)?.targetEvaluatorId.

P2 — Spec drift

specs/experiments/pairwise-compare-mvp.feature:25-26 still asserts label in ("A","B","tie"); shipped contract is candidate id. Update the scenario.


P3 (worth fixing, not blocking)

  • DynamicZodForm.tsx:480-498 hardcodes fieldKey === "include_metrics" + cost/duration copy in the shared all-evaluators renderer — abstraction leak; drive off schema metadata.
  • computeAggregates.ts:156 folds the directional pairwise score (0/0.5/1) into the cross-evaluator averageScore — meaningless blend; exclude directional evaluators.
  • Triple-duplicated ["cost","duration"] literal set (generated schema, METRIC_META, types.ts:193) — add a metric in langevals and the column config silently rejects it. Single-source it.
  • generate_evaluators_ts.py:237 widens requiredFields/optionalFields to string[] for the whole catalog (loses compile-time field-name checks). Note: evaluators.generated.ts was already string[], so evaluators.ts:41 is consistent, not a regression — but the union-loosening is an undocumented trade-off.
  • Thin orchestration tests — orchestrator.test.ts only asserts pure cell-shape; the scope-blindness (P1) and accounting bugs (P2) would each be caught by one targeted test. Add cell-scope-generates-only-scoped-row + missing:"both"/"A" skip cases.
  • withEvaluatorScores (:340) can return a serialized/raw object to the judge for non-string outputs; relies on downstream coercion the extras path doesn't apply.
  • PairwiseConfigForm.tsx:149 useEffect(() => setDraft(value),[value]) can clobber in-flight edits if the parent ever passes a fresh value identity or debounces onChange.

Clean / verified

Swap-and-confirm issues exactly 2 calls, returns agreed winner / "tie" on disagreement (pairwise_compare.py:134-145); allow_extra defaults False with no regression to other evaluators; MetricsSection correctly uses useWatch not form.watch(); no hooks-return-JSX; no missing projectId/TenantId-first; SSE /execute error handling intact; Phase-1→Phase-2 completedTargetOutputs capture is race-free.

Minimum to merge: the two P1s + P2 extras-bounding + PR-description/spec alignment.

🤖 ruthless-review — findings traced to source, not pattern-matched.

@Aryansharma28

Copy link
Copy Markdown
Contributor Author

All CodeRabbit findings resolved — ready for re-review

Pushed three follow-up commits since the last review (62c209f361716da9580bf049) addressing every actionable finding from CodeRabbit's pass. Inline replies posted on each of the 12 comments above.

Disposition

# Finding Disposition Commit
1 pairwise_compare.py str.format crash on unknown placeholders Fixed — literal substitution, unknown slots pass through 61716da9
2 DynamicZodForm.tsx any cast Skipped — file untouched by this PR, CR self-tagged 💤 Low value
3 EvaluatorEditorShared targets stripped to {id} Fixed — pass full TargetConfig to drawer 580bf049
4 PairwiseConfigForm StrictMode purity Fixed — ref-backed draft, onChange outside updater 62c209f3
5 PairwiseAggregateHeader dead code Fixed — file deleted 62c209f3
6 PairwiseCompareCell >300 lines DRY Deferred to follow-up PR — refactor-only, not correctness
7 useEvaluationsV3Store stale pairwise mappings Fixed — PAIRWISE_DERIVED_KEYS strip before spread 61716da9
8 computeAggregates.test coverage gap on handle labels Fixed — 2 new regression tests 580bf049
9 computeAggregates handle/promptId mismatch (BLOCKER) Fixed — {variantAHandle, variantBHandle} hint 62c209f3
10 orchestrator handle contract No code change — contract correct; consumer side fixed 62c209f3
11 spec verdict-label assertion stale Fixed — Option C contract wording 61716da9
12 spec scoreboard format stale Fixed — actual AggregateHeaderBar format 61716da9

Verification

  • pnpm typecheck ✅ clean across all 3 commits
  • pnpm test:unit src/experiments-v3/utils/__tests__/computeAggregates.test.ts40/40 pass (was 38; +2 regression tests for handle labels)
  • npx biome format --write applied to all touched files

10 of 12 fixed in-PR. The 2 skipped are: an unrelated any cast in a file this PR doesn't touch, and a DRY refactor of PairwiseCompareCell that's purely structural — happy to ship as a focused follow-up where it can be reviewed in isolation.

@sergioestebance — ready for your re-review. This is the bulletproof pass.

@Aryansharma28

Copy link
Copy Markdown
Contributor Author

Self goated-review — one more bug found and fixed (ded22223)

After pushing the CR-resolution round, I ran a deep adversarial pass on the current HEAD — bucketed every changed file by risk and walked the data-flow contracts end-to-end. One real bug surfaced and is now fixed.

Bug — variantIdentifierFor promptId fallback was a silent verdict-dropper

In orchestrator.ts the resolver was:

if (handle) return handle;
return t.promptId;        // ← bug

But the client-side normalizePairwiseLabel in computeAggregates.ts only matches the label against (a) legacy A/B/tie, (b) the variant's internal target.id, or (c) the supplied handle hint. A raw promptId KSUID matches none of those — so if loadedPrompts was missing the prompt (deleted prompt, or worker cache miss right after a deploy), every pairwise verdict for that variant was silently dropped and the header collapsed to 0/0/0 · Tied.

Fix: drop the promptId fallback. Priority is now handle → target.id. Two new orchestrator tests cover both branches.

Surfaces I walked and verified clean

  • Phase 2 abort flow (skip-reason burst + cell loop + per-cell checkpoint) — abort respect is correct, per-cell barrier catches any leak past the skip-reason loop
  • seedTargetOutputs plumbing (client expansion → request payload → completedTargetOutputs pre-seed → Phase 2 consumption) — keys agree end-to-end, row-by-row partition is correct
  • PAIRWISE_DERIVED_KEYS strip — only fires in updateTargetPairwise on pairwise targets, never touches non-pairwise mappings
  • PairwiseConfigForm ref-backed draft — back-to-back picks and parent-pushed value updates both converge without stomping
  • pairwise_compare.py _judge + swap-and-confirm — slot-to-original-candidate translation is correct, position-bias forcing a tie even when allow_tie=False is the intended structural behavior

Verification

  • pnpm typecheck ✅ clean
  • pnpm test:unit orchestrator: 22/22 pass (+2 new), computeAggregates: 40/40 pass
  • biome formatted, pushed

@sergioestebance — `ded22223` is the new HEAD. Re-review when you have a minute.

@sergioestebance

Copy link
Copy Markdown
Collaborator

Ruthless review — verdict: 🟡 functionally sound, not clean-merge-ready

CI is fully green and mergeable, and the core runtime path is correctly implemented and well-tested: per-row verdicts, winner-by-id label, the 4-layer dispatch fix, column scoreboard, and the Phase-1→Phase-2 orchestration. The scariest candidate bug — the handle-vs-promptId mismatch flagged "Major" by CodeRabbit — is actually solved: normalizePairwiseLabel matches target-id OR handle (two-pronged), useTargetName returns prompt.handle (the same field the orchestrator emits), and there's a dedicated regression test. Most inline bot comments are stale (pre-fix).

Two issues should be cleared before merge; the rest are follow-ups. None are runtime regressions.

P2 — Dead code shipped + spec describes unreachable features

AggregateHeaderBar.tsx and utils/pairwiseHandoffs.ts are imported only by their own test files — no rendered component imports either. The PR description itself says "Removed the top aggregate bar." So the aggregate header, the Losses filter, and Copy-as-bug-report exist as tested-but-unwired modules. Three of the six scenarios in specs/experiments/pairwise-compare-mvp.feature describe behavior a user cannot reach:

  • Scenario: Aggregate header reflects tally (asserts the exact "variant_a 12 – 7 variant_b · 2 ties · 21 verdicts" format)
  • Scenario: Copy as bug report on losing row
  • Scenario: Filter chip — show losses

Feature files are the requirements — this is green CI on code that doesn't ship + a spec that lies. Fix: either wire AggregateHeaderBar/handoffs into the workbench, or delete both modules + their tests and cut those three scenarios. Don't leave it half-in.

P2 — Unscoped contract widening in the dispatch layer

evaluationsWorker.ts (~L640) and evaluations-legacy.ts (~L570) now spread all non-canonical input fields (...extras) into the langevals body for every evaluator type, not just pairwise — previously these were stripped. Any non-pairwise evaluator whose mappings emit a stray field will now forward it on the wire: a platform-wide behavioral change riding inside a pairwise PR. Fix: gate the passthrough on evaluatorType === "langevals/pairwise_compare" (or presence of candidate_*), or add a comment/test justifying why forwarding extras is safe for all evaluators. langevals-ci passing suggests low blast radius, but the scope is wider than the PR's stated intent.

P3 — follow-ups, not blocking

  • PairwiseCompareCell.tsx — 351 source lines; ResolvedVerdict and the no-variant fallback duplicate winner/tie/Popover JSX. Extract a VerdictBody. (SRP/DRY)
  • pairwise_compare.pyos.environ["AZURE_API_VERSION"] = "2023-12-01-preview" is copy-pasted from sibling evaluators (llm_boolean, off_topic, …). Not a new regression (self.env overrides it), but it's the stale-Azure-api-version hazard that 404s gpt-5 deployments. Worth flagging the whole family for an env-overridable default.
  • Progress accounting (orchestrator.ts) — Phase 1 emits total: totalCells, Phase 2 emits total: totalCells + pairwiseCells.length, so the denominator jumps mid-run; skip-reason error events write to CH + SSE but don't count toward total/completed. Cosmetic.
  • DynamicZodForm.tsx:486(o: any) violates the repo no-explicit-any-tsx rule; use z.ZodTypeAny. Trivial.

Already addressed (verified — not re-flagging)

Handle/promptId mismatch (fixed + tested), str.format KeyError (now .replace()), stale pairwise mappings on cleared pick (PAIRWISE_DERIVED_KEYS delete added), double import litellm (CodeQL green).

Residual testing gap

No test drives the real /execute route → orchestrator with the schema-validated pairwise field end-to-end. Orchestrator tests construct state objects directly, bypassing zValidator. pairwise is declared in targetConfigSchema/evaluatorConfigSchema (verified), so the schema-strip bug class is covered by construction — but one route-level integration test would harden it cheaply.


Recommendation: clear the two P2s before merge (both fast: delete-or-wire the dead modules + trim the spec; gate the extras passthrough). P3s are legitimate follow-ups.

🤖 ruthless-review

@Aryansharma28

Copy link
Copy Markdown
Contributor Author

Both P2s addressed at b1138d974, plus the chip-render contract gap your earlier review surfaced.

P2#1 — dead code + spec lies → chore(pairwise): drop dead AggregateHeaderBar + handoffs modules (afa5d7c)

  • Deleted AggregateHeaderBar.tsx, pairwiseHandoffs.ts, and the dedicated pairwiseHandoffs.test.ts.
  • Edited pairwise-preview.browser.test.tsx to drop the two AggregateHeaderBar cases.
  • Cut the three unreachable scenarios from specs/experiments/pairwise-compare-mvp.feature ("Aggregate header reflects tally", "Copy as bug report on losing row", "Filter chip — show losses") and replaced them with one scenario matching what the live TargetHeader column scoreboard actually renders.
  • Net −485 lines.

P2#2 — unscoped extras passthrough → fix(evaluators): bound extras passthrough to evaluator's known fields (b1138d9)

  • Both evaluationsWorker.ts and evaluations-legacy.ts now look up the evaluator definition via AVAILABLE_EVALUATORS and bound the spread to requiredFields ∪ optionalFields. Pairwise's candidate_* keys are declared in its catalog so they still ride through; any other evaluator's mappings stay canonical-6-only — the contract widening is gone.
  • Includes the // @ts-ignore → // @ts-expect-error micro-fix at evaluations-legacy.ts:161.

Bonus — chip-render dead under new contract → fix(pairwise): route chip-tint + verdict strip through normalizePairwiseLabel (d485a4b)
This was a regression your first review didn't catch and the second one assumed was "actually solved" — but normalizePairwiseLabel was only consumed by computePairwiseTargetAggregate. Three UI surfaces still did literal label === "A" | "B" | "tie" checks:

  • TargetCell.resolvePairwiseState → chip silently stops tinting under the new winner-by-id format.
  • TargetCell.renderPairwiseVerdicts → per-row strip silently skipped.
  • RowVerdictStrip + PairwiseVerdictRow were type-narrowed to the legacy labels, and label === "A" ? variantAName would render the wrong winner name even if the upstream filters were relaxed.

Exported normalizePairwiseLabel, then routed both surfaces through it:

  • New PairwiseAwareEvaluatorChip wrapper inside TargetCell resolves variant handles via useTargetName at its top level (so the hooks don't live inside the evaluators.map(...) loop) and computes the winner/loser/tie tint from the normalized slot.
  • PairwiseVerdictRow now takes the raw label: string and normalizes before delegating; returns null when neither variant matches, instead of falling through to a wrong-name render.

Typecheck clean; 762/762 unit tests passing locally across src/experiments-v3, src/server/experiments-v3, and src/server/routes/__tests__/evaluator-input-coercion.

P3 follow-ups (PairwiseCompareCell 351-line + duplicated ResolvedVerdict body, Azure API version env-overridable default, progress accounting denominator, DynamicZodForm any typing) deliberately left for follow-up PRs per your guidance.

Aryansharma28 and others added 7 commits June 29, 2026 11:11
Adds Python entry/settings/result classes for the new
langevals/pairwise_compare evaluator plus the BDD spec under
specs/experiments/.

evaluate() returns Skipped for now — implementation lands in
follow-up commits per the steps in the issue.

Parent epic: #5099

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implement the Python evaluator body for pairwise compare:

- _judge() runs one litellm tool-call with a deterministic verdict
  schema ({reasoning, winner}) and translates the displayed-slot
  winner back to the original candidate label so swap-and-confirm
  works correctly.
- evaluate() runs one judge call by default; when swap_and_confirm
  is True (the default) it issues a second call with positions
  swapped and returns "tie" on disagreement (PandaLM-style position
  bias mitigation).
- include_metrics is rendered into the prompt only when non-empty;
  metrics follow the displayed slot, not the original candidate.
- Returns PairwiseCompareResult with score (0.0/0.5/1.0), label,
  reasoning text, and summed judge cost.

Add `allow_extra=True` opt-in to EvaluatorEntry.__init_subclass__
so the multi-candidate shape (candidate_a_*, candidate_b_*, golden)
can declare typed fields without smuggling structured data through
the `contexts: list[str]` slot. Default behavior unchanged for the
existing single-output evaluator family; the error message now
points new authors at the opt-in.

Refs #5100, epic #5099.
Mock litellm.completion at the seam so the deterministic logic
(swap-and-confirm, order translation, metrics injection, score
mapping) is exercised without API keys.

Coverage:
- skipped when a candidate output is missing
- single judge call when swap_and_confirm=False
- exactly two judge calls when swap_and_confirm=True
- agreement returns the agreed winner
- disagreement returns "tie"
- order translation when running the swapped half directly
- metrics injected into the prompt only when include_metrics non-empty
- score map: A=0.0, tie=0.5, B=1.0

Real-API integration test pattern (a la test_llm_boolean) can be
added later; the logic checks above are the high-value ones.
Add the langevals/pairwise_compare entry to evaluators.generated.ts
(both the langevals/ts-integration source and its langwatch copy),
matching the shape and indentation style of the surrounding
generated entries.

Surgical patch instead of a full codegen rerun: the existing file
has accumulated formatting drift since its last regen (commit
d583fbe), so a fresh `uv run python scripts/generate_evaluators_ts.py`
produces ~5000 lines of unrelated ordering/whitespace churn. Keeping
this PR's diff focused on the feature; codegen-drift cleanup is a
separate concern.

Also: switch include_metrics from `default_factory=list` to
`default=[]` so the codegen (which only reads `field.default`)
doesn't crash on PydanticUndefined. Pydantic v2 deep-copies the
default at instantiation, so the usual mutable-default footgun does
not apply.

Refs #5100.
Add an optional `pairwise` block to evaluatorConfigSchema, with its
own exported sub-schema (`pairwiseEvaluatorConfigSchema`) so the UI
form and the server execution path can share the type.

Fields:
- variantA, variantB: TargetConfig ids whose per-row outputs are
  the two candidates being judged.
- goldenField: dataset field name whose value is the reference answer.
- includeMetrics: per-candidate metrics injected into the judge prompt
  (cost / duration). Empty by default.

The server-side `executionRequestSchema` consumes the same shared
`evaluatorConfigSchema`, so no mirror is needed in
`src/server/experiments-v3/execution/types.ts`. Adding the field is
backward compatible: existing evaluator configs validate unchanged
because `pairwise` is optional.

Refs #5100.
Two-pass orchestration so pairwise evaluators can run against both
variants' outputs without inventing a cross-cell dependency graph:

Phase 1 (existing flow, unchanged for non-pairwise users):
- generateCells emits per-(row, target) cells with all NON-pairwise
  evaluators attached.
- Pairwise evaluators are filtered out here — they would crash
  because candidate_b's output is not available within a single
  per-target cell.

Phase 1 -> Phase 2 bridge:
- runOrchestrator now tracks successful target outputs by
  (rowIndex, targetId) in a new `completedTargetOutputs` map as
  target_result events flow through processEventForStorage.

Phase 2 (new):
- After Phase 1's await Promise.all(activeCells), generatePairwiseCells
  emits one synthetic cell per (pairwise evaluator, rowIndex) where
  BOTH variantA and variantB outputs exist. The cell sets
  targetId=variantA and skipTarget=true so the existing workflow
  builder + skip-target fast path execute without modification.
- buildEvaluatorInputs grows a pairwise branch that bypasses the
  per-target mapping system: golden from cell.datasetEntry[goldenField],
  candidate_a_*/candidate_b_* from cell.pairwise, and `input` from
  variantA's existing dataset mapping (with a fallback to
  cell.datasetEntry.input).
- Cells run through the same semaphore + executeCell loop, so abort,
  progress, and CH dispatch all keep working.

Rows where one variant failed are silently skipped — there is no
honest pairwise verdict without both candidates.

Out of scope (separate issues):
- N-way (#5101): generatePairwiseCells can be generalized later, the
  Phase-2 hook stays the same.
- Cross-experiment comparison (#5102).

Refs #5100.
Three new UI components for the pairwise compare evaluator, plus a
winner/loser visual state on the existing EvaluatorChip:

PairwiseConfigForm (EvaluatorPanel/PairwiseConfigForm.tsx)
  - Variant A / Variant B target selects; variant B excludes variant A
    so users can't pick the same target twice.
  - Golden dataset-field select.
  - Include cost / Include latency checkboxes.
  - "Bias-corrected (2x judge calls)" indicator.

RowVerdictStrip
  - Per-row strip showing winner (Variant A name / B name / "Tie")
    with a popover for judge reasoning.

AggregateHeaderBar
  - Tally "<A> wins X · <B> wins Y · Ties Z"
  - Bias-corrected indicator + total judge cost.
  - Filter chips (All / A / B / Losses).
  - Export + Promote-A + Promote-B buttons (handoffs wired in step F).

EvaluatorChip
  - New `pairwiseState` prop: "winner" tints green, "loser" tints red,
    "tie" stays neutral. Callers compute this by mapping the verdict
    label ("A"/"B"/"tie") to the chip's target id via the evaluator's
    pairwise config.

Out of scope this commit:
  - Routing langevals/pairwise_compare to PairwiseConfigForm inside
    ConfigPanel, and rendering RowVerdictStrip + AggregateHeaderBar
    inside EvaluationsV3Table. Those wirings touch heavier state-
    plumbing and are tracked separately; the components are
    self-contained and ready to import.

Refs #5100.
Aryansharma28 and others added 5 commits June 29, 2026 11:11
Found during self-review. When `loadedPrompts.get(promptId)?.handle` came
back undefined (prompt deleted, or worker cache miss), the orchestrator
fell back to emitting the raw `promptId` KSUID ("prompt_6IFkbb…") as
the verdict identifier. But the aggregator's normalizer only matches
against (a) legacy A/B/tie, (b) the variant's internal target id, or
(c) the supplied handle hint — a promptId KSUID matches none of those,
so the verdict was silently dropped and the column-header summary
collapsed to 0/0/0.

Fix: skip the promptId fallback. Priority is now handle → target.id.
Both sides know how to normalize either.

Two new orchestrator tests:
- handle present in loadedPrompts → cell's candidate.id is the handle
- handle missing → cell's candidate.id is the internal target id, NOT
  the promptId KSUID

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
These modules and three of their spec scenarios described UI behavior
the PR explicitly removed (top aggregate bar, "Copy as bug report",
"Losses" filter chip). With no production importers they were dead
weight and a 430-line "spec lies" surface. Cut the source, the
dedicated tests, the preview-only AggregateHeaderBar test cases, and
the three unreachable scenarios; replace them with one scenario that
matches what the live column scoreboard (TargetHeader) actually renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…iseLabel

The PR description claimed the chip-tint and per-row verdict surfaces
accept both legacy "A"/"B"/"tie" and the new winner-by-id label
format. They did not — TargetCell.resolvePairwiseState and
renderPairwiseVerdicts both did literal `label === "A" | "B" | "tie"`
checks, and RowVerdictStrip's "A" / "B" ternary picked the wrong name
when the stored label was the winning candidate's prompt handle (e.g.
"say-hi"). Under the new orchestrator contract the chip-tint surface
silently stopped rendering and per-row strips silently stopped
appearing for any prompt-typed pairwise run.

Export `normalizePairwiseLabel` from `computeAggregates.ts` so both
surfaces share the same matcher as `computePairwiseTargetAggregate`,
then resolve the slot inside hook-friendly seams:

- `PairwiseAwareEvaluatorChip` wraps `EvaluatorChip` for pairwise
  evaluators. It calls `useTargetName` on both variants at the top of
  its render (which `evaluators.map(...)` cannot do) and computes the
  chip's winner/loser/tie tint from the normalized slot.
- `PairwiseVerdictRow` now accepts a raw `label: string` and does the
  same normalization before delegating to `RowVerdictStrip`; if the
  label matches neither variant's id nor handle, the strip returns
  null instead of falling through to a wrong-name render.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The pairwise PR added a non-canonical-fields spread in both the worker
(evaluationsWorker.ts) and the legacy route (evaluations-legacy.ts)
so pairwise's `candidate_a_*` / `candidate_b_*` reached the langevals
Python evaluator. The spread was unconditional, which silently widened
the wire contract for every other langevals evaluator — any stray
mapping output on a strict pydantic model would now 422 instead of
being stripped. Blast radius was all evaluators; only pairwise was
exercised.

Look up the evaluator definition via AVAILABLE_EVALUATORS and bound
the extras to `requiredFields ∪ optionalFields`. Pairwise's `candidate_*`
fields are declared in its catalog, so they still pass through; any
other evaluator's mappings remain canonical-6-only.

Also replace a `// @ts-ignore` introduced earlier in this PR's diff
with `// @ts-expect-error <reason>` (per project rule).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapse the multi-line Omit type and useMemo deps array onto one line
to match Biome's print width — caught by reviewdog-action-biome on
the chip-render fix commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Aryansharma28
Aryansharma28 force-pushed the worktree-pairwise-mvp branch from 01ac545 to a7439c7 Compare June 29, 2026 09:11
@github-actions

Copy link
Copy Markdown
Contributor

Automated low-risk assessment

This PR was evaluated against the repository's Low-Risk Pull Requests procedure and does not qualify as low risk.

This PR's diff exceeds the size limit for automated low-risk evaluation. Manual review required.

This PR requires a manual review before merging.

@Aryansharma28
Aryansharma28 merged commit 5964a2f into main Jun 29, 2026
78 checks passed
@Aryansharma28
Aryansharma28 deleted the worktree-pairwise-mvp branch June 29, 2026 09:20
0xdeafcafe added a commit that referenced this pull request Jul 3, 2026
…ate_a_id/output)

Upstream #5142 (pairwise_compare) added a 'preserve evaluator-specific fields' block to background/workers/evaluationsWorker.ts so evaluators that declare extras via required/optional fields (e.g. langevals/pairwise_compare's candidate_a_id, candidate_a_output, candidate_b_id, candidate_b_output) have those fields flow through to langevals via ...extras instead of being stripped by the canonical-6 forward.

This PR extracts runEvaluation out of the deleted worker; without this port, pairwise-compare evaluations silently strip the candidate fields and 422 from langevals. Port the block into runEvaluation.ts (kept path) at the same relative location — just before the stagedLangevalsFetch call — bounded to the evaluator's declared requiredFields ∪ optionalFields so a stray mapping output on a non-pairwise evaluator can't ride through.
Aryansharma28 added a commit that referenced this pull request Jul 6, 2026
…ing (#5367)

* fix(experiments-v3): resolve pairwise comparator UX bugs from dogfooding

Fixes a batch of issues surfaced while dogfooding the pairwise compare
feature (#5142, #5195): mislabeled buttons, dark-mode contrast failures,
inconsistent drawer styling, a raw 400 error leaking to users, and two
state-management bugs where the config drawer silently dropped user
input (Variant A/B/Golden field, and Include cost/duration) on re-render.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(experiments-v3): forward topLevel through ZodDefault unwrap in DynamicZodForm

The prior fix for duplicated Swap And Confirm / Allow Tie labels only
suppressed the inline label when topLevel=true, but the ZodDefault
unwrap branch recursed without forwarding it — every settings field
with .default() (which includes both of these) silently fell back to
topLevel=false, so the duplicate label bug was still live for the one
evaluator it was meant to fix. Verified live: reopening the Pairwise
Compare drawer now shows each label once, and Include cost/duration
toggles persist correctly across save + reopen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(experiments-v3): add regression test for missing-mapping alert click

Closes the one verification gap noted in the bug-fix batch: the alert
icon's onClick previously fell through to the chip's own uncontrolled
Menu.Trigger before stopPropagation could run. Asserts the click calls
onEdit exactly once and never renders the chip's own dropdown items.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(lint): satisfy biome formatting in pairwise bugfix files

CI's langwatch-app-ci lint job failed on line-wrapping in
ModelSelector.tsx, iconsMap.tsx, and evaluations-legacy.ts.
Applied via `biome check --write`, no logic changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(experiments-v3): apply CodeRabbit review suggestions

- Rename EvaluatorChip's menuOpen/setMenuOpen to isMenuOpen/setIsMenuOpen
  to satisfy the repo's boolean-naming convention (is/has/should prefix).
- Drop the redundant createEvaluator() call in the new alert-click test,
  reusing the already-constructed evaluator instance instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(ui): reuse providerKey instead of re-deriving in ModelSelector

CodeRabbit: selectedItem.value.split("/")[0] duplicated the already-
computed providerKey (selectedItem.value === model by construction of
the find() above), so just reuse it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(experiments-v3): apply remaining CodeRabbit outside-diff findings

- Rename DynamicZodForm's topLevel param to isTopLevel (boolean naming
  convention), same as the earlier menuOpen -> isMenuOpen rename.
- Fix a real, pre-existing bug in totalColumnPercentage: the dataset/
  target summation loops reconstructed column IDs as `dataset_${id}`
  and bare `target.id`, but resize actually stores under the header's
  real ID (`dataset.${id}` / `target.${targetId}`, per the
  columnHelper.accessor calls above). The mismatch meant a resized
  dataset/target column's width never factored into the table's total,
  silently falling back to the default percentage. Same root cause as
  the pairwise-column width bug this PR already fixes, just for the
  other two column types — verified by tracing the actual header IDs
  written by the resize handler and columnHelper definitions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
0xdeafcafe added a commit that referenced this pull request Jul 7, 2026
…ate_a_id/output)

Upstream #5142 (pairwise_compare) added a 'preserve evaluator-specific fields' block to background/workers/evaluationsWorker.ts so evaluators that declare extras via required/optional fields (e.g. langevals/pairwise_compare's candidate_a_id, candidate_a_output, candidate_b_id, candidate_b_output) have those fields flow through to langevals via ...extras instead of being stripped by the canonical-6 forward.

This PR extracts runEvaluation out of the deleted worker; without this port, pairwise-compare evaluations silently strip the candidate fields and 422 from langevals. Port the block into runEvaluation.ts (kept path) at the same relative location — just before the stagedLangevalsFetch call — bounded to the evaluator's declared requiredFields ∪ optionalFields so a stray mapping output on a non-pairwise evaluator can't ride through.
0xdeafcafe added a commit that referenced this pull request Jul 7, 2026
…ate_a_id/output)

Upstream #5142 (pairwise_compare) added a 'preserve evaluator-specific fields' block to background/workers/evaluationsWorker.ts so evaluators that declare extras via required/optional fields (e.g. langevals/pairwise_compare's candidate_a_id, candidate_a_output, candidate_b_id, candidate_b_output) have those fields flow through to langevals via ...extras instead of being stripped by the canonical-6 forward.

This PR extracts runEvaluation out of the deleted worker; without this port, pairwise-compare evaluations silently strip the candidate fields and 422 from langevals. Port the block into runEvaluation.ts (kept path) at the same relative location — just before the stagedLangevalsFetch call — bounded to the evaluator's declared requiredFields ∪ optionalFields so a stray mapping output on a non-pairwise evaluator can't ride through.
0xdeafcafe added a commit that referenced this pull request Jul 7, 2026
…ate_a_id/output)

Upstream #5142 (pairwise_compare) added a 'preserve evaluator-specific fields' block to background/workers/evaluationsWorker.ts so evaluators that declare extras via required/optional fields (e.g. langevals/pairwise_compare's candidate_a_id, candidate_a_output, candidate_b_id, candidate_b_output) have those fields flow through to langevals via ...extras instead of being stripped by the canonical-6 forward.

This PR extracts runEvaluation out of the deleted worker; without this port, pairwise-compare evaluations silently strip the candidate fields and 422 from langevals. Port the block into runEvaluation.ts (kept path) at the same relative location — just before the stagedLangevalsFetch call — bounded to the evaluator's declared requiredFields ∪ optionalFields so a stray mapping output on a non-pairwise evaluator can't ride through.
0xdeafcafe added a commit that referenced this pull request Jul 7, 2026
…ate_a_id/output)

Upstream #5142 (pairwise_compare) added a 'preserve evaluator-specific fields' block to background/workers/evaluationsWorker.ts so evaluators that declare extras via required/optional fields (e.g. langevals/pairwise_compare's candidate_a_id, candidate_a_output, candidate_b_id, candidate_b_output) have those fields flow through to langevals via ...extras instead of being stripped by the canonical-6 forward.

This PR extracts runEvaluation out of the deleted worker; without this port, pairwise-compare evaluations silently strip the candidate fields and 422 from langevals. Port the block into runEvaluation.ts (kept path) at the same relative location — just before the stagedLangevalsFetch call — bounded to the evaluator's declared requiredFields ∪ optionalFields so a stray mapping output on a non-pairwise evaluator can't ride through.
0xdeafcafe added a commit that referenced this pull request Jul 7, 2026
…ate_a_id/output)

Upstream #5142 (pairwise_compare) added a 'preserve evaluator-specific fields' block to background/workers/evaluationsWorker.ts so evaluators that declare extras via required/optional fields (e.g. langevals/pairwise_compare's candidate_a_id, candidate_a_output, candidate_b_id, candidate_b_output) have those fields flow through to langevals via ...extras instead of being stripped by the canonical-6 forward.

This PR extracts runEvaluation out of the deleted worker; without this port, pairwise-compare evaluations silently strip the candidate fields and 422 from langevals. Port the block into runEvaluation.ts (kept path) at the same relative location — just before the stagedLangevalsFetch call — bounded to the evaluator's declared requiredFields ∪ optionalFields so a stray mapping output on a non-pairwise evaluator can't ride through.
0xdeafcafe added a commit that referenced this pull request Jul 7, 2026
* refactor: remove Elasticsearch and the BullMQ legacy stack

The TraceService / EvaluationService / ExperimentRunService /
FilterService facades all had two backends — a ClickHouse one and an
Elasticsearch one — but every call was already going through ClickHouse.
The ES backends were constructed and never invoked. The legacy BullMQ
stack under src/server/background/ had been replaced piecewise by the
event-sourcing collector (recordSpan) but the old workers, queues, and
the tasks that wrote to ES were still wired up at boot.

This commit deletes both stacks from the application layer:

ES removal (live read/write paths):
- Drop the ElasticsearchTraceService / ElasticsearchEvaluationService /
  ElasticsearchExperimentRunService / ElasticsearchFilterService /
  ElasticsearchAnalyticsService backends (constructed but never called).
- Drop the evaluationEsSync + experimentRunEsSync reactors and the
  annotationEsSync hook — no more dual-write.
- Drop the ES analytics timeseries + scenario-analytics query builders.
- Drop the ES experiments batch-evaluation repository + the ES
  scenario-event service/repository (the SimulationFacade was already
  routing reads to ClickHouse).
- Strip Elasticsearch URL/API-key fields from the org settings input
  schema, the settings UI, and getApp().organizations.update.
- Drop the per-org ES-driven cron handlers
  (scenario_analytics, trace_analytics msearch, retention_period_cleanup,
  schedule_topic_clustering) and the ES task scripts they invoked
  (elasticMigrate, cold/*, deleteTracesRetentionPolicy, syncAnnotations,
  syncTraceCosts, backfillTraceAnalytics, backfillScenarioAnalytics,
  fixCustomMetadataOutsideCustomField, clearTopics, rerunChecks).
- Collapse env.IS_QUICKWIT branches.
- Replace ES query-builder type imports
  (@elastic/elasticsearch/lib/api/types) with neutral structural types
  in the filter / analytics / common-router files that never actually
  ship queries to ES anymore.
- Move the Protections type out of src/server/elasticsearch/protections
  to src/server/traces/protections.

BullMQ legacy stack removal:
- Delete src/server/background/ entirely (worker entry, the six queue
  files, collectorWorker, evaluationsWorker, topicClusteringWorker,
  usageStatsWorker, anomalyDetectionWorker).
- Move the pure helpers under background/workers/collector/ (cost,
  common, evaluations, evaluationNameAutoslug, metrics, piiCheck, rag)
  into src/server/tracer/collector/ — these are consumed app-wide and
  don't depend on BullMQ.
- Extract runEvaluation / runEvaluationForTrace / customEvaluation /
  DataForEvaluation from the deleted evaluationsWorker into
  src/server/evaluations/runEvaluation.ts, switching from ES
  getTraceById / getTracesGroupedByThreadId to TraceService.getById /
  getTracesByThreadId.
- Rewrite src/workers.ts: drop the 15-minute self-restart timer
  (helm/docker already restart-on-exit), drop the BullMQ Worker
  startups, initialize the worker-role app, and start the EE
  governance ingestion-puller worker via a new
  ee/governance/services/pullers/pullerQueue.ts.
- Migrate src/server/api/routers/httpProxyTracing.ts from
  scheduleTraceCollectionWithFallback to getApp().traces.recordSpan via
  CollectorSpanUtils — the only remaining caller of the old collector
  queue.
- Strip the legacy collector dedup (fetchExistingMD5s + 256-version
  cap) from src/server/routes/collector.ts; event sourcing handles
  dedup downstream.
- Drop /api/start_workers, /api/rerun_checks,
  /api/cron/schedule_topic_clustering,
  /api/cron/scenario_analytics,
  /api/cron/traces_retention_period_cleanup — the routes were
  exclusively legacy stack triggers.
- Delete src/server/topicClustering/ (BullMQ-only scheduler) and the
  /settings/topic-clustering page + command-bar entry + route.
- Rewrite TraceUsageService CH-only (drop the EsClientFactory
  constructor parameter and the splitProjectsByFlag fallback).

Core ES dir + package:
- Delete src/server/elasticsearch.ts + src/server/elasticsearch/
  (traces, transformers, patchOpensearch, patchQuickwit).
- Delete elastic/ (helpers, mappings, schema, all migrations).
- Remove @elastic/elasticsearch from package.json + lockfile.
- src/components/checks/TryItOut.tsx: drop the
  transformElasticSearchSpanToSpan call; CH spans already arrive in the
  domain Span shape.

Facade collapse:
- Collapse EvaluationService into a single CH-backed class
  (delete clickhouse-evaluation.service.ts, rename methods to throw on
  missing CH client instead of returning null, keep getEvaluationInputs
  nullable for the legitimate empty case).
- Collapse ExperimentRunService into a single CH-backed class
  (delete clickhouse-experiment-run.service.ts, fold
  listRunsForExperimentSlugPaginated into the renamed class).
- Collapse FilterServiceFacade → FilterService into a single CH-backed
  class (delete clickhouse-filter.service.ts).
- Delete SimulationFacade entirely — every consumer (suite router,
  scenario events router, simulation-runs REST, cancellation router,
  onboarding-checks service) now calls getApp().simulations.runs
  directly.

193 files changed, -29.8K LOC. The only remaining BullMQ consumer in
the codebase is the EE governance ingestion puller, which keeps
BullMQ's repeatable-jobs cron primitive until it migrates to event
sourcing (follow-up).

* refactor(context): isolate the BullMQ adapter to the EE governance puller

After the legacy stack removal the only BullMQ-importing file left in
src/server/ was the context adapter at adapters/bullmq.ts, and the only
caller of its withJobContext helper was the EE governance ingestion
puller worker. The other three exports
(createContextFromJobData / getJobContextMetadata / JobDataWithContext)
have no BullMQ dependency — they're pure ALS plumbing.

- Move the pure helpers into src/server/context/core.ts so the
  framework-neutral request-context module no longer touches BullMQ.
- Move withJobContext (the one piece that imports BullMQ's Job type)
  into ee/governance/services/pullers/withJobContext.ts, co-located
  with its only consumer.
- Move the existing withJobContext test cases (new __context shape +
  legacy __payload migration paths) into
  ee/governance/services/pullers/__tests__/withJobContext.unit.test.ts.
- Delete src/server/context/adapters/bullmq.ts and update
  asyncContext.ts to re-export the pure helpers from core only.

Result: BullMQ is now contained entirely in
ee/governance/services/pullers/. The next time the puller migrates to
event sourcing, bullmq and bullmq-otel come out of package.json as a
one-liner with no other callers to migrate.

* fix: restore topic clustering (deleted in error)

Topic clustering was incorrectly bundled into the ES/BullMQ removal in
the parent commit. It is a live feature that:

- Reads from ClickHouse via TraceService (not ES).
- Uses BullMQ only for the worker queue, which is fine — the legacy
  stack removal targeted the per-call collector / evaluations workers,
  not every BullMQ consumer.

Co-locate the queue + worker under src/server/topicClustering/ (rather
than the deleted src/server/background/queues/ + workers/), and re-home
the shared BullMQ helpers (QueueWithFallback + withJobContext) under
src/server/queues/ since they are now used by both the EE governance
puller and topic clustering.

Restored:
- src/server/topicClustering/{topicClustering,types,topicClusteringQueue,topicClusteringWorker}.ts
- src/server/topicClustering/__tests__/* (3 files)
- src/tasks/runTopicClustering.ts
- /api/cron/schedule_topic_clustering handler
- project.triggerTopicClustering tRPC mutation
- /settings/topic-clustering page route + command-bar entry

Wired the topicClusteringWorker into src/workers.ts alongside the EE
ingestion puller.

Test-mock fixes for stale ~/server/background/... imports flagged in
review (3 integration tests + 2 unit tests, plus a now-defunct queues-dir
scan in no-retention-gc.unit.test.ts):
- httpProxyTracing.integration.test: rewrite to mock
  getApp().traces.recordSpan and decode the OTLP span into the legacy
  CollectorJob shape via a small facade — assertion bodies unchanged.
- evaluations.azure-byok.integration.test: drop mocks of long-gone
  runEvaluationJob / startEvaluationsWorker symbols; mock just
  runEvaluationForTrace at its new home.
- span-pii-redaction.service.test + topicClustering tests: update mock
  paths from ~/server/background/... to the new locations.

* fix(lockfile): drop stale @elastic/elasticsearch importer + pin vite

CI was failing on `pnpm install --frozen-lockfile` because the lockfile
still listed @elastic/elasticsearch ~8.15.3 as a direct dep of langwatch/
even though the package.json no longer declares it. The previous rebase
resolution preserved main's lockfile but kept my branch's package.json,
so they drifted.

Surgical fix: remove the @elastic/elasticsearch entry from the importer
block. Orphan package + peer-dep references downstream are inert and
clear out next time someone regenerates the lockfile.

Also pin `vite` to 8.0.10 via overrides. vite 8.0.16 (published
2026-06-01) hasn't aged past the workspace's 7-day minimumReleaseAge
gate yet, so any `--no-frozen-lockfile` run hits the maturity check
trying to resolve vitest's peer. The pin doesn't help today's gate trip
(overrides apply after maturity checks), but it stops future regens from
churning between 8.0.10 and the latest unaged patch.

* fix: restore node:crypto import in httpProxyTracing.ts

The legacy-stack removal commit dropped `import crypto from "node:crypto"`
along with the other imports it was rewriting, but two callers of
`crypto.randomBytes` (line 96/97 — trace_id and span_id generators)
still reference it. Typecheck on the global Web Crypto sees no
`randomBytes` and fails.

* fix: chase test fallout from the ES/BullMQ removal

CI flushed out a pile of stale references that survived the previous
commits because nothing on the happy path touched them.

Production:
- evaluation.service.ts: getEvaluationInputs returns null when no
  ClickHouse client is available (commit message claimed this, code
  threw — restore the documented contract; the eval-inputs panel is
  load-bearing on the trace drawer and a 500 cascade poisons polling).
- httpProxyTracing.ts: don't throw when the project lookup misses —
  fall back to DEFAULT_PII_REDACTION_LEVEL. The tenantId is the
  authoritative project id from the caller; the project row is only
  consulted for the redaction setting and is fine to default.

Test fixes:
- internal-routes-auth.integration.test: repoint at
  /api/cron/old_lambdas_cleanup (the deleted retention-cleanup,
  start_workers, rerun_checks endpoints no longer exist; the kept
  destructive cron exercises the same cronPolicy() wiring).
- metrics.test + metrics.unit.test: vi.mock paths were one level deep
  too far after the collector move from src/server/background/workers/
  to src/server/tracer/. Real getLLMModelCosts then hit Prisma in unit
  tests with no DB.
- experiment-run.service.getRun-null.unit.test: the test was patching
  service.clickHouseService (gone after the facade collapse). Rewrite
  to mock getClickHouseClientForProject so the "client unavailable"
  and "row not yet materialized" cases stay covered.
- experiment-run-dedup-safety + trace-dedup-oom-safety: the structural
  guard read clickhouse-experiment-run.service.ts directly. Point at
  the merged experiment-run.service.ts (clickhouse-trace.service.ts
  still exists — only the experiment-run side collapsed).
- cancellation-event-sourcing.integration.test: vi.mock("~/server/redis")
  was a partial factory that dropped makeQueueName. After the TC
  restoration the test's transitive imports need it; spread
  importOriginal() so the rest of the module passes through.
- httpProxyTracing.integration.test: also mock the relative-path
  spelling of ~/server/app-layer/app so the route's own import is
  intercepted regardless of how vitest canonicalises specifiers.

* fix(build): isolate makeQueueName so the client bundle stops pulling ioredis

The boot-smoke test was failing the entire PR because the simulations
route chunk (`__...path__-XXXXX.js`) tried to evaluate ioredis at
top-level in the browser and tripped `process is not defined`
(via `process.nextTick`).

Root cause: when the legacy stack was removed, `makeQueueName` moved
from `~/server/background/queues/makeQueueName.ts` (a tiny standalone
file) into `~/server/redis.ts`. `~/server/scenarios/scenario.constants.ts`
imports `makeQueueName`, and the SimulationsPage route transitively
pulls in scenario.constants for its queue identifiers. So every
SimulationsPage chunk now dragged the whole `~/server/redis` module
— and ioredis — into the client bundle.

Fix: pull `makeQueueName` back into its own file
(`~/server/queues/makeQueueName.ts`), re-export it from `~/server/redis`
for compatibility, and switch scenario.constants to the dedicated path.
Local `vite build` + `node scripts/smoke-boot.mjs` now reports
"BOOT SMOKE PASSED" with no externalised-node-builtin warnings.

* fix: hoist vi.mock factory + mark orphaned scenarios @unimplemented

httpProxyTracing.integration.test was failing every test ("There was
an error when mocking a module") because the vi.mock factories
referenced a top-level `fakeApp` variable. Vitest hoists vi.mock
above all top-level code, so closures over outer variables fail.
Move the shared mock fn into vi.hoisted() and inline the factories.

feature-parity: 19 orphaned scenarios survived the legacy-stack
removal because the tests that bound them lived under
src/server/background/ (deleted) or referenced ES storage layers
that no longer exist. Tag the orphans @unimplemented so the parity
check stops failing — re-binding them to event-sourcing-backed
tests is follow-up work, not in scope here:

- Redis Cluster compat: 4 scenarios tested BullMQ queue hash-tag
  behaviour. After the legacy-stack removal the only BullMQ users
  are the EE puller + topic clustering; the tests can be rewritten
  against those queues but it's a fresh integration-test surface.
- Extensible scenario metadata: 11 ES-storage-round-trip and
  metadata-namespace scenarios. ES is gone; equivalent ClickHouse
  projection coverage is a follow-up.
- Monitor execution backend: 3 evaluator-settings scenarios that
  were bound to evaluationsWorker.integration.test.ts (gone).
- API endpoint authorization: drop "Worker and ops trigger endpoints
  reject callers..." (the endpoints don't exist) and repoint the
  destructive-cron scenario at the surviving old-lambdas-cleanup
  route.

* fix(topic-clustering): break circular import causing worker boot TDZ

Restoring topic-clustering reintroduced a runtime regression: workers
crashed on boot with `ReferenceError: Cannot access 'TOPIC_CLUSTERING_QUEUE'
before initialization`. The cycle is

    topicClusteringWorker.ts
      -> topicClustering.ts (line 15: clusterTopicsForProject)
      -> topicClusteringQueue.ts (line 14: scheduleTopicClusteringNextPage)
      -> topicClusteringWorker.ts

When workers.ts imports the queue first, queue.ts re-enters worker.ts via
topicClustering.ts. queue.ts line 18 then reads `TOPIC_CLUSTERING_QUEUE.NAME`
while worker.ts is still on its imports — the const is in TDZ.

Extract the queue name + job-type tuple to a leaf
`topicClusteringQueue.constants.ts` with no cyclic edges. Both worker.ts
and queue.ts now import the constant from there; the cycle on
`runTopicClusteringJob` survives but that's a hoisted function declaration,
so its binding is available regardless of which side enters first.

Caught by `make quickstart all-local`. With the fix the worker logs
"topic clustering checks worker registered" and "topic clustering worker
active, waiting for jobs!" cleanly.

* fix(prompts): stop polling traces.getById for failed assistant messages

When a prompt submission errors (e.g. NLP unreachable, provider 500,
upstream rate-limited), the chat renders an ErrorMessage instead of an
AssistantMessage — but TraceMessage was still mounted unconditionally
on every non-streaming assistant turn. That meant every failed
submission also mounted a trace polling component looking up a trace
that was never emitted, producing a chain of 404s on
`api.traces.getById` (retry: 10, exp-backoff to 60s) and a "Maximum
update depth exceeded" React warning in posthog session-replay.

Gate the TraceMessage mount on `!isError`. Errored assistant turns have
no backing trace; the only thing left to render is the ErrorMessage,
which is already shown above.

Verified live against `make quickstart all-local` with no NLP container
(repro path for "LangWatch NLP is unreachable"). Before: 15 console
errors per submit; after: 1 unrelated pre-existing SVG warning.

* chore(evaluations): drop dead imports and string throw in runEvaluation

- remove unused imports left over from the evaluations-worker extraction
  (CostReferenceType, CostType, nanoid, getProtectionsForProject,
  captureException, withScope, createLogger + the unused logger instance)
- throw new Error("trace not found") instead of a bare string

* fix(tests): chase moved modules in tests added on main

- Protections import: ~/server/elasticsearch/protections -> ~/server/traces/protections
- getRun integration test: clickhouse-experiment-run.service was folded
  into experiment-run.service; use ExperimentRunService

* chore: purge remaining Elasticsearch remnants from the audit

- usage metering: drop the dead MeterBackend concept entirely —
  resolveUsageMeter no longer takes clickhouseAvailable or returns a
  backend (nothing ever consumed it; the only non-ClickHouse value was
  the removed Elasticsearch fallback); UsageService and presets wiring
  updated, backend-selection tests removed
- delete stale comments claiming ES is a live backend (org settings
  migration note, experiment-run facade fallback, filter fallback,
  license-enforcement counting source, mapper/type docs, stall
  detection, synthetic error span, llmParameterMap, ee licensing)
- reword legacy snake_case mapper docs that described ES as current

Intentionally kept: history notes, regression guards asserting ES
absence, legacy ESBatchEvaluation/ElasticSearch* shape names, and the
unused Prisma elasticsearch* columns (follow-up migration).

* fix: chase moved cost module in model-cost-span-preview and drop stale UsageService ctor arg

* chore(lint): satisfy biome on files touched by the main merge

Biome's organizeImports ordering shifted after merging main's import
changes; re-sort the affected files. Also drop the dead `internalAuth`
const and collapse two `!x || !x.method()` guards to optional chaining
in misc.ts (biome noUnusedVariables + useOptionalChain).

* fix(rebase): re-integrate main features that landed in rebased-over code

Rebasing onto origin/main dropped three main-side changes that lived in
files this branch deletes/relocates/restored; the merges that carried
them are gone in a linear history, so re-apply them explicitly:

- runEvaluation: port #4729 (unified scoped data privacy) — native
  evaluator short-circuit (isNativeEvaluatorType/executeNativeEvaluation)
  + augmentEvaluationResult(droppedCategories) on both return paths. The
  branch extracted runEvaluation from the pre-#4729 worker, so without
  this the data-privacy augmenter silently vanished from evals.
- topicClustering: take origin/main's topicClustering.ts (+ co-located
  ./topicClusteringQueue import) — the restored copy was a pre-#4653/
  #4828/#4571 snapshot still routing to the removed python langwatch_nlp
  service. Restores nlpgo-only routing (#4653), the single argMax count
  pass (#4828), and the page-fetch read-stream cap (#4571). Align the two
  topicClustering tests with main (langevals mock) + co-located queue path.

collectorWorker's #4729 data-privacy is already applied downstream in the
event-sourcing recordSpanCommand path, so collector.ts (recordSpan) needs
no change.

* fix(rebase): chase main-renamed modules + narrowed APIs after rebase

The dropped main-merges also carried module renames and a signature
narrowing that the branch's deleted/relocated files and main's newer
files now straddle. Re-apply across the affected files so typecheck +
biome pass clean (0 type errors):

- Protections moved out of src/server/elasticsearch/ -> src/server/traces/;
  retarget 10 main-added files (projection, tracesV2, redactAttributes,
  data-privacy tests) that still imported the old path.
- main consolidated the generated zod/type modules: evaluators.zod.generated
  -> evaluators, {tracer,experiments}/types.generated -> {tracer,experiments}/
  types. Retarget the legacy-route files (collector, misc, evaluations-legacy).
- piiCheck relocation background/workers/collector -> tracer/collector:
  retarget the two stray test imports.
- captureException narrowed to Error|string (#2155): wrap the unknown
  catch args via toError() in evaluations-legacy + misc.
- Project.piiRedactionLevel removed by #4729: collector + misc track-event
  spans now pass DEFAULT_PII_REDACTION_LEVEL (the policy is resolved
  downstream in the recordSpan pipeline).

biome --write applied (import sort + optional-chain); one pre-existing
noUnusedVariables warning remains (also present on origin/main).

* fix(evaluations): enrich trace evaluations in runEvaluationForTrace (P1)

The worker→runEvaluation extraction swapped the legacy
getTraceById({ includeEvaluations: true }) for TraceService.getById,
which routes through getTracesWithSpans and does NOT enrich evaluations
(that only happens in the paginated getAllTracesForProject path). The
field mapper reads `trace.evaluations ?? []`, so any evaluator whose
input maps from the `evaluations` source (a prior evaluator's result)
was silently fed [] — a quiet correctness regression with no crash.

Fetch evaluations via TraceService.getEvaluationsMultiple (already mapped
to the legacy Evaluation[] shape the mapper expects) and attach them to
the trace before buildDataForEvaluation, restoring parity with the worker.

Note: this path still has no executing test (its spec is @unimplemented);
a regression test that asserts the `evaluations` source maps real data is
the right follow-up.

* fix(ci): repair rebase regressions in unit + integration tests

Four CI failures, all from the rebase taking older snapshots of branch-only
files or losing main's schema/query evolution:

- evaluations-legacy.ts: restored the branch's working getEvaluatorDataForParams
  (coercedString/coerceEvaluatorScalar preprocessing + the CODE_EVALUATOR_CHECK_PREFIX
  code-evaluator branch were dropped to an older snapshot). Re-applied the
  main-driven module renames + captureException(toError) narrowing on top.
  Fixes evaluator-input-coercion.unit.test.ts (10).
- misc.ts: restored the RoleBinding-aware /mcp/authorize handler (the old
  legacy-TeamUser check had been re-introduced, returning 500/false-403).
  Re-applied renames + toError + DEFAULT_PII_REDACTION_LEVEL. Fixes
  mcp-authorize.rolebinding.unit.test.ts (4).
- experiment-run.service.ts: getRun now uses main's scalar
  UpdatedAt = (SELECT max(UpdatedAt) ...) single-run read instead of the
  IN-tuple. Fixes experiment-run-dedup-safety.unit.test.ts (2).
- httpProxyTracing.ts: drop the now-invalid select { piiRedactionLevel }
  (#4729 removed Project.piiRedactionLevel) that threw PrismaClientValidationError;
  use DEFAULT_PII_REDACTION_LEVEL. Fixes httpProxyTracing.integration.test.ts.

biome --write applied across the rebased diff to clear reviewdog lint.

* fix(p2)+test: stale ES string, document worker boot-ordering, P1 regression test

- settings.tsx: drop the stale "or Elasticsearch" from the org-update error
  toast (the ES config fields were removed).
- workers.ts: document why the puller/topic-clustering worker + queue modules
  are imported via await import() — they construct Redis-connecting
  QueueWithFallback instances at module load, so they must load only after
  verifyRedisReady(). (Not converted to top-level: that would connect to Redis
  before it's verified.)
- runEvaluation.evaluations-enrichment.unit.test.ts: regression test locking the
  P1 fix — an evaluator mapping from the `evaluations` source receives the
  trace's real evaluations (via getEvaluationsMultiple), not a silent []. Verified
  it fails when the enrichment is removed.

* fix(remediation): restore behavior dropped in the ES/BullMQ removal

Verified P0 + behavioral P1s from the PR #4547 audit (the blocker set). Each path regressed because logic was dropped while extracting code out of the deleted BullMQ worker.

- workers.ts: re-wire worker-role boot — scenario execution pool (P0: simulations were silently skipped on workers), worker /metrics listener, anomaly detector, ClickHouse storage-stats collection; add graceful SIGINT/SIGTERM shutdown with handle cleanup.
- runEvaluation.ts: restore code-evaluator routing (code/<id> -> runCodeEvaluator) and the embeddings_model env setup + openai/moderation guard the extracted module lost vs the canonical service.
- evaluation-execution.service.ts: enrich trace.evaluations in executeForTrace before building mapped data (parity with runEvaluationForTrace; the monitor path fed an empty evaluations source).
- routes/collector.ts: route REST ingestion through ingestNormalizedSpan so it shares the (tenant,trace,span) dedup gate + ADR-022 spool with OTLP, instead of calling recordSpan directly.
- tracer/collector: restore unit coverage for the moved span-tree/text-extraction helpers (deleted common.test.ts had no committed replacement).

typecheck clean; 235 evaluation unit tests green.

* refactor(analytics,experiments-v3): remove dead Elasticsearch query and mapper builders

Audit §B6 follow-up to PR #4547. No live callers remain after the ES backends were dropped: generateTracesPivotQueryConditions/generateFilterConditions (analytics/common.ts) and mapEsRunToExperimentRun/mapEsBatchEvaluationToRunWithItems + ESRunAggregationBucket (experiments-v3/mappers.ts). Keeps the still-live mapEsTargetsToTargets (used by routes/evaluations-legacy.ts). Drops the now-orphaned tests and a stale filter-conditions comment.

* docs(specs): drop stale Elasticsearch/BullMQ references from scenario specs

Audit §C follow-up. extensible-scenario-metadata: neutralize/remove the ES-storage scenarios (scenario events persist to ClickHouse via event sourcing). monitor-execution-backend: note evaluationsWorker + its test were removed with the BullMQ stack (monitor evals now run via executeEvaluation.command.ts) and correct results-stored-in-Elasticsearch -> ClickHouse. redis-cluster-compatibility: minor comment wording.

* fix(ui): restore canonical ModelOption import in LlmConfigField

Audit P2 follow-up. Replace the hand-rolled ModelOption type with the import from ~/server/topicClustering/types; topic clustering was restored in this PR, so the canonical type exists.

* fix(lint): satisfy biome organize-imports on PR-changed files

* fix(test): stub traceService.getEvaluationsMultiple in EvaluationExecutionService integration mocks

The executeForTrace evaluations-enrichment (getEvaluationsMultiple, added in 689be85) broke three integration tests whose hand-rolled traceService mocks didn't stub the new method (TypeError: getEvaluationsMultiple is not a function across shards 1/2/5). Stub it to resolve {} (no-op enrichment), mirroring the unit-test mock. typecheck clean.

* fix: address /review-pr re-audit findings (P1 collector + P2 worker hardening)

- collector.ts (P1): REST /api/collector counted ingestion failures via Promise.allSettled 'rejected', but ingestNormalizedSpan catches its own errors and RESOLVES with {status:'failed'} (never rejects) — so rejectedSpans was always 0 and failed spans were reported to the SDK as success with no error log. Now inspects the resolved SpanIngestionResult.status (still treats an unexpected rejection as a failure).

- workers.ts /metrics (P2 security): the worker metrics listener served the full prom-client registry unauthenticated on 0.0.0.0 while the web path is bearer-gated. Extract isMetricsAuthorized into server/metrics.ts and apply it to the worker listener too (fail-closed in prod).

- workers.ts shutdown (P2): gracefulShutdown discarded the topic-clustering + ingestion-puller worker handles and never closed the App; now registers both close handles and closes getApp() (CH/Redis/Prisma) last.

- evaluation-execution.service.unit.test.ts (P2 test): the A4 executeForTrace evaluations-enrichment was untested (every mock stubbed getEvaluationsMultiple to {}); add a test asserting prior evaluations reach the mapped data.

typecheck + biome clean; 208 evaluation unit tests pass.

* chore: drop unused prisma import in httpProxyTracing (codeql/code-quality)

* docs(experiment-run.service): document getRun's deliberate null-on-no-CH (#4547 review)

Per Aryansharma28's review (thread PRRT_kwDOKRXhvM6L6N-6): getRun's null-on-no-CH was a deliberate UX call (PR #3483 dogfood — fold-window polling race) but the class-level JSDoc still claimed all methods return null, and getRun's own JSDoc didn't explain the divergence from listRuns / getRunAggregates / listRunsForExperimentPaginated (which throw). Update the class JSDoc to call out the exception, expand getRun's JSDoc with the rationale + the route caller's intentional 404 mapping, and add an inline comment at the no-CH guard.

* fix(tests): retarget imports from deleted background/collector and elasticsearch/protections (rebase fixup)

PR #4547 moves src/server/background/workers/collector/cost -> src/server/tracer/collector/cost and src/server/elasticsearch/protections -> src/server/traces/protections. Upstream commits landed 3 files that import from the old paths; the rebase onto current main surfaced the dangling imports. Retarget to the new locations.

* fix(evaluations): preserve evaluator-specific extras (pairwise candidate_a_id/output)

Upstream #5142 (pairwise_compare) added a 'preserve evaluator-specific fields' block to background/workers/evaluationsWorker.ts so evaluators that declare extras via required/optional fields (e.g. langevals/pairwise_compare's candidate_a_id, candidate_a_output, candidate_b_id, candidate_b_output) have those fields flow through to langevals via ...extras instead of being stripped by the canonical-6 forward.

This PR extracts runEvaluation out of the deleted worker; without this port, pairwise-compare evaluations silently strip the candidate fields and 422 from langevals. Port the block into runEvaluation.ts (kept path) at the same relative location — just before the stagedLangevalsFetch call — bounded to the evaluator's declared requiredFields ∪ optionalFields so a stray mapping output on a non-pairwise evaluator can't ride through.

* docs(specs): reconcile feature specs with the ES/BullMQ removal (/review-pr audit)

Rewrites scenarios that mandated merging BullMQ jobs with ES scenario
events, restores tags where behaviour ships again with test coverage
(redis-cluster hash-tags, extensible scenario metadata), and drops
implementation-detail phrasing flagged by the audit.

* test: restore coverage lost with the deleted ES/BullMQ test files (/review-pr audit)

Re-homes the deleted suites' assertions against the surviving subjects:
TraceUsageService (incl. the CH-unavailable ?? 0 branch), makeQueueName
hash-tags, metrics auth fail-closed + getWorkerMetricsPort, REST
collector result handling + 429 span cap, googleDLP redaction, stall
derivation, RUN_STARTED metadata pass-through, extensible-metadata
schemas, evaluator-extras allowlist, and monitor settings resolution.

* fix: address /review-pr full-audit findings (P0 spend-spike + P1s)

Restores behaviours silently dropped with the BullMQ stack: the
5-minute spend-spike anomaly evaluation tick, daily self-hosted usage
telemetry, and OTel instrumentation in the worker process. Forwards the
bearer token on the /workers/metrics proxy, returns 500 when every span
in an ingest request fails, fixes googleDLPClearPII discarding its own
redactions, and gates decrypted s3SecretAccessKey behind manage
permission. Deduplicates the track_event route against the shared
service, extracts member-classification helpers, honours traceIds in CH
timeseries, and deletes the dead ES-era helpers, builders, and modules
the audit flagged.

* test(specs): fix @Scenario bindings broken by the ES/BullMQ spec reconciliation

- Retarget five scenario-run-utils annotations to the renamed scenarios
  in unified-run-table.feature (ES/BullMQ wording was rewritten)
- Drop the vacuous 'does not import Elasticsearch' source check; the
  module no longer exists and the spec scenario was removed
- Restore the missing binding for 'Uses cached count within TTL' on the
  trace-usage cache test
- Mark the event_details aggregation-keys scenario @unimplemented: its
  ES binding died with the ES backend and events.event_details is not
  yet supported by the ClickHouse metric translator

* fix(dev): stop build-mcp-server.sh timestamps breaking under FORCE_COLOR

concurrently (pnpm dev) sets FORCE_COLOR, which makes console.log wrap
numbers in ANSI colour codes even when piped — the coloured start
timestamp then got interpolated into the elapsed-time eval and killed
the whole prepare chain with a SyntaxError. Write raw strings instead.

* feat(nlpgo): route xai, groq, cerebras, and deepseek providers

The playground proxy's provider inference only knew the six original
providers, so a default model like xai/grok-4.3 hard-400'd with
missing_provider on /go/proxy/v1/chat/completions. Map the remaining
TS-registry providers on both nlpgo paths (x-litellm headers for the
playground proxy, inline credentials for llmexecutor/Studio):

- xai / groq / cerebras are Bifrost-native and ride the ProviderID
  passthrough with a plain API key
- deepseek is absent from Bifrost's enum; route it through the vLLM
  (openai-compat) adapter defaulting to https://api.deepseek.com
- plain api-key providers share one Generic inline-credentials slot
  instead of four copy-pasted per-provider slots; the duplicated
  provider switches in gatewayproxy are merged into providerForPrefix

Spec: specs/nlp-go/proxy.feature (provider-prefix routing scenarios)

* feat(scenarios): surface typed generation errors from the Go gateway

nlpgo's handled-error envelope (herr) was flattened to a 500 with a
bare string by the scenario-generate route, leaving the browser with
"bad_request" and no way to react. Now:

- nlpgoHandledErrorFrom() parses the envelope off the AI SDK's
  APICallError (unwrapping RetryError) into a DomainError keyed by
  meta.reason, forwarded as domainError on the response
- the browser service throws ScenarioGenerationError carrying
  kind + meta; classifyGenerationError matches typed kinds before
  string sniffing (missing_provider → config tier + configure CTA)
- unknown kinds expand kind/message/meta into the raw-message surface
  instead of showing an opaque status word

* feat(scenarios): show which model Generate uses, with a settings link

Both the scenario editor panel and the AI create modal now render
"Uses <model> · Change" next to their Generate buttons (resolved via
the scenarios.generator feature key), linking to model provider
settings. Config-tier generation failures get a "Model settings"
toast action. AICreateModal grows an optional footerHint slot.

* fix(ui): dark-mode tokens and layout stability on scenario surfaces

- CurrentDrawer: the lazy-drawer Suspense fallback rendered a 200px
  block in page flow, shoving the whole page down while the chunk
  loaded; it is now a fixed overlay where the drawer appears
- SimulationCard: reserve clearance for the floating status pill so it
  no longer covers the last preview lines
- SaveAndRunMenu, Stop buttons (RunRow, ScenarioGridCard), and the
  variable-mapping editor swapped hard-coded light-palette literals
  (blue.50, gray.100, orange.50, bg white, raw gray-500 vars) for
  semantic tokens so dark mode is readable; missing-mapping error uses
  fg.error

* chore: regenerate pnpm lockfile after rebase onto main
sergioestebance added a commit that referenced this pull request Jul 17, 2026
Adds the check that would have stopped the 42 PNGs this PR removes. Fails a
PR that adds an image outside the directories where images legitimately live
(docs/images, docs/media, langwatch/public, assets, specs, python-sdk/examples),
and points the author at the pr-screenshots repo instead.

A path allowlist rather than a reference scan on purpose: "is this image used
anywhere?" reads like the better rule but flags plenty of legitimate docs
images, which are referenced only via the docs site's own path conventions.

Verified against the history it is meant to catch — it fails #5142, #5367 and
#5523, and passes a real docs-image commit (#4796) and this PR's own deletions.
sergioestebance added a commit that referenced this pull request Jul 17, 2026
…#5889)

* chore: remove committed PR screenshots, close the gitignore gap

Three directories of browser-QA screenshots (42 PNGs, ~5.8MB) reached main
across PRs #5142, #5452 and #5523:

  langwatch/.pr-screenshots/    20 PNGs + .gitkeep
  langwatch/docs/pairwise-bugfixes/  10 PNGs
  docs/pr5106/                  12 PNGs

None are referenced by any code, spec, doc or test — they were PR-body
evidence, which by convention lives in the langwatch/pr-screenshots repo and
is linked by raw URL, never committed to the product tree.

Deleting them loses nothing: all three PR bodies link the images by *branch*
name, and those branches were deleted on merge, so every one of those URLs
already 404s today.

langwatch/docs/ held nothing but the artifact dump, so it goes with it.

The .gitignore rule that should have caught this was path-anchored to
`langwatch/pr-screenshots/` and missed the dot-prefixed spelling actually
used. Replaced with `**/pr-screenshots/` + `**/.pr-screenshots/`.

* ci: fail PRs that commit screenshots outside image homes

Adds the check that would have stopped the 42 PNGs this PR removes. Fails a
PR that adds an image outside the directories where images legitimately live
(docs/images, docs/media, langwatch/public, assets, specs, python-sdk/examples),
and points the author at the pr-screenshots repo instead.

A path allowlist rather than a reference scan on purpose: "is this image used
anywhere?" reads like the better rule but flags plenty of legitimate docs
images, which are referenced only via the docs site's own path conventions.

Verified against the history it is meant to catch — it fails #5142, #5367 and
#5523, and passes a real docs-image commit (#4796) and this PR's own deletions.

* ci: fail the image guard loudly on git-diff errors

CodeRabbit: with set -o pipefail, a git diff failure (e.g. a bad BASE_REF)
was swallowed by the || true that exists only to absorb grep's exit-1 on no
match, so the guard silently exited 0 — the one thing a guard must never do.
Split git diff onto its own line so its failure trips set -e; grep keeps its
guarded no-match. Verified: bad ref now exits 128, valid ref still passes.
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.

Pairwise compare should be its own column, not a chip evaluator Native pairwise LLM-as-judge evaluator (V3, built-in) — MVP

2 participants