fix: stop reporting evaluation and grounding failures as empty results - #70
Merged
Conversation
A failure rendered as a successful empty result is the defect class this
project exists to catch in other people's software. This repository produces
GRPO rewards, grounding metrics and exported artifacts, so here the same shape
does not raise -- it yields a wrong number.
Each site below could not tell "I looked and the answer is empty/zero" from
"I could not look", and returned the first for the second. Each fix makes the
distinction representable rather than logged.
GRPO milestone reward (openadapt_ml/training/grpo/reward.py) -- highest blast
radius, it is the training signal:
* openadapt-evals not installed -> logged a warning and returned reward 0.0.
* VLM judge raised -> caught per milestone; the milestone stayed in the
denominator, so a 429 depressed the reward exactly like an unmet milestone.
* Milestone with no description -> `continue`d past but still counted, so the
task could never score 1.0 and nothing said why.
* No milestones / none of type "screenshot" -> returned 0.0.
All now raise MilestoneEvaluationError. GRPOTrainer catches it at the one place
that can decide, and returns None ("no measurement, keep the existing reward"),
which the caller already handles -- rather than feeding an invented 0.0 into
compute_group_advantages and taking a gradient step on it. GRPOTrainer's unused
`_compute_milestone_reward` raised the same 0.0 for an unknown task_id; it now
raises KeyError, matching its sibling.
Grounding (openadapt_ml/grounding/): `GeminiGrounder.ground` printed the error
and returned []; `_parse_bbox_response` swallowed JSONDecodeError/KeyError/
TypeError; `extract_ui_elements` did both. [] is the legitimate answer for "I
looked and matched nothing", and evaluate_grounder scores it as best_iou 0.0 /
centroid_hit False -- so a rate limit or a bad key was recorded as a grounding
miss the model never made. These now raise GroundingError; the honest empty
result still returns [].
Grounding metrics (openadapt_ml/evals/grounding.py): a step whose screenshot
could not be opened was `continue`d past. Every GroundingMetrics property
divides by len(results), so the sample left the denominator and the reported
hit rate stayed high over a silently smaller sample. Now raises.
Parquet summary (openadapt_ml/export/parquet.py): `except ImportError: return`
made `include_summary=True` a no-op that returned None exactly like success.
Azure login probe (scripts/setup_azure.py): an unrecognised az error returned
True -- "I could not tell, so assume you are logged in". Now re-raises.
Also: PR #69 fixed `task_dir` being declared twice in GRPOConfig (PIE794) and
kept the correct, documented declaration. Nothing in the configured rule set
`B,E,F,I,W` catches a duplicated class field, so the defect could return the
moment the audit stopped. PIE794 is adopted as a single rule (repository count:
0) and tests/test_failure_is_not_success.py adds an AST guard over every
dataclass in the package.
Every test in tests/test_failure_is_not_success.py is mutation-checked: with
the production file reverted to its pre-fix form the matching test fails with
"DID NOT RAISE" (behaviour, not a missing import -- the new exception types are
resolved dynamically for exactly that reason).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
A sweep of
openadapt-mlfor one defect class: a failure rendered as a successful empty result. This repository produces GRPO rewards, grounding metrics and exported artifacts, so a swallowed failure here does not raise — it yields a wrong number.At each site below the code could not tell "I looked and the answer is empty/zero" from "I could not look", and returned the first shape for the second. Every fix makes the distinction representable (an exception, or an explicit
Nonethe caller already handles). No fix is "log it and return the same value".Fixed, ranked by blast radius
1. GRPO milestone reward — this is the training signal
openadapt_ml/training/grpo/reward.py::evaluate_milestones_screenshotopenadapt-evalsnot installedlogger.warning(...),return 0.0MilestoneEvaluationErrorMilestoneEvaluationErrorcontinue, but still counted intotal— the task could never score 1.0 and nothing said whyMilestoneEvaluationErrorscreenshotreturn 0.0MilestoneEvaluationError0.0 is a legal reward. Every one of these fed an invented 0.0 into
compute_group_advantages, which normalised it and took a gradient step on a measurement that never happened.GRPOTrainer._compute_milestone_reward_from_rolloutcatches the new error at the one place that can decide, and returnsNone— "there is no measurement here, keep the reward the environment already gave you" — which the caller already handles.Noneand0.0are now different things._compute_milestone_rewardreturned 0.0 for an unknowntask_id; it now raisesKeyError, matching its sibling, which already got this right.2. Grounding — an API error was recorded as a grounding miss
openadapt_ml/grounding/base.py,detector.pyGeminiGrounder.groundprinted the error and returned[]._parse_bbox_responseswallowedJSONDecodeError/KeyError/TypeError.extract_ui_elementsdid both.[]is the legitimate answer for "I looked and matched nothing", andevaluate_grounderscores it asbest_iou=0.0,centroid_hit=False. So a rate limit, a bad key or an unparseable reply was written into the metrics as a grounding failure the model never made. These now raise the newGroundingError; the honest empty result still returns[](guarded by its own test).3. Grounding metrics — a shrinking denominator
openadapt_ml/evals/grounding.py::evaluate_grounder_on_episodeA step whose screenshot could not be opened was
continued past. EveryGroundingMetricsproperty divides bylen(results), so the sample silently left the denominator: the reported hit rate stayed high, computed over fewer samples than the episode contains, with nothing saying so. Now raises.4. Parquet summary — "wrote nothing" looked like "wrote it"
openadapt_ml/export/parquet.py::_write_summaryexcept ImportError: returnmadeto_parquet(..., include_summary=True)a no-op that returnedNoneexactly like success.5. Azure login probe —
Truemeaning "I did not check"scripts/setup_azure.py::check_az_logged_inAn unrecognised
azerror returnedTrue("assume you are logged in"); the caller skippedaz loginand failed later with an unrelated error. Now re-raises. The two positively-recognised "not logged in" answers still returnFalse, and that path has its own test.The named defect from #69:
task_dirGRPOConfigdeclaredtask_dirtwice (PIE794). Both spelledstr | None = None, so the surviving default was unchanged and there is no user-visible misbehaviour to report — the duplicate was a latent hazard, not a live bug. #69 kept the correct one: the first declaration, carrying the block comment that matches the class docstring, and the one whose position already fixed the field's place in the generated__init__. It deleted the redundant one-line restatement. That is the right repair.But nothing in the configured rule set
B,E,F,I,Wcatches a duplicated class field — verified:ruff check --select B,E,F,I,Wpasses clean against the pre-#69 file. The defect could return the moment the audit stopped. So:PIE794is adopted as a single rule, not thePIEfamily (repository count at adoption: 0), consistent with how[tool.ruff.lint]documents every other selection.tests/test_failure_is_not_success.pyadds an AST guard over every dataclass inopenadapt_ml/, not just this one.Mutation checks
Every test here was mutation-checked: the production file was reverted to its pre-fix form (
git show e1ff03e:fortask_dir,git show origin/main:for the rest) and the matching test confirmed to fail, then restored.The new exception types are resolved dynamically in the tests, on purpose — importing them directly would make the revert fail with
ImportError, which proves only that a name was removed. As written, the revert fails withFailed: DID NOT RAISE, i.e. on the defect itself.Sample of the recorded pre-fix output:
The ruff gate was mutation-checked the same way: with the pre-#69 duplicate restored,
uv run ruff check .now exits 1 withPIE794 Class field 'task_dir' is defined multiple times; on mergedmainit exits 0.The three tests that pass in both directions are deliberate: they guard the honest paths (a real no-match still returns
[], a measured 0.5 is still 0.5, a recognised auth error still returnsFalse) so these fixes cannot be "improved" into raising on normal operation.Fake gates
Grepped
.github/workflows/for|| true,continue-on-error,exit 0,set +e,--exit-zero,|| :. Zero hits. No gate in this repository is pretending to be a gate. #69's widenedruff check .is genuinely blocking — confirmed by making it fail on demand.Sweep status of the items #69 surfaced
Checked against merged
origin/main, not a local checkout:F821undefined name (x3)NameErrorF811redefined-while-unused (x5)F841unused variable (x4)E712,E722bare except (x2)PIE794task_dirduplicateS110/S112swallowed exceptionsJudged NOT to be defects (with reasons)
S110/S112are inopenadapt_ml/cloud/{local,lambda_labs,vast_ai,ssh_tunnel,modal_cloud}.py. These are best-effort status and cleanup probes on infrastructure that is allowed to be absent, and they already returnFalse/None, which is an honest "not there" — not a success shape. Blast radius is a dashboard row, not a metric.ssh_tunnel._is_port_in_usereturningTrueonOSError— reads like the pattern but is correct:bind()failing is the evidence the port is in use._parse_vlm_output_to_actiondefaulting toDONE()on unparseable model output — logged, and DONE-terminates the episode. It is a deliberate rollout policy, not a swallowed failure; changing it changes training semantics and belongs in its own change with its own evidence.GRPOTrainerscreen_sizefallbacks andaction._grpo_raw_textAttributeError— narrow, genuinely optional, and fall back to a configured value rather than to a fabricated measurement.setup_azure.pyreturn Trueat the "quota already at level" and "role already assigned" branches — these are real success states positively recognised from the error text, not guesses.scripts/{check_quota,p1_episode_success_ab_test,run_demo_experiment_n30}.pyS110s — genuine instances, but in one-off analysis scripts with no importers. Reported rather than fixed, to keep this PR reviewable.PLW1510(subprocess.runwithoutcheck=) — every fix is a behaviour change needing per-call judgement, aspyproject.tomlalready records. Not adopted here.Verification
One test was found reaching the live Gemini API while being written (
import google.generativeai as genaibinds from the parent package attribute, so patchingsys.modulesalone leaves the real client in place). Fixed to patch both; the test now runs offline in 0.08s.fix:— this will cut a patch release on merge, under the standing release authorization. PyPI publication will be verified from the index, not from a green run.🤖 Generated with Claude Code
https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM