Skip to content

Commit 328f40d

Browse files
committed
chore: cleanup
Signed-off-by: Nick Goncharenko <ngoncharenko@nvidia.com>
1 parent 8193be9 commit 328f40d

4 files changed

Lines changed: 139 additions & 27 deletions

File tree

packages/nemo_evaluator_sdk/examples/gym/inspect_results.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,20 @@ def per_task_attempts(
103103

104104

105105
def load_bundle(bundle: Path) -> AgentEvalSummary:
106-
"""Load the persisted summary, including native and runner aggregates and per-task attempts."""
107-
return AgentEvalSummary.model_validate(json.loads((bundle / "summary.json").read_text(encoding="utf-8")))
106+
"""Load the persisted summary, including native and runner aggregates and per-task attempts.
107+
108+
Rejects a bundle written before ``task_metric_attempts`` existed rather than reading one. The
109+
field defaults to empty, so an older bundle would otherwise load cleanly and simply show no
110+
per-task section — the reader would conclude the run had no per-task outcomes rather than that
111+
this script cannot see them.
112+
"""
113+
payload = json.loads((bundle / "summary.json").read_text(encoding="utf-8"))
114+
if "task_metric_attempts" not in payload:
115+
raise SystemExit(
116+
f"{bundle / 'summary.json'} predates summary.task_metric_attempts, which this script reads "
117+
"per-task outcomes from. Re-run the eval to produce a current bundle."
118+
)
119+
return AgentEvalSummary.model_validate(payload)
108120

109121

110122
# --------------------------------------------------------------------------------------------------

packages/nemo_evaluator_sdk/src/nemo_evaluator_sdk/agent_eval/results.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
serialize_value,
3535
summary_aggregate_record,
3636
)
37-
from pydantic import BaseModel, ConfigDict, Field
37+
from pydantic import BaseModel, ConfigDict, Field, field_serializer
3838

3939
#: Metric-output value schemas retained in the ordered per-task attempt mapping.
4040
_TASK_METRIC_VALUE_SCHEMAS = (ContinuousScore, DiscreteScore, BooleanValue)
@@ -86,6 +86,20 @@ class AgentEvalAttemptValue(BaseModel):
8686
),
8787
)
8888

89+
@field_serializer("value")
90+
def serialize_nan(self, value: float | None) -> float | str | None:
91+
"""Emit NaN as the string ``"NaN"``, matching :class:`MetricOutput`.
92+
93+
A metric may legitimately score an attempt NaN, and this is the first summary field to carry
94+
a raw metric value rather than a filtered aggregate. ``json.dumps`` would write it as a bare
95+
``NaN`` token, which is valid Python but not valid JSON, so any strict reader of
96+
``summary.json`` would reject the whole bundle. Pydantic coerces the string back to a float
97+
on load, so the round trip is lossless.
98+
"""
99+
if isinstance(value, float) and math.isnan(value):
100+
return "NaN"
101+
return value
102+
89103

90104
class AgentEvalSummary(BaseModel):
91105
"""Aggregated scores, coverage, per-task attempt values, and run counts for an agent-eval run."""
@@ -744,9 +758,10 @@ def _task_metric_attempts(
744758
reuses one costs pass@k nothing.
745759
"""
746760
output_keys: dict[str, set[tuple[str, str]]] = {}
747-
# Declared under a schema this mapping does not retain. Tracked so an emitted numeric value cannot
748-
# add back what the spec filter just excluded.
749-
excluded: set[tuple[str, str]] = set()
761+
# Per task, the outputs it declared under a schema this mapping does not retain. Tracked so an
762+
# emitted numeric value cannot add back what that task's spec filter just excluded -- and keyed by
763+
# task because tasks in one run need not declare the same output under the same schema.
764+
excluded: dict[str, set[tuple[str, str]]] = {}
750765
if tasks is not None:
751766
for task in tasks:
752767
task_keys = output_keys.setdefault(task.id, set())
@@ -756,16 +771,17 @@ def _task_metric_attempts(
756771
if issubclass(spec.value_schema, _TASK_METRIC_VALUE_SCHEMAS):
757772
task_keys.add((metric_type, spec.name))
758773
else:
759-
excluded.add((metric_type, spec.name))
774+
excluded.setdefault(task.id, set()).add((metric_type, spec.name))
760775

761776
scores_by_task_metric: dict[tuple[str, str], list[AgentEvalTaskScore]] = {}
762777
for score in scores:
763778
scores_by_task_metric.setdefault((score.task_id, score.metric_type), []).append(score)
764779
task_keys = output_keys.setdefault(score.task_id, set())
765780
if score.status not in (AgentEvalScoreStatus.COMPLETED, AgentEvalScoreStatus.PARTIAL):
766781
continue
782+
task_excluded = excluded.get(score.task_id, frozenset())
767783
for output in score.outputs:
768-
if (score.metric_type, output.name) in excluded:
784+
if (score.metric_type, output.name) in task_excluded:
769785
continue
770786
if _semantic_value(output) is not None:
771787
task_keys.add((score.metric_type, output.name))
@@ -809,11 +825,15 @@ def _task_pass_at_k_scores(
809825
denominator is never silent. (Tasks excluded from a given ``k`` merely for having fewer than ``k``
810826
attempts are *not* counted there — that is the estimator working as defined, not missing data.)
811827
812-
"No usable attempt" includes a task that was never scored at all: a runner that returns no trial
813-
for a requested task (Harbor logs a warning and carries on) leaves it declaring the metric with an
814-
empty attempt list, and it lands in ``nan_count`` like any other unmeasured task. That is
815-
deliberate — it is the same missing coverage whether the trial died or was never produced, and
816-
excluding it would report pass@k over a denominator quietly smaller than the task set asked for.
828+
"No usable attempt" includes a task that was never scored at all: it declares the metric, holds an
829+
empty attempt list, and lands in ``nan_count`` like any other unmeasured task. That is the same
830+
missing coverage whether the trial died or was never produced, and excluding it would report
831+
pass@k over a denominator quietly smaller than the task set asked for.
832+
833+
Note this is reachable only through :meth:`AgentEvalSummary.from_scores` called directly with a
834+
task list wider than the scores — a caller re-aggregating a subset, say. A full run cannot get
835+
here: :meth:`AgentEvaluator._score_trials` refuses to score at all when a task produced no trial,
836+
so a runner that drops one fails the run rather than reporting it as missing coverage.
817837
"""
818838
scorelike = _scorelike_outputs(tasks)
819839
if not scorelike:

packages/nemo_evaluator_sdk/tests/agent_eval/test_task_metric_attempts.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from __future__ import annotations
55

6+
import json
7+
import math
68
from collections.abc import Iterator
79
from pathlib import Path
810

@@ -184,9 +186,11 @@ def test_failed_trials_are_attempts_but_metric_failures_are_unmeasured() -> None
184186

185187

186188
def test_a_task_that_produced_no_trial_is_unmeasured_and_counted_in_pass_at_k_nan() -> None:
187-
# A runner may return no trial at all for a requested task (Harbor warns and carries on). The task
188-
# still declares the metric, so it holds an empty attempt list and counts as missing coverage --
189-
# excluding it would report pass@k over a denominator smaller than the task set that was asked for.
189+
# from_scores can be handed a task list wider than the scores -- a caller re-aggregating a subset.
190+
# The task still declares the metric, so it holds an empty attempt list and counts as missing
191+
# coverage: excluding it would report pass@k over a denominator smaller than the task set asked
192+
# for. A full run cannot reach this state; AgentEvaluator._score_trials refuses to score when a
193+
# task produced no trial, which test_evaluator.py::test_run_rejects_tasks_without_trials pins.
190194
reward = _Metric("reward", MetricOutputSpec.continuous_score("score"))
191195
tasks = [_task("scored", reward), _task("never-ran", reward)]
192196
scores = [_score("scored", "attempt-0", "reward", "score", 1.0)]
@@ -223,6 +227,50 @@ def test_outputs_declared_under_an_unretained_schema_stay_out_even_when_numeric(
223227
}
224228

225229

230+
def test_one_tasks_schema_exclusion_does_not_suppress_another_tasks_output() -> None:
231+
# The spec filter is per task: tasks in one run need not declare the same output under the same
232+
# schema. Task-a declaring usage.prompt_tokens as a free model must not strip it from task-b,
233+
# which never declared it and whose only evidence is the numeric value it actually emitted.
234+
tasks = [
235+
_task("task-a", _Metric("usage", MetricOutputSpec.model("prompt_tokens", _TokenCount))),
236+
_task("task-b", _Metric("reward", MetricOutputSpec.continuous_score("score"))),
237+
]
238+
scores = [
239+
_score("task-a", "attempt-0", "usage", "prompt_tokens", 100),
240+
_score("task-b", "attempt-0", "reward", "score", 1.0),
241+
_score("task-b", "attempt-0", "usage", "prompt_tokens", 250), # undeclared on task-b
242+
]
243+
244+
attempts = AgentEvalSummary.from_scores(scores, tasks=tasks).task_metric_attempts
245+
246+
# task-a declared it under an unretained schema, so it is not a key there at all -- not even an
247+
# empty one -- and the numeric value it emitted cannot add it back.
248+
assert attempts["task-a"] == {}
249+
# task-b never declared it, so its emitted numeric value is the only evidence and it is kept.
250+
assert sorted(attempts["task-b"]) == ["reward.score", "usage.prompt_tokens"]
251+
assert _pairs(attempts)["task-b"]["usage.prompt_tokens"] == [("attempt-0", 250.0)]
252+
253+
254+
def test_nan_attempt_values_survive_json_as_a_string() -> None:
255+
# A metric may legitimately score NaN. json.dumps would write a bare NaN token, which is not
256+
# valid JSON, so summary.json must carry the string form -- and read it back as a float.
257+
tasks = [_task("task-a", _Metric("reward", MetricOutputSpec.continuous_score("score")))]
258+
summary = AgentEvalSummary.from_scores(
259+
[_score("task-a", "attempt-0", "reward", "score", float("nan"))], tasks=tasks
260+
)
261+
262+
payload = summary.model_dump(mode="json")
263+
assert payload["task_metric_attempts"]["task-a"]["reward.score"][0]["value"] == "NaN"
264+
265+
# Strict JSON: no bare NaN/Infinity tokens anywhere in the serialized bundle.
266+
def _reject(constant: str) -> float:
267+
raise AssertionError(f"summary.json contains a bare {constant} token")
268+
269+
reloaded = json.loads(json.dumps(payload), parse_constant=_reject)
270+
value = AgentEvalSummary.model_validate(reloaded).task_metric_attempts["task-a"]["reward.score"][0].value
271+
assert value is not None and math.isnan(value)
272+
273+
226274
def test_without_tasks_there_is_no_spec_to_filter_on() -> None:
227275
# No tasks means no declared schemas to consult, so every numeric output observed is retained.
228276
scores = [_score("task-a", "attempt-0", "usage", "prompt_tokens", 1234)]
@@ -389,6 +437,18 @@ def test_vendored_results_module_is_a_verbatim_copy_of_this_one() -> None:
389437
)
390438

391439

440+
def test_gym_example_rejects_a_bundle_written_before_task_metric_attempts(tmp_path: Path) -> None:
441+
# The field defaults to empty, so an older bundle would load cleanly and simply show no per-task
442+
# section -- a reader would take that as "no per-task outcomes" rather than "this script cannot
443+
# see them". Fail with a version message instead.
444+
from packages.nemo_evaluator_sdk.examples.gym.inspect_results import load_bundle
445+
446+
(tmp_path / "summary.json").write_text(json.dumps({"task_count": 2}), encoding="utf-8")
447+
448+
with pytest.raises(SystemExit, match="predates summary.task_metric_attempts"):
449+
load_bundle(tmp_path)
450+
451+
392452
def test_gym_example_reads_task_outcomes_from_summary() -> None:
393453
from packages.nemo_evaluator_sdk.examples.gym.inspect_results import per_task_attempts, per_task_outcomes
394454

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

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

0 commit comments

Comments
 (0)