fix: count a job-reported error as a failed row - #184
Conversation
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
currentlycodinng
left a comment
There was a problem hiding this comment.
Verified at cd0a105. ruff check src, ruff format --check src and basedpyright all clean; pytest -m 'not integration' gives 5161 passed, 4 skipped locally, and gh pr checks 184 is 13/13.
The bug is real and the diagnosis is right. cd0a105^:processings.py reads result['name'] at 159 and result['output'] at 160 and nothing else, and table_display.py:47 counts failures off job_result.error, so a job that caught its own failure was invisible. wrap_agent.py:99 flags exactly {error, timeout}, which matches UNEVALUATED_TERMINATIONS at scorers.py:20; judge and max_turns correctly stay None. I checked the compatibility claim by grepping src/, examples/ and docs/ and it holds: every other 'error': key is nested in an output payload, a report field or a colour map, so nothing in the repo starts failing.
I probed _job_reported_error directly rather than trusting the docstring:
{'message': 'x', 'code': 123} -> 'x'
{'weird': ..., 'no_message_key'} -> "{'weird': 'shape', ...}"
{'message': '', 'detail': 'fb'} -> 'fallback'
'boom' -> 'boom' '' -> None None -> None
False -> 'False' 0 -> '0'
Two notes on that below, inline.
The larger point, which I can't attach inline since these files aren't in the diff: simulate() has the same bug and this PR does not fix it. simulation/api.py:2102 returns {'name': job_name, 'output': to_open_responses(...)} with no error key, and api.py:2318 hands it straight to evaluatorq(jobs=[job_fn]). Four lines up, api.py:2098 already computes terminated_by in (error, timeout) for the hook, so the branch is sitting right there. It's less bad than the wrap path because the default scorers catch it (criteria_met scores 0.0 at scorers.py:206 and api.py:2185 reports pass=False), so there's no silent 100%. But Failed Jobs still reads 0 on that path and check_pass_failures(treat_errors_as_failure=True) still misses those rows. integrations/langchain_integration/wrap_agent.py:148-151 is the same shape (returns name/output only, no error key), and CLAUDE.md:190 says a job that calls a target must emit the key unconditionally. Also worth a follow-up: types.py:140-144's JobReturn TypedDict still only declares name and output, and it's exported from init.py, so a user typing their job against it can't legally add the key the new contract wants without a NotRequired[str | None] there.
Not blocking. Happy for the sibling paths to be a follow-up if you'd rather keep this diff to the mechanism plus one caller, but I'd rather they went in here while the reasoning is loaded.
`process_job` read only `name` and `output` from a job's return value, so `JobResult.error` was set only when the job raised. A job that catches its own failure and reports it instead — which is what `wrap_simulation_agent` does, since one dead row must not kill the batch — came back indistinguishable from a clean one: `Failed Jobs 0`, `Success Rate 100%`, exit 0, on a run whose conversation never happened. Reproduced with an invalid `ORQ_API_KEY`: the simulation 401'd, the runner returned `terminated_by=error`, and the run reported a full pass. The signal already existed and was thrown away. `to_open_responses` records `error` and `status: 'failed'`, but nested inside `output`, where nothing reads it. Two parts. `process_job` now honours a top-level `error` key, keeping the output so the transcript survives for diagnosis — unlike the raise path, which discards it. `wrap_simulation_agent`'s job emits the key unconditionally, `None` on success and the runner's reason when it ended in `error` or `timeout`. This gives the CLAUDE.md house rule — a job that calls a target emits the `error` key unconditionally — a mechanism on the core `evaluatorq()` path, where it previously had none; only the red team path honoured it. `check_pass_failures(treat_errors_as_failure=True)` now catches these rows. A dict payload is flattened to its `message` rather than stringified, because `str()` on a dict renders a Python repr that reaches the results table and any judge reading the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cd0a105 to
b34202d
Compare
Applies the agreed findings from the review of this branch. The flattener collapsed a blank message to None, so a job that reported a failure with nothing to say produced a clean row again — the bug this branch exists to close, for one payload shape. Presence is now the failure signal: an unreadable payload becomes a named placeholder and logs, and the runner's reason falls back to the termination when it is empty. Flattening delegates to common.output_adapters.output_error_text rather than a second key ladder, so a job-level and an output-level error payload cannot disagree about what the same dict means. simulate() and wrap_simulation_agent derived the failure reason two different ways — one read metadata['error'] first, the other read reason alone — so the same dead run described itself differently depending on the entry point. Both now call simulation.evaluators.scorers.failure_reason. A failed row's job span closed as OK in a trace viewer while the summary table called it failed. All three failure paths in process_job now mark the span. Docs: the simulation guide still taught the workaround for this bug, and the evaluation reference documented no way for a custom job to report a failure it handled. Both updated, including the fact that check_pass_failures gates on errors only with treat_errors_as_failure=True. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review —
|
| Critic | Lens | Rating |
|---|---|---|
| The Skeptic | strategy | 🟢 MINOR ISSUES |
| The Pedant | quality | 🟠 NEEDS REWORK |
| The Historian | reuse | 🟠 NEEDS REWORK |
| The Realist | goal | 🟠 NEEDS REWORK |
| The Outsider | all four, via Codex | 🔴 FUNDAMENTALLY FLAWED |
Applied in 2a883ff
1. A blank error message flattened back to a clean row. Eight critics, independently — the highest-agreement finding in the run, and the one that mattered. _job_reported_error mapped '', {'message': ''} and {'message': None} to None, so a job that reported a failure with nothing to say produced a passing row: exactly the bug this branch exists to close, reintroduced for one payload shape, and pinned by this branch's own tests. It was reachable — wrap_agent passed result.reason straight through, and the runner builds that reason as str(e), which is empty for any exception raised without arguments. Presence is now the failure signal: an unreadable payload becomes job reported a failure with no readable message and logs a WARNING, and failure_reason falls back to the termination when the reason is empty.
2. A fourth error-flattening ladder. The Historian and the Outsider both found common/output_adapters.py:49's output_error_text, which already flattens a str-or-dict error payload and already handles the present-but-empty case correctly ("Error presence, rather than message truthiness, marks a failed target"). The minimality lens reached the same place from the other direction — no producer in the repo emits a dict, so the message/error/detail ladder, the JSON fallback and the json import existed for a caller that does not exist. Flattening now delegates to output_error_text, and the helper is 8 lines instead of 27. Note the ceiling: a dict with no readable message now renders as output_error_text renders it everywhere else, rather than picking detail out. No producer emits that shape.
3. Two derivations of "why did this simulation fail". Six critics. simulate()'s job read metadata['error'] or reason; wrap_simulation_agent's read reason alone. They agree today only because _partial_result sets both from the same string — an invariant enforced in a third file neither call site references. Both now call simulation.evaluators.scorers.failure_reason, beside UNEVALUATED_TERMINATIONS.
4. The job span stayed green on a failed row. The Skeptic and the code lens. set_span_error exists in common/tracing.py for exactly this ("For swallowed failures — the code recovered, but the span should not read as OK in a trace viewer") and was unused here, so the summary table said Failed Jobs 1 while the trace said OK. All three failure paths in process_job now mark the span, including the two raise paths that had the same gap.
5. Docs that contradicted the code. The Realist, the spec lens and the Outsider. docs/guides/simulation-in-evaluatorq.md:132 still taught the workaround — "evaluatorq reports a 100% success rate for this run even when the conversation itself never happened" — on the exact page the bug was found on. The PR body deferred that to #180, which has already landed. Fixed here: the example now gates on job_result.error. docs/evaluation-reference.md gains the raw-dict job contract (emit error on every path, None on success; @job() nests the key under output, so a decorated job reports failures by raising) and its CI-gate snippet now passes treat_errors_as_failure=True with the caveat stated.
6. Comment and docstring corrections. The comments lens and the Pedant. The same paragraph appeared five times; the check_pass_failures addition and the Job alias docstring were false as written ("returns a dict with 'name' and 'output' keys", one line under the TypedDict that had just gained a third key). JobReturn's docstring now says what is actually true — including that Job types the return as dict[str, Any], so nothing type-checks a job against the shape.
7. Test gaps. The tests lens and the Outsider: nothing asserted the user-visible claim. create_summary_display had zero coverage in the repo, and the CHANGELOG's claim was written in terms of Failed Jobs. Added a test that Failed Jobs reads 1 and Success Rate reads 0% for an error-reporting row, and a producer-level test that a terminated_by=error run with an empty reason still reports one.
Open — needs a call
1. The bug report said "exit 0" and this branch does not change any exit code. Five critics landed on this. Failed Jobs and Success Rate do change by default — table_display.py:47 reads job_result.error with no gate. But evaluatorq() never calls check_pass_failures itself, treat_errors_as_failure defaults to False, ✓ Evaluation completed successfully is an unconditional console.print, and simulate(exit_on_failure=True) only raises for rows missing from the result cache — an error row is in the cache, so it does not fire. The repro (invalid ORQ_API_KEY, 401, terminated_by=error) still exits 0.
- A — make an errored row fail by default: flip
treat_errors_as_failuretoTrue, and fold error rows intosimulate()'sexit_on_failuredecision. Costs a real default flip: every existing script that currently exits 0 over a dead row starts failing, so it needs afeat!-level conversation or a deliberate opt-out flag. - B — leave the defaults, say so plainly: the CHANGELOG now states that exit codes are unchanged and the docs now show
treat_errors_as_failure=True. Costs the gap staying open for anyone who does not read either.
Lean: B for this branch, A as its own PR. The counters are the honest scope of a fix:; changing when a run exits non-zero is a separate decision with a blast radius, and merging it in here would hide it behind a bug fix.
2. @job() cannot emit the key the new contract requires. job_helper.py:84 wraps a function's return into {'name', 'output'} and never lifts an inner error to the top level, so every decorated job — the way the docs teach job authoring, and how red team and the integrations build theirs — reports failures only by raising. CLAUDE.md tells the next author "a new job that calls a target must emit the error key unconditionally" while the sanctioned helper makes that impossible.
- A — make the contract reachable: give
@joban explicit way to return a handled failure (aJobOutcomereturn type, or a documented sentinel), and auditredteam/adaptive/pipeline.py,redteam/runner.pyandintegrations/langchain_integration/wrap_agent.py:148, which the Outsider and the errors lens found still nest their error underoutput. Costs a decorator change plus a repo-wide sweep, in a PR that is currently aboutprocess_job. - B — document the split:
@jobjobs raise; raw-dict jobs may report. Already written intodocs/evaluation-reference.mdin the applied commit.
Lean: B now, A as a follow-up ticket. The Realist checked the other builders and found their failures already raise or already flow through red team's own error_payload() system, so this is a completeness gap rather than a live second instance of the bug — but it is a real one, and CLAUDE.md's rule is currently untrue as written.
3. JobReturn enforces nothing. Four critics. Job = Callable[[DataPoint, int], Awaitable[dict[str, Any]]], so the TypedDict is documentation. I tried annotating both simulation producers -> JobReturn and basedpyright rejected it: a TypedDict is not assignable to dict[str, Any], so the producers no longer satisfy the Job alias. Tightening the alias itself makes every existing user job a type error — a breaking change. Reverted; the JobReturn docstring now says the contract is unenforced rather than implying otherwise.
Rejected
- The dict-key ladder's
message/error/detailprecedence. The Outsider called it wrong ({'message': None, 'detail': 'real'}discards the detail). True, but moot — the ladder is gone, andoutput_error_textkeeps the whole payload in that case. error: Falsecounts as a failure with the message'False'. Two critics wanted it treated as "no error".Falseis outside the declaredstr | Nonecontract, and counting an unreadable value as failed is the conservative direction; the CHANGELOG documents it.- Test quote-style inconsistency. The Pedant checked each file's pre-existing convention and confirmed both new blocks match their own file.
ruff formatis scoped tosrcdeliberately. Not a finding. - The third
UNEVALUATED_TERMINATIONSsite (api.py:2239). Inside_sim_evaluation_details; it derives an evaluator explanation, not a job return, and already reportspass_=False. No change needed. test_simulate_job_error_key.pymonkeypatchingrunner._run_with_timeout. Flagged as reaching past the public seam, but that private method is what the production path calls (api.py:2152, with its ownnoqa); patchingrunwould intercept nothing.
Solo flags, not applied
- Evaluator averages in the detailed table now include an errored row's scores, so one panel can read 100% while the summary counts the row failed — newly possible because an errored row keeps its scores. Worth a look.
to_open_responseswritesoutput['error']only forterminated_by == 'error', nottimeout, so a timed-out run's nested error field is stillNonefor any judge reading it.- No log line at the choke point in
process_jobwhen a job reports an error and no results table is printed — both red team and simulation passprint_results=False.
|
Took the sibling paths here rather than deferring — the reasoning was loaded, as you say. Rebased onto
The one I did not change is Left open: the evaluators-run-on-an-errored-row thread, where I documented the consequence rather than skipping the scorers. Reasoning is on that thread. CI verbatim: |
…arget failures process_job skipped nothing when a job reported its own failure, so a dead transcript still ran every evaluator — an LLM judge call per row for a verdict on a conversation that never happened. The row keeps its output for diagnosis and skips its evaluators, matching the raise path's zero scores. wrap_langchain_agent now catches a failing invoke/ainvoke and reports it through the top-level error key instead of raising; a caller mistake (no prompt and no usable messages column) still raises.
The check counted rows missing from the result cache. A run the runner ended in error is *in* the cache — it produced a result, just not a conversation — so simulate() and eq sim returned normally over a target that was 401 throughout, which is exactly what the caller asked to exit on. The message now names both counts. exit_on_failure=False is unchanged: the same rows are reported as a warning and the partial results come back. Scorer verdicts are still reporting only, so an agent that answered badly does not raise. Two tests whose subject is something else (the terminal hook, the per-simulation wall clock) opt out explicitly rather than relying on a dead row exiting 0. CLAUDE.md gains the distinction this branch's review got wrong: the error key it legislates is the one inside a target or LLM invocation's output payload, which decides what a judge reads. JobReturn['error'] is a different key at the top level of a job's return value, deciding whether the row counts as failed. @job() nesting everything under output is the contract, not a gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review decisionsBoth open decisions from the review above are resolved and applied in 39f014f. 1.
|
currentlycodinng
left a comment
There was a problem hiding this comment.
Re-verified at 39f014f. ruff check src, ruff format --check src and basedpyright clean, pytest -m 'not integration' gives 5236 passed / 4 skipped locally, gh pr checks 184 is 13/13.
Every point from my last pass is addressed, and two of them better than I suggested. simulate()'s job at api.py:2158 and the langchain job at wrap_agent.py:165 both emit the key unconditionally, types.py:151 types it NotRequired[str | None], wrap_agent.py:104 goes through failure_reason so the two simulation paths cannot describe the same dead run differently, and evaluatorq.py:53 no longer claims an errored row has no scores. The falsy-message and repr complaints are answered by routing through output_error_text and making presence the signal rather than truthiness; I checked {'message': ''} and '' both reach the placeholder with a WARNING carrying the payload. Skipping evaluators on an errored row removes the judge-call cost entirely, which is a better answer than the readability fix I asked for. I confirmed the skip live: a dead target logs 'skipping 2 evaluator(s) for row 0' and no scorer runs.
The langchain try/except earns more than the changelog says. processings.py's generic except Exception never sets job_name, only the JobError path does, so a raising raw job used to land in the summary table as 'job'. Returning the error keeps the real name.
One thing I want an answer on before this merges. Widening exit_on_failure to error and timeout rows also throws away the run's artifacts. _simulate_core re-raises at api.py:1624, so build_simulation_run (:1651), the executive summary, the recommendations and auto_save_run (:1706) are all skipped, and cli.py:783 exits 1 before _write_results or _export_report. When only dropped rows triggered this, fine. Now a single provider timeout in a 50-row batch discards the saved run, the report and the results file for the 49 rows that worked, and the caller paid for those. Either save before raising, or keep error/timeout on the warning path by default and let a caller opt into the hard gate.
Two smaller things, neither in this diff so noting here instead of inline. The docs updates missed three places that still describe the dropped-only semantics: src/evaluatorq/simulation/README.md:75, README.md:183, and the SimulationDroppedError docstring at simulation/exceptions.py:20 ("Raised when simulation job(s) produced no result and were dropped"), which now also raises for rows that did produce a result and just ended in error or timeout. And skipping evaluators means a dead simulate row carries no scores at all, I probed one and its metadata is ['datapoint_id', 'error', 'persona', 'scenario'] with no evaluator_scores, where criteria_met used to stamp 0.0. That makes scorers.py:222 and api.py:2246 unreachable from both entry points, which is where RES-1308 put the guard against an unaudited run showing green in the uploaded experiment. The row still uploads with error set, so this is a question about how the experiment renders a scoreless row rather than a bug.
| parts.append(f'{len(dead)} ended in error or timeout (rows: {dead})') | ||
| msg = f'{caller}(): {len(missing) + len(dead)} of {expected} simulation(s) failed — ' + '; '.join(parts) | ||
| if exit_on_failure: | ||
| raise SimulationDroppedError(msg, partial_results=results) |
There was a problem hiding this comment.
This now fires for any row that ended in error or timeout, and the raise skips everything downstream: build_simulation_run at :1651, the executive summary, the recommendations, and auto_save_run at :1706. cli.py:783 catches it and exits 1 before _write_results and _export_report. So one timed-out row in a 50-row batch discards the saved run and the report for the 49 that worked, and those were paid for. Save the run before raising, or leave error/timeout on the warning path by default.
| if evaluators: | ||
| if evaluators and error is not None: | ||
| logger.warning( | ||
| 'job {!r} reported an error, skipping {} evaluator(s) for row {}: {}', |
There was a problem hiding this comment.
Consequence worth confirming: a dead simulate row now reaches the uploaded experiment with no evaluator scores at all. I probed one and its metadata is ['datapoint_id', 'error', 'persona', 'scenario'], criteria_met used to stamp 0.0 here. That makes scorers.py:222 and api.py:2246 unreachable from simulate() and wrap_simulation_agent, and those branches exist so an unaudited run cannot show green in the experiment (RES-1308). The row does carry error, so this may be fine, I want to know it was a decision.
The bug
evaluatorq reported
Failed Jobs 0,Success Rate 100%,✓ Evaluation completed successfullyand exit 0 on a run where the conversation never happened.Reproduced while running the example on the
docs/autofill-wrap-simulation-agentpage with anORQ_API_KEYthat is invalid for its workspace. The simulation got401 authentication_error,SimulationRunner.runcaught it and returnedterminated_by=error, and the run reported a full pass over a dead target.Why nothing saw it
simulation/runner.pyswallows the exception on purpose and returns a partial result. Correct — one bad row must not kill the batch.simulation/convert.py:157already records the failure:'error': {'message': result.reason}, plus'status': 'failed'. The signal existed.output.wrap_agent.pyreturned{'name', 'output'}with no top-levelerror, andprocessings.pyread onlyresult['name']andresult['output'].JobResult.errorwas set only when the job raised, andtable_display.py:47counts failures from that field. Hence 100%.The fix
process_jobhonours a top-levelerrorkey returned by a job, and keeps the output — unlike the raise path, which discards it. The transcript is the most useful thing you have when diagnosing a failed run.simulate()'s own (_build_simulation_job_and_cache) andwrap_simulation_agent's — emiterrorunconditionally:Noneon success, the runner's reason when it ended inerrorortimeout. Both derive it through one helper,simulation.evaluators.scorers.failure_reason, so the same dead run cannot describe itself two ways depending on the entry point.job reported a failure with no readable messageand logs aWARNING, rather than silently becoming a clean row again.common.output_adapters.output_error_text, the repo's existing reader of an error payload, so a job-level and an output-level error cannot disagree about what the same dict means.ERRORon every failed row, including the two raise paths — those previously closed asOKwhile the summary table called the row failed.Note this is a different key from the one CLAUDE.md's "Target calls and error payloads" section legislates. That one lives inside the output payload of a target or LLM invocation and decides what a judge reads; this one is at the top level of a job's return value and decides whether the row counts as failed. An invocation lives inside a job, so a job can carry both.
@job()nests what it is handed underoutput, which is where the judge-facing key belongs — a decorated job reports a row failure by raising. CLAUDE.md now spells the distinction out, because this review got it wrong first.What this does not change
Library-level exit codes.
Failed JobsandSuccess Ratenow move by default, andsimulate(exit_on_failure=True)— its default — now raises for a run that ended inerrorortimeout, not only for a dropped row.evaluatorq()itself still never exits: it has no gate of its own, andcheck_pass_failures'streat_errors_as_failurestill defaults toFalse. A caller who never opted in gets the same exit code as before; the docs now show the flag and say what leaving it off means.JobReturnis documentation, not enforcement.Jobtypes a job's return asdict[str, Any], and a TypedDict is not assignable to that, so annotating producers-> JobReturndoes not type-check. Tightening the alias would break every existing user job. The docstring now states this rather than implying otherwise.Compatibility
A job already returning a top-level
errorkey for a non-failure reason will now be counted as failed. Nothing in this repo does;CHANGELOG.mdnotes it under### Notable defaults.Docs
docs/guides/simulation-in-evaluatorq.mdtaught the workaround for this exact bug ("evaluatorq reports a 100% success rate for this run even when the conversation itself never happened"). The example now gates onjob_result.error.docs/evaluation-reference.mddocuments the raw-dict job contract — emiterroron every path,Noneon success — and its CI-gate snippet now passestreat_errors_as_failure=True, with the default's behaviour stated.Tests
All fakes, no credentials:
tests/unit/test_processings.py— a reported error setsJobResult.error, keeps the output, is still scored, and tripscheck_pass_failures(treat_errors_as_failure=True); an omitted key and an explicitNoneboth stay clean; a blank or unreadable payload reports the placeholder rather thanNone; andcreate_summary_displayreadsFailed Jobs 1/Success Rate 0%for such a row, which is the claim the CHANGELOG makes.tests/simulation/test_wrap_agent.py—error/timeoutreport the reason;judge/max_turnsemit the key asNonerather than omitting it; a failure with an empty reason still reports one.tests/simulation/test_simulate_job_error_key.py— the same contract onsimulate()'s own job.tests/simulation/test_hooks.py—exit_on_failure=Trueraises for a run that ended in error;Falsewarns and returns the partial results.CI checks verbatim:
ruff check src,ruff format --check src,basedpyright(0 errors, whole repo),pytest -m 'not integration'(5252 passed, 2 skipped),mkdocs build --strict.🤖 Generated with Claude Code