Skip to content

feat(evaluations): add client-side evaluation runner - #61

Closed
donei003 wants to merge 26 commits into
mainfrom
pr39-summary-accounted-terminal
Closed

feat(evaluations): add client-side evaluation runner#61
donei003 wants to merge 26 commits into
mainfrom
pr39-summary-accounted-terminal

Conversation

@donei003

@donei003 donei003 commented Aug 31, 2026

Copy link
Copy Markdown

Intent

Update existing PR #39 on its existing head branch devin/1786604824-evaluations-scaffold to fix evaluation summary polling against the real backend. The backend may omit run state, so summary polling should still complete when row counts prove processing is complete: total rows is greater than zero, pending rows is zero, and passed+failed+error rows account for total rows. Preserve protection against empty or malformed summaries: total_rows=0 or missing state with no meaningful row accounting must not be treated as complete. Add focused regression coverage for the real backend shape with no state, total_rows=10, pending_rows=0, and rows accounted by passed/failed/error terminating, while keeping existing coverage for missing-state empty or unaccounted summaries. Validate focused tests and deliver by updating PR 39 rather than opening a separate PR.

What Changed

  • Added the launchdarkly_ai_server.evaluations module (api, module, runner, types), a client-side evaluation runner that lists datasets, resolves handlers and tools, drives generation against provider handlers with a concurrency-controlled worker pool, and emits $ld:ai:offline-evals:generation events for each row (including error payloads).
  • Polled run summaries until terminal, accepting either an explicit terminal state or, when the backend omits state, row-count accounting (total_rows > 0, pending_rows == 0, passed + failed + error == total_rows); empty or unaccounted summaries remain pending so EvalRunResult.passed is only derived from a settled summary.
  • Exposed EvaluationsModule from launchdarkly_ai_server.__init__, refreshed AGENTS.md, CLAUDE.md, and the packages/ai + packages/client READMEs/agents.md for the new API, and added focused coverage in tests/test_evaluations.py and tests/test_evaluations_run.py for the state-omission terminal case and for empty/unaccounted summaries staying pending.

Risk Assessment

✅ Low: Small, well-bounded fix that adds a state-omitted terminal branch guarded by three conjuncts matching the intent verbatim; existing missing-state/unaccounted coverage remains valid and a focused regression test asserts the new path.

Testing

Ran the four focused summary-polling tests in packages/client/tests/test_evaluations_run.py — all pass on the target commit. Confirmed the new regression test truly reproduces the bug by temporarily reverting module.py to dcb08f6; the new test failed there (poll loop kept issuing /summary GETs) and passed again once the fix was restored, exercising both new behavior (state-less + accounted rows terminates) and preserved protections (missing state with no accounting keeps polling; timeout still fires with pending_rows>0). No transient artifacts left in the worktree.

Evidence: Focused summary-polling test transcript (post-fix, 4 passed)

packages/client/tests/test_evaluations_run.py::test_summary_is_polled_until_terminal_state PASSED [ 25%] packages/client/tests/test_evaluations_run.py::test_summary_polling_completes_without_state_when_all_rows_are_accounted PASSED [ 50%] packages/client/tests/test_evaluations_run.py::test_summary_polling_ignores_missing_state_even_when_pending_is_zero PASSED [ 75%] packages/client/tests/test_evaluations_run.py::test_summary_polling_times_out_waiting_for_terminal_state PASSED [100%] == 4 passed, 7 deselected in 0.10s ===

============================= test session starts ==============================
platform darwin -- Python 3.12.9, pytest-9.1.1, pluggy-1.6.0 -- /Users/dylanoneill/.no-mistakes/worktrees/00b1855f4236/01M1D11SPDEQNHJHHJSX609QH5/.venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/dylanoneill/.no-mistakes/worktrees/00b1855f4236/01M1D11SPDEQNHJHHJSX609QH5
configfile: pyproject.toml
plugins: mock-3.15.1, anyio-4.14.1, asyncio-1.4.0, langsmith-0.9.3
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 11 items / 7 deselected / 4 selected

packages/client/tests/test_evaluations_run.py::test_summary_is_polled_until_terminal_state PASSED [ 25%]
packages/client/tests/test_evaluations_run.py::test_summary_polling_completes_without_state_when_all_rows_are_accounted PASSED [ 50%]
packages/client/tests/test_evaluations_run.py::test_summary_polling_ignores_missing_state_even_when_pending_is_zero PASSED [ 75%]
packages/client/tests/test_evaluations_run.py::test_summary_polling_times_out_waiting_for_terminal_state PASSED [100%]

======================= 4 passed, 7 deselected in 0.10s ========================
Evidence: Regression reproduction (pre-fix module.py fails the new test)
With packages/client/src/launchdarkly_ai_server/evaluations/module.py reverted to commit dcb08f6:
FAILED packages/client/tests/test_evaluations_run.py::test_summary_polling_completes_without_state_when_all_rows_are_accounted
AssertionError: unexpected request: GET https://app.launchdarkly.com/api/v2/projects/proj/evaluations/evaluation-id/runs/run-id/summary
(Restored module.py to c84894c afterward; git status --short is clean.)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • uv run pytest packages/client/tests/test_evaluations_run.py -k 'test_summary_polling_completes_without_state_when_all_rows_are_accounted or test_summary_polling_ignores_missing_state_even_when_pending_is_zero or test_summary_is_polled_until_terminal_state or test_summary_polling_times_out_waiting_for_terminal_state' -v — 4 passed
  • Regression reproduction: reverted packages/client/src/launchdarkly_ai_server/evaluations/module.py to commit dcb08f6 (pre-fix _is_terminal_summary that returned False when state is None) and re-ran the new test; it failed with AssertionError: unexpected request: GET .../summary, proving the fix is load-bearing. Restored the file to the target commit and reconfirmed the clean worktree with git status --short.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.


Note

Overview
Adds init_evaluations and EvaluationsModule.run() so apps can run generation-only offline evals in-process: resolve datasets and tools via the management API (LD_API_TOKEN, separate LD_API_BASE_URI / LD_UI_BASE_URI), create evaluation + API-source run, invoke a provider handler per row (no handler retries), optionally emit $ld:ai:offline-evals:generation events through the SDK, flush, then poll /summary until the run is settled.

Summary polling now finishes when the API returns a terminal state, or—when state is omitted—when total_rows > 0, pending_rows == 0, and passed + failed + error rows equal total_rows; empty or unaccounted summaries keep polling (3-minute timeout). EvalRunResult.passed is true only when error and pending counts are both zero (failed assertion rows do not fail the harness result).

Public exports, README/agent docs, CLAUDE.mdAGENTS.md, and tests cover API client retry rules, full run orchestration, and the stateless summary regression cases.

Reviewed by Cursor Bugbot for commit 4c79090. Bugbot is set up for automated code reviews on this repo. Configure here.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +111 to +112
if self._sdk_key:
client = await self._resolve_client()

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.

🔴 Keyless evaluation runs always stall

Without an SDK key, run executes every row but sends no results. Summary polling then times out after the provider work completes.

Prompt for agents
EvaluationsModule.run creates an API-source run with a nonzero rowCount, but the only result-delivery path is the LaunchDarkly SDK custom event transport. When sdk_key is absent, it still invokes every handler and then polls a summary that cannot account for those rows. Either require an SDK key before creating records or invoking handlers, or add a supported result-ingestion path for keyless runs. Update the public initialization contract and tests to match the chosen behavior.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

f"{_segment(evaluation.id)}/runs/{_segment(evaluation_run.id)}"
)
return EvalRunResult(
passed=(summary.error_rows == 0 and summary.pending_rows == 0),

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.

🔴 Failed runs report success

A terminal FAILED, ERROR, or canceled summary with zeroed counts produces passed=True. CI consumers then accept an unsuccessful evaluation.

Prompt for agents
EvaluationsModule._is_terminal_summary treats FAILED, ERROR, CANCELED, and CANCELLED as terminal, but EvaluationsModule.run derives passed only from error_rows and pending_rows. Terminal failure summaries can omit counts or return zeros, making the result pass. Derive success from both the terminal state and settled row counts, or raise EvaluationsError for unsuccessful terminal states. Preserve the intentionally documented treatment of failed_rows if that distinction is part of the evaluations contract, and add tests for each failure terminal state.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@donei003

Copy link
Copy Markdown
Author

Closing duplicate PR opened from validation lane; the same commits have been pushed to the existing target PR #39.

@donei003 donei003 closed this Aug 31, 2026
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.

1 participant