Repository navigation
fix(eval): infra errors and empty replies are counted as errors, never scored #280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6b52c66
89d19c9
93bc14a
f99dfb2
32709c9
9547e87
73cdb3e
4a8b0ad
a97c38b
16710be
21ddc76
d13fcb3
a9bcf7e
c026063
823787c
f3717a9
cbb879f
0f31dad
753e5fb
2170fb8
d3fae29
fcc5698
b301b5a
97d38dc
fb60380
a3a74df
d604fbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| ### Added | ||
|
|
||
| - **Errors are counted apart.** An answer whose call failed is tallied as `errors` in `eval reference` summaries and per arm in arms summaries. The arms summary shows an `Errors` column, and the console summary shows the count per arm. | ||
|
|
||
| ### Changed | ||
|
|
||
| - **Earlier figures are not comparable.** Pass rates, deltas and selection rates from runs before this change do not match new ones for the same inputs. Re-run `arms judge` or `eval reference` to compare. | ||
| - **Errors leave the scores out.** An answer whose call failed leaves `n`, the pass rate and the mean out. Before, it was lumped with answers that no check could score. | ||
| - **Empty replies are errors, never scores.** An empty, whitespace-only or content-less model reply is an error on its cell in arms runs and in `eval reference`: it is never judged or graded (a negated check no longer scores `""` as 1.0), and it leaves `n`, the pass rate and the mean out. | ||
| - **Judge failures with an empty message are counted.** A judge exception whose message is empty (for example `TimeoutError()`) is recorded under its class name, so it is counted as an error rather than dropped. | ||
| - **`delta_vs_baseline` is paired.** It is the mean of arm minus baseline over the questions both arms scored. Before, it was the difference of two unpaired means, so a question one arm errored on moved the delta. | ||
| - **A judge failure is an error, not a pass.** In `eval reference`, a judge check whose call raised, or whose reply had no YES or NO, makes the answer an error: no score and no pass, even when its other checks passed. Before, the other checks decided the pass. The block carries `error`, as an arm cell does. | ||
| - **An empty reply stays an error when an output shaper adds text.** Emptiness is read on the model's reply before the shapers run, so a footer on an empty reply is not an answer. | ||
|
|
||
| ### Fixed | ||
|
|
||
| - **Empty replies and content-less replies are not cached.** Neither is stored in or replayed from the response cache, so a later run asks the model again. | ||
| - **`delta_vs_baseline` is always present.** It is `null` when no question has both arms scored, instead of missing. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,14 +18,15 @@ | |
| from pathlib import Path | ||
| from typing import Any, Dict, List, Optional, Sequence | ||
|
|
||
| from dokimi.caching import is_empty_reply | ||
| from dokimi.utils import question_id # noqa: F401 (re-exported: callers import it from here) | ||
| from dokimi.artifacts import save_artifact | ||
| from dokimi.llm import llm_call_with_usage | ||
| from dokimi.defaults import DEFAULT_ANSWER_MODEL, DEFAULT_ANSWER_PROVIDER | ||
| from dokimi.eval import stats as arm_stats | ||
| from dokimi.eval.interactions import interaction_significance | ||
| from dokimi.eval.arm_summary import ( | ||
| cost_summary, summarize_agentic_usage, summarize_skill_selection, | ||
| cell_values, cost_summary, empty_reply_cell, paired_delta, summarize_agentic_usage, summarize_skill_selection, unjudged_cell, | ||
| ) | ||
| from dokimi.eval.cells import Cell | ||
| from dokimi.eval.question_text import question_text_of, require_question_texts | ||
|
|
@@ -393,11 +394,13 @@ def _answer_one_arm( | |
| return {"error": f"generation_failed: {exc}", "answer": None, | ||
| "metadata": cfg_metadata} | ||
|
|
||
| raw_chars = len(answer_text or "") | ||
| rec = to_record(usage, self.model, self.provider) | ||
| # Read on the reply the model gave, before the shapers: a footer added to nothing is not an answer. | ||
| if is_empty_reply(answer_text): # an error cell, as a session's empty turn; the usage still counts | ||
| return empty_reply_cell(rec, cfg_metadata, t0) | ||
| final_text = self._apply_shapers(shapers, answer_text) | ||
| raw_chars = len(answer_text or "") | ||
| final_chars = len(final_text or "") | ||
|
|
||
| rec = to_record(usage, self.model, self.provider) | ||
| return { | ||
| "answer": final_text, | ||
| "raw_answer": answer_text if shapers else None, | ||
|
|
@@ -566,8 +569,13 @@ def _answer_agentic_arm( | |
| "tool_usage": tool_usage, "metadata": cfg_metadata} | ||
|
|
||
| raw_answer = result["answer"] | ||
| final_answer = self._apply_shapers(shapers, raw_answer) | ||
| rec = to_record(result.get("usage") or result["token_usage"], self.model, self.provider) | ||
| if is_empty_reply(raw_answer): # read before the shapers: a footer added to nothing is not an answer | ||
| return empty_reply_cell( | ||
| rec, cfg_metadata, t0, agentic=True, transcript=result["transcript"], | ||
| tool_call_count=result["tool_call_count"], tool_usage=result.get("tool_usage", {}), | ||
| iterations=result["iterations"], stopped_reason=result["stopped_reason"]) | ||
|
Comment on lines
+573
to
+577
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. An agentic empty reply is now an error cell, so The cell keeps Suggestion: either keep counting the selection of an
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| final_answer = self._apply_shapers(shapers, raw_answer) | ||
| metrics = rec.as_metrics_dict( | ||
| answer_chars=len(raw_answer or ""), final_chars=len(final_answer or "") | ||
| ) | ||
|
|
@@ -614,8 +622,8 @@ def _score(rec: Record) -> Dict[str, Any]: | |
| gt = rec.ground_truth | ||
| for arm_name in arm_names(rec): | ||
| answer = rec.answer(arm_name) | ||
| if answer is None or not answer.text: | ||
| scores[arm_name] = None | ||
| if answer is None or is_empty_reply(answer.text): | ||
|
napetrov marked this conversation as resolved.
Resolved
|
||
| scores[arm_name] = unjudged_cell(answer) # an errored or empty answer is never judged | ||
| continue | ||
| context, source = self.judge_context.resolve_with_source( | ||
| rec.question_id, arm_name, dict(answer.raw) | ||
|
|
@@ -630,7 +638,7 @@ def _score(rec: Record) -> Dict[str, Any]: | |
| ) | ||
| except Exception as exc: | ||
| logger.exception("Judging arm %s failed", arm_name) | ||
| score = {"error": str(exc)} | ||
| score = {"error": str(exc) or type(exc).__name__} | ||
| if isinstance(score, dict): | ||
| score = {**score, "judge_context_source": source} | ||
| digest = (self.judge_context.digest_of(rec.question_id, arm_name) | ||
|
|
@@ -752,25 +760,18 @@ def build_output( | |
| def _summarize( | ||
| self, evaluations: List[Dict[str, Any]], baseline_arm: str | ||
| ) -> Dict[str, Any]: | ||
| per_arm: Dict[str, List[float]] = {name: [] for name in self.arm_names} | ||
| for ev in as_records(evaluations, source="evaluations", kind="evaluations"): | ||
| for arm_name in ev.scores: | ||
| if (v := ev.score(arm_name)) is not None: | ||
| per_arm.setdefault(arm_name, []).append(float(v)) | ||
| per_arm, errors = cell_values(evaluations, self.arm_names) | ||
|
|
||
| summary: Dict[str, Any] = {"per_arm": {}} | ||
| base_avg = None | ||
| for arm_name, vals in per_arm.items(): | ||
| avg = round(sum(vals) / len(vals), 1) if vals else None | ||
| summary["per_arm"][arm_name] = {"avg_aggregate": avg, "n": len(vals)} | ||
| if arm_name == baseline_arm: | ||
| base_avg = avg | ||
| if base_avg is not None: | ||
| for arm_name, stats in summary["per_arm"].items(): | ||
| avg = stats["avg_aggregate"] | ||
| stats["delta_vs_baseline"] = ( | ||
| round(avg - base_avg, 1) if avg is not None and arm_name != baseline_arm else None | ||
| ) | ||
| summary["per_arm"][arm_name] = {"avg_aggregate": avg, "n": len(vals), "errors": errors.get(arm_name, 0)} | ||
| # Paired over the questions both arms scored; the unpaired means above are still reported. | ||
| # Always present: null for the baseline, and for an arm with no question both arms scored. | ||
| for arm_name, stats in summary["per_arm"].items(): | ||
| stats["delta_vs_baseline"] = ( | ||
| paired_delta(evaluations, baseline_arm, arm_name) if arm_name != baseline_arm else None | ||
| ) | ||
|
|
||
| # Paired statistics. Every arm is tested against the *same* baseline | ||
| # sample question-by-question, so the arms are mutually comparable and | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,10 +13,12 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Dict, Iterable, Mapping | ||
| import time | ||
| from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple | ||
|
|
||
| from dokimi.records import RecordLike | ||
| from dokimi.records import Answer, RecordLike | ||
| from dokimi.records import records as as_records | ||
| from dokimi.eval.stats import paired_vectors | ||
|
|
||
| #: Stamp on an agentic answer's ``tool_usage["counts"]``: its tool counts skip | ||
| #: calls that failed (``error`` in the transcript). Unstamped answers counted | ||
|
|
@@ -154,7 +156,10 @@ def summarize_skill_selection(records: Iterable[RecordLike]) -> Dict[str, Any]: | |
| }) | ||
| if failed: | ||
| s["failed"] += 1 | ||
| continue | ||
| # An empty final reply fails the answer, not the choice: the skills were | ||
| # loaded before it came back empty, so the choice is still scored. | ||
| if arm.get("error") != "empty_reply": | ||
| continue | ||
| first = skill.get("selected") | ||
| # Skills loaded together in the first turn are one choice. It is a hit | ||
| # only if every one of them is acceptable: crediting "the right one is | ||
|
|
@@ -264,3 +269,49 @@ def cost_summary(records: Iterable[RecordLike]) -> Dict[str, Any]: | |
| if prompt else None), | ||
| } | ||
| return out | ||
|
|
||
|
|
||
| def unjudged_cell(answer: Optional[Answer]) -> Optional[Dict[str, str]]: | ||
| """The cell of an answer that is not judged: an error for an errored or empty one, ``None`` for none.""" | ||
| if answer is None: | ||
| return None | ||
| return {"error": answer.error or "empty_reply"} | ||
|
|
||
|
|
||
| def empty_reply_cell(rec: Any, metadata: Any, started: float, **extra: Any) -> Dict[str, Any]: | ||
| """The answer cell of a call that returned no text: an error, never an answer. | ||
|
|
||
| The call was paid for, so its usage (*rec*) is still recorded. *started* is the ``time.time()`` | ||
| at the start of the arm; *extra* carries what the agent loop adds to a cell. | ||
| """ | ||
| return {"error": "empty_reply", "answer": None, "token_usage": rec.as_token_usage_dict(), | ||
| "metrics": rec.as_metrics_dict(), "metadata": metadata, | ||
| "elapsed_sec": round(time.time() - started, 2), **extra} | ||
|
|
||
|
|
||
| def paired_delta(evaluations: Iterable[Any], baseline_arm: str, arm_name: str) -> Optional[float]: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: The same rule now lives in two places, and Suggestion: build it from
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Already fixed in 2170fb8: paired_delta reads paired_vectors, so the complete-case rule lives in one place. |
||
| """The mean of ``arm - baseline`` over the questions that both arms scored; ``None`` if none. | ||
|
|
||
| A question one arm errored on (or left unscored) is not in the delta at all: the | ||
| difference of two unpaired means would compare different questions. | ||
| """ | ||
| arm_vals, base_vals = paired_vectors(evaluations, arm_name, baseline_arm) | ||
| diffs = [a - b for a, b in zip(arm_vals, base_vals, strict=True)] | ||
| return round(sum(diffs) / len(diffs), 1) if diffs else None | ||
|
|
||
|
|
||
| def cell_values(evaluations: Iterable[Any], arm_names: Sequence[str]) -> Tuple[Dict[str, List[float]], Dict[str, int]]: | ||
| """Per arm, the aggregates that count, and how many cells errored instead. | ||
|
|
||
| An errored cell (the answer call failed, or the judge raised) has no aggregate, so it | ||
| is left out of the mean and its ``n``; the errors are counted apart, not as scores. | ||
| """ | ||
| values: Dict[str, List[float]] = {name: [] for name in arm_names} | ||
| errors: Dict[str, int] = {name: 0 for name in arm_names} | ||
| for ev in as_records(evaluations, source="evaluations", kind="evaluations"): | ||
| for arm_name, cell in ev.scores.items(): | ||
| if (v := ev.score(arm_name)) is not None: | ||
| values.setdefault(arm_name, []).append(float(v)) | ||
| elif cell is not None and cell.error: | ||
| errors[arm_name] = errors.get(arm_name, 0) + 1 | ||
| return values, errors | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nothing pins what the agentic
empty_replycell keeps for the skill-selection summary.The point of keeping the cell's
tool_usageis thatsummarize_skill_selectionstill scores the skill the agent loaded before its reply came back empty (arm_summary.py:161). Mutation check: droppingtool_usage=result.get("tool_usage", {})from this call leavestests/test_arm_cell_errors.pyandtests/test_skill_selection.pyat 75 passed.test_an_agentic_empty_reply_stays_an_error_cell_when_an_output_shaper_adds_textfeeds the loop"tool_usage": {}, andtests/test_skill_selection.py:119builds its cell by hand, so neither goes through this return.If
tool_usageis lost,first_turn_loadedis empty. An agent that loaded the right skill then counts as having loaded none, andselection_accuracymoves with no test failing.Suggestion: give the fake loop result a
tool_usagewith a first-turn skill load, assertcell["tool_usage"]equals it, and runsummarize_skill_selection([rec])on the record to assert the choice is scored.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in a3a74df: the agentic empty-reply test now gives the loop a first-turn skill load, asserts cell tool_usage equals it, and runs summarize_skill_selection on the record. Dropping tool_usage from the runner call fails it (red), and it passes on the branch (green).