Summary
A scorer that returns Score(value=[float("nan"), 1.0]) raises an error in the log-event validation step. After all samples complete, log-event validation fails and the eval finishes with status="error" and no results, reporting an opaque wall of pydantic errors (every event type in the union rejected). The identical value shaped as a dict ({"a": float("nan"), "b": 1.0}) works fine.
Found while stress-testing NaN handling around #4458/#4286. #3796 established NaN-at-root as the unscored sentinel for scalar, dict, and list-valued scorers — this is the one NaN placement that slips through: inside a list.
Reproduction
from inspect_ai import Task, eval
from inspect_ai.dataset import Sample
from inspect_ai.scorer import Score, Target, accuracy, scorer
from inspect_ai.solver import solver
@solver
def passthrough():
async def solve(state, generate):
state.output.completion = "done"
return state
return solve
@scorer(metrics=[accuracy()])
def listy():
async def score(state, target):
return Score(value=[float("nan"), 1.0])
return score
log = eval(
Task(dataset=[Sample(input="q", target="x")], solver=passthrough(), scorer=listy()),
model="mockllm/model",
display="none",
)[0]
print(log.status) # "error"
print(log.results) # None
print(log.error.message.splitlines()[0])
# 75 validation errors for list[union[SampleInitEvent,SampleLimitEvent,...]]
Reproduced on 0.3.232; the type definitions involved are unchanged on current main.
Root cause
The failure point (from log.error.traceback) is sample logging, not scoring:
_eval/task/run.py log_sample
log/_recorders/streaming.py materialize_streaming_sample
event/_validate.py validate_events # TypeAdapter(list[Event]).validate_python
Three facts compose:
- Pydantic's JSON serialization dumps
NaN as null (same for root, dict, and list placements — verified: ScoreEvent(score=Score(value=[nan, 1.0]), ...).model_dump_json() contains "value":[null,1.0]). The sample's events pass through this serialization on their way into the streaming sample buffer.
Value's branches are asymmetric (scorer/_metric.py): Mapping[str, str | int | float | bool | None] admits None, but Sequence[str | int | float | bool] does not.
- When the completed sample is materialized from the buffer for logging,
validate_events re-validates the buffered event dicts. The ScoreEvent now carries "value": [null, 1.0], the Sequence branch rejects None, and because validation tries the dict against all 23 event types in the union, the single bad event reports as 75 unrelated-looking errors. The dict shape survives the same round-trip ({"a": null} validates against the Mapping branch), which is why only the list placement crashes.
The trap for scorer authors: Score(value=[None, 1.0]) fails fast at construction with a clear message, but NaN is a perfectly valid float at construction time — it only becomes an invalid null at the serialization boundary, so the failure is deferred to end-of-run and the diagnostic points nowhere near the scorer.
Suggested fix
The minimal, symmetric fix is to align the Sequence branch with the Mapping branch: Sequence[str | int | float | bool | None]. That makes the round-trip lossy-but-stable (NaN → null), matching what the dict shape already does.
If NaN-in-container is instead considered invalid (as distinct from the sanctioned NaN-at-root sentinel from #3796), the alternative is to reject it at Score construction with a targeted error message — anything is better than the current deferred union-validation dump with loss of the whole run's results.
Related
Summary
A scorer that returns
Score(value=[float("nan"), 1.0])raises an error in the log-event validation step. After all samples complete, log-event validation fails and the eval finishes withstatus="error"and no results, reporting an opaque wall of pydantic errors (every event type in the union rejected). The identical value shaped as a dict ({"a": float("nan"), "b": 1.0}) works fine.Found while stress-testing NaN handling around #4458/#4286. #3796 established NaN-at-root as the unscored sentinel for scalar, dict, and list-valued scorers — this is the one NaN placement that slips through: inside a list.
Reproduction
Reproduced on 0.3.232; the type definitions involved are unchanged on current
main.Root cause
The failure point (from
log.error.traceback) is sample logging, not scoring:Three facts compose:
NaNasnull(same for root, dict, and list placements — verified:ScoreEvent(score=Score(value=[nan, 1.0]), ...).model_dump_json()contains"value":[null,1.0]). The sample's events pass through this serialization on their way into the streaming sample buffer.Value's branches are asymmetric (scorer/_metric.py):Mapping[str, str | int | float | bool | None]admitsNone, butSequence[str | int | float | bool]does not.validate_eventsre-validates the buffered event dicts. TheScoreEventnow carries"value": [null, 1.0], theSequencebranch rejectsNone, and because validation tries the dict against all 23 event types in the union, the single bad event reports as 75 unrelated-looking errors. The dict shape survives the same round-trip ({"a": null}validates against theMappingbranch), which is why only the list placement crashes.The trap for scorer authors:
Score(value=[None, 1.0])fails fast at construction with a clear message, but NaN is a perfectly validfloatat construction time — it only becomes an invalidnullat the serialization boundary, so the failure is deferred to end-of-run and the diagnostic points nowhere near the scorer.Suggested fix
The minimal, symmetric fix is to align the
Sequencebranch with theMappingbranch:Sequence[str | int | float | bool | None]. That makes the round-trip lossy-but-stable (NaN →null), matching what the dict shape already does.If NaN-in-container is instead considered invalid (as distinct from the sanctioned NaN-at-root sentinel from #3796), the alternative is to reject it at
Scoreconstruction with a targeted error message — anything is better than the current deferred union-validation dump with loss of the whole run's results.Related