Skip to content

Commit ce2e986

Browse files
committed
fix(evaluator): skip agent-eval preflight for metrics with no scoreable trial
Failed trials short-circuit to a failed score without invoking the metric, but preflight ran for every metric regardless. A run whose trials all failed therefore probed the judge endpoint for nothing, and could abort outright: preflight resolves the judge model, which raises for an unresolved reference. Importing a batch of failed trials returned an exception instead of failed scores. Preflight a metric only when one of its tasks has a trial that will be scored. Signed-off-by: Sandy Chapman <schapman@nvidia.com>
1 parent ba8f3fe commit ce2e986

3 files changed

Lines changed: 88 additions & 10 deletions

File tree

packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/evaluator.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import asyncio
1111
import uuid
1212
from collections import defaultdict
13-
from collections.abc import Awaitable, Callable, Sequence
13+
from collections.abc import Awaitable, Callable, Mapping, Sequence
1414
from datetime import UTC, datetime
1515
from importlib.metadata import PackageNotFoundError
1616
from importlib.metadata import version as package_version
@@ -245,7 +245,7 @@ async def _score_trials(
245245
if not task.metrics:
246246
raise ValueError(f"task {task.id!r} does not declare any metrics")
247247

248-
await _preflight_task_metrics(tasks)
248+
await _preflight_task_metrics(tasks, trials_by_task)
249249

250250
semaphore = asyncio.Semaphore(config.parallelism)
251251

@@ -386,14 +386,22 @@ async def generate_one(index: int, task: AgentEvalTask) -> AgentEvalTrial:
386386
await close_client()
387387

388388

389-
async def _preflight_task_metrics(tasks: Sequence[AgentEvalTask]) -> None:
390-
"""Run each distinct metric's preflight once.
389+
async def _preflight_task_metrics(
390+
tasks: Sequence[AgentEvalTask],
391+
trials_by_task: Mapping[str, Sequence[AgentEvalTrial]],
392+
) -> None:
393+
"""Run each distinct metric's preflight once, skipping metrics with nothing to score.
391394
392395
Agent-eval scores metrics directly rather than through ``prepare_metric_for_execution``, so
393-
nothing else runs their preflight.
396+
nothing else runs their preflight. Failed trials short-circuit to a failed score without
397+
invoking the metric, so a task whose trials all failed must not trigger one: preflight resolves
398+
the judge endpoint, making it both a wasted request and a way for the run to abort.
394399
"""
395400
preflighted: set[int] = set()
396401
for task in tasks:
402+
scoreable = any(trial.status != AgentEvalTrialStatus.FAILED for trial in trials_by_task.get(task.id, ()))
403+
if not scoreable:
404+
continue
397405
for metric in task.metrics:
398406
if isinstance(metric, MetricWithPreflight) and id(metric) not in preflighted:
399407
preflighted.add(id(metric))

packages/nemo_evaluator_sdk/tests/agent_eval/test_evaluator.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,3 +972,65 @@ async def test_run_preflights_each_metric_once_across_tasks() -> None:
972972
)
973973

974974
assert metric.preflight_calls == 1
975+
976+
977+
class _ProbingMetric(_ConstantMetric):
978+
"""Metric whose preflight probes a remote endpoint, as an LLM judge's does."""
979+
980+
preflight_calls: int = 0
981+
982+
async def preflight(self) -> None:
983+
self.preflight_calls += 1
984+
raise RuntimeError("endpoint probe failed")
985+
986+
987+
def _failed_trial(task_id: str = "task-1", trial_id: str = "trial-1") -> AgentEvalTrial:
988+
return AgentEvalTrial(
989+
id=trial_id,
990+
task_id=task_id,
991+
status=AgentEvalTrialStatus.FAILED,
992+
output=AgentOutput(output_text=""),
993+
)
994+
995+
996+
@pytest.mark.asyncio
997+
async def test_failed_trials_are_scored_without_preflighting_their_metric() -> None:
998+
"""A run whose trials all failed must not probe the judge endpoint.
999+
1000+
Failed trials short-circuit to a failed score without invoking the metric, so preflighting is
1001+
both a wasted remote call and a new way for the run to abort: preflight resolves the judge
1002+
model, and that raises for an unresolved reference. Importing a batch of failed trials must
1003+
still yield failed scores rather than an exception.
1004+
"""
1005+
metric = _ProbingMetric()
1006+
1007+
result = await AgentEvaluator().run(
1008+
tasks=[_task(metric)],
1009+
trials=[_failed_trial()],
1010+
config=AgentEvalRunConfig(parallelism=1),
1011+
)
1012+
1013+
assert metric.preflight_calls == 0
1014+
assert [score.status for score in result.scores] == [AgentEvalScoreStatus.FAILED]
1015+
1016+
1017+
@pytest.mark.asyncio
1018+
async def test_metric_is_preflighted_when_any_trial_is_scoreable() -> None:
1019+
"""One completed trial is enough to need the endpoint resolved."""
1020+
metric = _PreflightCountingMetric()
1021+
1022+
await AgentEvaluator().run(
1023+
tasks=[_task(metric, task_id="task-1"), _task(metric, task_id="task-2")],
1024+
trials=[
1025+
_failed_trial(task_id="task-1", trial_id="trial-1"),
1026+
AgentEvalTrial(
1027+
id="trial-2",
1028+
task_id="task-2",
1029+
status=AgentEvalTrialStatus.COMPLETED,
1030+
output=AgentOutput(output_text="Candidate answer"),
1031+
),
1032+
],
1033+
config=AgentEvalRunConfig(parallelism=1),
1034+
)
1035+
1036+
assert metric.preflight_calls == 1

sdk/python/nemo-platform/src/nemo_platform/beta/evaluator/agent_eval/evaluator.py

Lines changed: 13 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)