Skip to content

Commit 1fcc67c

Browse files
committed
fix(inspect): return None on skip instead of Score.unscored()
Score.unscored() records a NaN value. Custom report renderers (e.g. the edu-panda-skill-harness eval report) that normalize scores via isinstance(v, float) treat that NaN as a real 0–1 score and average it into the mean, poisoning the whole scorer column to NaN. Returning None omits the sample from this scorer's results entirely — handled cleanly by every Inspect metric and by naive renderers — and matches the skip convention used by the harness's other scorers (and rubric_judge). None is a fully-supported Scorer return per the Scorer protocol (-> Score | None). Applies to all three skip/error paths: missing/invalid target_grade, no text, and transient API/parse errors. Tests updated to assert None.
1 parent a438dd2 commit 1fcc67c

2 files changed

Lines changed: 29 additions & 47 deletions

File tree

integrations/inspect-python/src/learning_commons_inspect_scorers/gla.py

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ def gla_scorer(
7878
the target also counts as a pass. Set to ``False`` for an
7979
exact match.
8080
81-
Returns ``Score.unscored()`` when ``target_grade_key`` is absent or not a valid
82-
grade band, when no text is available to evaluate, or when a transient API/parse
83-
error occurs. Re-raises ``ConfigurationError`` and ``InputValidationError`` as
84-
task-level failures.
81+
Returns ``None`` (skip — the sample is omitted from this scorer's results) when
82+
``target_grade_key`` is absent or not a valid grade band, when no text is
83+
available to evaluate, or when a transient API/parse error occurs. Re-raises
84+
``ConfigurationError`` and ``InputValidationError`` as task-level failures.
8585
8686
``Score.value`` is ``CORRECT`` (pass) or ``INCORRECT`` (fail).
8787
``Score.metadata`` contains ``gla_grade``, ``target_grade``, ``alternative_grade``,
@@ -98,35 +98,24 @@ def gla_scorer(
9898
get_text = _artifact_text if text_source == "artifacts" else _completion_text
9999

100100
async def score(state: TaskState, target: Target) -> Score | None:
101+
# Skip paths return None (not Score.unscored()): None omits the sample from
102+
# this scorer's results entirely, which every Inspect metric — and custom
103+
# report renderers — handle cleanly. Score.unscored() records a NaN value
104+
# that naive renderers can mistake for a real score and average into the mean.
101105
target_grade: str = (state.metadata.get(target_grade_key) or "").strip()
102106
if not target_grade or target_grade not in GRADE_BANDS:
103-
return Score.unscored(
104-
explanation=(
105-
f"{target_grade_key!r} is missing or not a valid grade band. "
106-
f"Valid bands: {', '.join(GRADE_BANDS)}"
107-
),
108-
metadata={
109-
"target_grade_key": target_grade_key,
110-
"target_grade": target_grade or None,
111-
},
112-
)
107+
return None
113108

114109
text = get_text(state)
115110
if not text.strip():
116-
return Score.unscored(
117-
explanation="No text to evaluate (empty completion or no readable artifacts).",
118-
metadata={"target_grade": target_grade, "gla_grade": None},
119-
)
111+
return None
120112

121113
try:
122114
result = await evaluator.evaluate(GradeLevelAppropriatenessEvaluationInput(text=text))
123115
except (ConfigurationError, InputValidationError):
124116
raise # setup/programming errors — let Inspect surface them as task failures
125-
except APIError as exc: # OutputValidationError is a subclass of APIError
126-
return Score.unscored(
127-
explanation=f"GLA evaluation failed: {exc}",
128-
metadata={"target_grade": target_grade, "gla_grade": None},
129-
)
117+
except APIError: # OutputValidationError is a subclass; transient grading failure → skip
118+
return None
130119

131120
gla_grade: str = result.answer.score
132121
passed = gla_grade in _acceptable_bands(target_grade, allow_adjacent)

integrations/inspect-python/tests/test_gla_scorer.py

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import math
65
from pathlib import Path
76
from unittest.mock import AsyncMock, MagicMock, patch
87

@@ -140,31 +139,26 @@ async def test_boundary_11ccr_adjacent_passes(self):
140139
state = _make_state(completion="Sample.", metadata={"target_grade": "11-CCR"})
141140
assert (await scorer_fn(state, _make_target())).value == CORRECT
142141

143-
async def test_missing_target_grade_returns_unscored(self):
142+
async def test_missing_target_grade_returns_none(self):
144143
scorer_fn, _ = _make_scorer()
145144
state = _make_state(completion="Sample.", metadata={})
146-
score = await scorer_fn(state, _make_target())
147-
assert math.isnan(float(score.value))
145+
assert await scorer_fn(state, _make_target()) is None
148146

149-
async def test_invalid_target_grade_returns_unscored(self):
147+
async def test_invalid_target_grade_returns_none(self):
150148
scorer_fn, _ = _make_scorer()
151149
state = _make_state(completion="Sample.", metadata={"target_grade": "Grade 5"})
152-
score = await scorer_fn(state, _make_target())
153-
assert math.isnan(float(score.value))
150+
assert await scorer_fn(state, _make_target()) is None
154151

155-
async def test_empty_completion_returns_unscored(self):
152+
async def test_empty_completion_returns_none(self):
156153
scorer_fn, mock_evaluator = _make_scorer()
157154
state = _make_state(completion="", metadata={"target_grade": "6-8"})
158-
score = await scorer_fn(state, _make_target())
159-
assert math.isnan(float(score.value))
155+
assert await scorer_fn(state, _make_target()) is None
160156
mock_evaluator.evaluate.assert_not_called()
161157

162-
async def test_api_error_returns_unscored(self):
158+
async def test_api_error_returns_none(self):
163159
scorer_fn, _ = _make_scorer(side_effect=APIError("rate limit"))
164160
state = _make_state(completion="Sample.", metadata={"target_grade": "6-8"})
165-
score = await scorer_fn(state, _make_target())
166-
assert math.isnan(float(score.value))
167-
assert "rate limit" in score.explanation
161+
assert await scorer_fn(state, _make_target()) is None
168162

169163
async def test_configuration_error_propagates(self):
170164
scorer_fn, _ = _make_scorer(side_effect=ConfigurationError("no key"))
@@ -186,11 +180,10 @@ async def test_custom_target_grade_key(self):
186180
state = _make_state(completion="Sample.", metadata={"expected_grade": "6-8"})
187181
assert (await scorer_fn(state, _make_target())).value == CORRECT
188182

189-
async def test_custom_target_grade_key_absent_returns_unscored(self):
183+
async def test_custom_target_grade_key_absent_returns_none(self):
190184
scorer_fn, _ = _make_scorer(target_grade_key="expected_grade")
191185
state = _make_state(completion="Sample.", metadata={"target_grade": "6-8"})
192-
score = await scorer_fn(state, _make_target())
193-
assert math.isnan(float(score.value))
186+
assert await scorer_fn(state, _make_target()) is None
194187

195188
async def test_custom_grader_model_passed_to_adapter(self):
196189
with (
@@ -221,16 +214,15 @@ async def test_reads_artifact_files(self, tmp_path: Path):
221214
called_text = mock_evaluator.evaluate.call_args[0][0].text.value
222215
assert "mitochondria" in called_text
223216

224-
async def test_unreadable_artifact_returns_unscored(self):
217+
async def test_unreadable_artifact_returns_none(self):
225218
scorer_fn, mock_evaluator = _make_scorer(text_source="artifacts")
226219
state = _make_state(
227220
metadata={
228221
"target_grade": "6-8",
229222
"artifacts": [{"path": "/nonexistent/path.md", "filename": "missing.md"}],
230223
}
231224
)
232-
score = await scorer_fn(state, _make_target())
233-
assert math.isnan(float(score.value))
225+
assert await scorer_fn(state, _make_target()) is None
234226
mock_evaluator.evaluate.assert_not_called()
235227

236228
async def test_multiple_artifacts_joined_with_separator(self, tmp_path: Path):
@@ -307,7 +299,7 @@ def test_scorer_wires_through_eval(self):
307299
assert sample.score is not None
308300
assert sample.score.value == CORRECT
309301

310-
def test_unscored_sample_does_not_count_as_failure(self):
302+
def test_skipped_sample_is_omitted_from_scores(self):
311303
from unittest.mock import AsyncMock, patch
312304

313305
from inspect_ai import Task, eval
@@ -323,14 +315,15 @@ def test_unscored_sample_does_not_count_as_failure(self):
323315
):
324316
scorer = gla_scorer()
325317

326-
# Sample has no target_grade — should be unscored, not INCORRECT
318+
# Sample has no target_grade — gla_scorer returns None, so it must NOT
319+
# contribute a score (no CORRECT/INCORRECT, no NaN to poison the mean).
327320
task = Task(
328321
dataset=[Sample(input="Write something.", metadata={})],
329322
solver=[generate()],
330323
scorer=scorer,
331324
)
332325

333326
log = eval(task, model="mockllm/model")[0]
327+
assert log.status == "success"
334328
sample = log.samples[0]
335-
assert sample.score is not None
336-
assert math.isnan(float(sample.score.value))
329+
assert (sample.scores or {}).get("gla_scorer") is None

0 commit comments

Comments
 (0)