|
3 | 3 |
|
4 | 4 | from __future__ import annotations |
5 | 5 |
|
| 6 | +import json |
| 7 | +import math |
6 | 8 | from collections.abc import Iterator |
7 | 9 | from pathlib import Path |
8 | 10 |
|
@@ -184,9 +186,11 @@ def test_failed_trials_are_attempts_but_metric_failures_are_unmeasured() -> None |
184 | 186 |
|
185 | 187 |
|
186 | 188 | 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. |
190 | 194 | reward = _Metric("reward", MetricOutputSpec.continuous_score("score")) |
191 | 195 | tasks = [_task("scored", reward), _task("never-ran", reward)] |
192 | 196 | 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( |
223 | 227 | } |
224 | 228 |
|
225 | 229 |
|
| 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 | + |
226 | 274 | def test_without_tasks_there_is_no_spec_to_filter_on() -> None: |
227 | 275 | # No tasks means no declared schemas to consult, so every numeric output observed is retained. |
228 | 276 | 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: |
389 | 437 | ) |
390 | 438 |
|
391 | 439 |
|
| 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 | + |
392 | 452 | def test_gym_example_reads_task_outcomes_from_summary() -> None: |
393 | 453 | from packages.nemo_evaluator_sdk.examples.gym.inspect_results import per_task_attempts, per_task_outcomes |
394 | 454 |
|
|
0 commit comments