feat: add status field to EvaluationOutput - #359
Conversation
Add an optional 'status' field to EvaluationOutput that controls whether a result is included in aggregate score computations. Three values are supported: - 'graded' (default): real verdict, included in all aggregations - 'could_not_evaluate': evaluator tried but could not grade - 'informational': surfaces content for review, not pass/fail The _default_aggregator now filters to graded outputs before computing averages. The Experiment report builder and EvaluationReport.flatten() also exclude non-graded results from overall_score. The field defaults to 'graded', making this fully backward compatible with all existing evaluators and consumers.
| detailed_results: list[list[EvaluationOutput]] = [] | ||
| diagnoses: list[dict | None] = [] | ||
| recommendations: list[str | None] = [] | ||
| statuses: list[str] = [] |
There was a problem hiding this comment.
Issue (Important + Medium): Two things on statuses:
-
Same typing point as
EvaluationOutput.status— preferlist[Literal["graded", "could_not_evaluate", "informational"]]overlist[str]for validation and discoverability. -
statusesdefaults to[]whilescores/test_passesare populated, and the rest of the code compensates per-index with... if i < len(self.statuses) else "graded"(seeflattenline 58 and_displayline 146). This means a validEvaluationReportcan havelen(statuses) != len(scores). It's guarded today, but any futurezip(scores, statuses, strict=True)(like the one you added on line 60) would raise on such a report.
Suggestion: Consider a Pydantic validator that pads statuses to len(scores) with "graded" on construction, so the parallel-list invariant holds everywhere and the per-index guards can go away. At minimum, document the invariant in the docstring.
| "score": aggregate_score, | ||
| "reason": aggregate_reason or "", | ||
| "detailed_results": evaluation_outputs, | ||
| "status": ( |
There was a problem hiding this comment.
Issue (Important): This nested conditional expression is hard to parse, and the roll-up rule is implicit: when no output is graded, it falls back to evaluation_outputs[0].status, so a case mixing could_not_evaluate and informational gets whichever happens to be first — an arbitrary tie-break that isn't documented anywhere.
Suggestion: Extract a small named helper with an explicit precedence rule, e.g.:
def _rollup_status(outputs: list[EvaluationOutput]) -> str:
if not outputs or any(o.status == "graded" for o in outputs):
return "graded"
# define intended precedence when no graded output exists
if any(o.status == "could_not_evaluate" for o in outputs):
return "could_not_evaluate"
return "informational"This makes the intended behavior testable and removes the positional dependency. Please also confirm the desired precedence — the PR doesn't currently specify what a mixed non-graded case should roll up to.
There was a problem hiding this comment.
Thanks — _roll_up_status() is a nice improvement and the precedence is now explicit and documented. Keeping the first-non-graded-output tie-break is a reasonable call now that it's intentional rather than incidental.
One tiny follow-up: the helper is annotated evaluation_outputs: list (untyped element). Since EvaluationOutput is already imported here, list[EvaluationOutput] would be more precise and consistent with the repo's typing conventions. Non-blocking.
| test_pass: bool | ||
| reason: str | None = None | ||
| label: str | None = None | ||
| status: str = "graded" |
There was a problem hiding this comment.
Issue (Important): status is typed as bare str, so Pydantic accepts any value. A typo like status="could_not_evaluete" would silently be treated as non-graded and excluded from every aggregation — a scoring-corruption footgun with no error surfaced. The three values are also the public contract, so they should be discoverable via type hints/IDE autocomplete.
Suggestion: Use a Literal, which is the established convention in this package (types/multimodal.py, types/detector.py):
from typing import Literal
status: Literal["graded", "could_not_evaluate", "informational"] = "graded"This gives validation for free and makes the accepted values self-documenting. Aligns with the "obvious path is the happy path" tenet.
|
|
||
|
|
||
| class TestDefaultAggregatorWithStatus: | ||
| """Tests for _default_aggregator filtering on status.""" |
There was a problem hiding this comment.
Issue (Suggestion): The test name says not_in_serialized_when_default, but the body asserts the opposite — that status is present in model_dump(). The name contradicts the behavior and will mislead readers.
Suggestion: Rename to something like test_status_included_in_serialized_when_default.
| status="could_not_evaluate", | ||
| ), | ||
| EvaluationOutput( | ||
| score=0.0, |
There was a problem hiding this comment.
Issue (Suggestion): The aggregator tests assert score, passed, and reason field-by-field. Since _default_aggregator returns a fixed 3-tuple, a single equality check would also guard against the reason string/ordering regressing:
assert Evaluator._default_aggregator(outputs) == (0.0, False, "No gradable evaluation outputs produced")Suggestion: For the deterministic cases (e.g. test_all_could_not_evaluate, test_all_informational, test_empty_outputs_list), assert the full tuple. Keep per-field assertions only where the score is a float approximation.
|
Assessment: Comment Clean, well-tested, backward-compatible addition — the graded-only filtering is applied consistently across Review Categories
Nice job keeping this fully backward compatible via the |
- TYPING: Use Literal['graded', 'could_not_evaluate', 'informational'] for status field on EvaluationOutput and statuses on EvaluationReport instead of plain str - READABILITY: Extract nested ternary status roll-up into _roll_up_status() helper with explicit precedence documentation - DATA INVARIANT: Add model_validator to EvaluationReport that pads statuses list with 'graded' to match scores length - TESTING: Rename misleading test_status_not_in_serialized_when_default to test_status_included_in_model_dump_when_default, use full-object equality assertions where output is deterministic
|
Re-review of
Verified locally: 34/34 status tests pass, 928 related tests pass, Only a single non-blocking nit remains (element type on the |
|
Nice — this matches the design in #346 closely. I'd independently put up #357 for the same issue a few hours earlier; happy to close mine as superseded by this one since it's already through review. Cross-linking for provenance. One thing worth a follow-up (applies to this implementation as merged, not a blocker): the graded-only filter is applied to overall_score, but the raw scores / test_passes lists still carry the 0.0 / False placeholders for could_not_evaluate rows. Any consumer that computes a pass-rate directly off test_passes — rather than reading overall_score — will still count skipped/overflowed cases as hard failures. Might be worth either a docstring note steering consumers to overall_score/statuses, or a small pass_rate helper that applies the same graded-only filter, so the two aggregation paths can't drift. Good to see the _worker task-crash path tagged too, not just the _run_evaluator catches — that's the case that most often bites in practice. |
Summary
Adds an optional
statusfield toEvaluationOutputthat lets evaluators signal when a result should not count toward aggregate scores.Three values are supported:
"graded"(default) - real verdict, included in all aggregations"could_not_evaluate"- evaluator tried but could not grade (preconditions not met, harness failure, missing data)"informational"- surfaces content for human review, not pass/failThe
_default_aggregator,Experimentreport builder, andEvaluationReport.flatten()all filter to graded outputs before computingoverall_score. The field defaults to"graded", making this fully backward compatible.Usage
What's tested
EvaluationOutput.statusfield: default value, serialization roundtrip, backward compat_default_aggregator: filters non-graded outputs, handles all-CNE, mixed statuses, empty listEvaluationReport.flatten(): excludes non-graded fromoverall_score, preserves statusesto_file/from_filecould_not_evaluate,informational, mixed outputsAll 190 relevant tests pass (24 new + 166 existing). Ruff formatting and linting clean.
Related to #346