Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6b52c66
fix(eval): an errored answer is counted as an error, not as unscored
napetrov Oct 29, 2026
89d19c9
fix(cache): an empty reply is never stored or replayed
napetrov Oct 29, 2026
93bc14a
fix(cache): a content-less reply is never stored or replayed
napetrov Oct 29, 2026
f99dfb2
fix(arms): an empty reply is an error cell, never judged or scored
napetrov Oct 29, 2026
32709c9
fix(arms): a judge error with an empty message is still an error cell
napetrov Oct 29, 2026
9547e87
fix(eval): an empty reference reply is an error, not graded as ""
napetrov Oct 29, 2026
73cdb3e
fix(arms): delta_vs_baseline is paired over the questions both arms s…
napetrov Oct 29, 2026
4a8b0ad
refactor(arms): keep arm_runner under the growth cap
napetrov Oct 29, 2026
a97c38b
docs(changelog): empty and content-less replies are errors, judge err…
napetrov Oct 29, 2026
16710be
fix(eval): a judge failure is an error, not a pass; empty replies sta…
napetrov Oct 29, 2026
21ddc76
Show an absent errors count as unknown in the arms report
antonwolfy Oct 29, 2026
d13fcb3
Describe the paired delta and counted errors where docs lagged
antonwolfy Oct 29, 2026
a9bcf7e
Merge branch 'main' of https://github.com/intel/dokimi into pr280-addr
napetrov Oct 29, 2026
c026063
test(llm): pin the replay half of the keeps rule
napetrov Oct 29, 2026
823787c
test(report): pin the unknown errors count of an older arms artifact
napetrov Oct 29, 2026
f3717a9
fix(eval): an agentic arm's empty reply is an error cell before the s…
napetrov Oct 29, 2026
cbb879f
Review: PRR_kwDOVBg7g88AAAABU5FakQ: already fixed in d13fcb38
napetrov Oct 29, 2026
0f31dad
fix(eval): a judge check after a failed one makes no call in eval ref…
napetrov Oct 30, 2026
753e5fb
fix(eval): an empty final reply keeps its skill choice in the selecti…
napetrov Oct 30, 2026
2170fb8
refactor(eval): paired_delta reads the stats pairing
napetrov Oct 30, 2026
d3fae29
refactor(cache): keeps() takes the Optional reply text, no cast in co…
napetrov Oct 30, 2026
fcc5698
test(cache): replay-only treats an empty stored entry as a miss; name…
napetrov Oct 30, 2026
b301b5a
docs(changelog): file the run-hygiene changes that move scores under …
napetrov Oct 30, 2026
97d38dc
Merge remote-tracking branch 'intel/main' into feat/run-hygiene
napetrov Oct 30, 2026
fb60380
test(caching): keep the content-less reply comment to two lines
napetrov Oct 30, 2026
a3a74df
test(arms): an agentic empty reply keeps the tool usage the selection…
napetrov Oct 30, 2026
d604fbb
Merge remote-tracking branch 'intel/main' into feat/run-hygiene
napetrov Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions dokimi/changelog.d/20261028_120200_napetrov_feat-run-hygiene.md
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.
18 changes: 13 additions & 5 deletions dokimi/docs/answer-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,22 @@ value is blank or missing.
Every check accepts `mode` (`any`/`all` over its `values`), `negate` and
`weight`. A question's score is the weighted mean of the checks that were
counted. It passes when it reaches `threshold`; without a threshold, it passes
when every counted check passes. A failed answer call leaves its checks
unscored rather than failed. `--no-judge` leaves the `judge` checks unscored
when every counted check passes. A failed answer call is an error, not a failed
check: the answer gets no score and no pass. `--no-judge` leaves the `judge` checks unscored
and makes no model calls; an arms artifact written that way has `judge_model`
and `judge_provider` set to `reference`. The summary's `judge_unscored` counts the
counted answers whose pass leaves out a judge check that had no verdict. The
summary's `check_unscored` does the same for a deterministic check that did not
run to a result (a `regex` past its limit): the pass is decided on the checks
that ran, and the count shows how many passes left one out.
that ran, and the count shows how many passes left one out. An answer whose
call failed is counted as `errors`, not `unscored`, and leaves the pass rate and
the mean out just as an unscored answer does. A judge call that raises, or whose
reply has no YES or NO after its retries, is an error too: the answer gets no score
and no pass, even when its other checks passed. The block's `error` names it (for
example `judge: TimeoutError`), as an arm cell's `error` does. An arms summary has the same
count per arm as `errors`. An empty or content-less reply is an error, never graded:
it leaves the pass rate and the mean out, and is never cached (on store or replay),
so a rerun asks the model again.

`eval reference` writes eval.v1 for an `answers generate` file and an arms
artifact for an `arms run` file. Each block carries `scorer: reference`, and
Expand Down Expand Up @@ -119,7 +127,7 @@ because the native run had no dokimi doc context. The tool's own verdicts
become an eval.v1 file with `scorer: native:<tool>`. Running `eval reference`
on that answers file re-grades the same completions with dokimi's checks,
which shows where the two graders disagree. An Inspect sample that errored
keeps its error on the answer, so `eval reference` leaves it unscored, as
Inspect does. A promptfoo output must come from
keeps its error on the answer, so `eval reference` counts it as an error, not a
score, as Inspect does. A promptfoo output must come from
a single provider. Supported log formats are Inspect `json` logs (inspect-ai
0.3.283) and promptfoo `-o *.json` (0.125.0).
8 changes: 4 additions & 4 deletions dokimi/docs/evaluating-treatments.md
Original file line number Diff line number Diff line change
Expand Up @@ -718,10 +718,10 @@ Two gates are deliberately statistical rather than absolute:
- **coverage, not zero errors.** A judge call fails occasionally (~1.25% observed against
the Intel proxy), and a failed call leaves `aggregate: null`. The gate requires ≥95% of
questions scored in *both* arms.
- **paired deltas, not the artifact's own number.** `delta_vs_baseline` averages each arm
over whatever it scored, so one failed call subtracts means taken over different
questions (39 vs 40 was observed, worth ~0.4 points). The script recomputes over
questions both arms scored. It pools questions; a mean of per-cell means differs slightly
- **paired deltas.** The artifact's `delta_vs_baseline` is paired over the questions both
arms scored, so one failed call does not subtract means taken over different
questions (39 vs 40 was observed, worth ~0.4 points). The script recomputes it over
those questions. It pools questions; a mean of per-cell means differs slightly
when the sets have different sizes — say which one a report uses.

## Scope and caveats
Expand Down
19 changes: 18 additions & 1 deletion dokimi/dokimi/caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,15 +278,32 @@ def draw_extra(extra: Dict[str, Any], draw: int) -> Dict[str, Any]:
return {**extra, "draw": draw} if draw else extra


def keeps(cache_if: Optional[Callable[[str], bool]], text: str) -> bool:
def is_empty_reply(text: Optional[str]) -> bool:
"""Whether a reply carries no answer: no content (``None``) or only whitespace.

The one test every path shares (response cache, single-turn and session arms,
``eval reference``, the judge). An empty reply is an error on the cell: it is
never scored, never counted as a 0, and never stored or replayed.
"""
return text is None or not text.strip()


def keeps(cache_if: Optional[Callable[[str], bool]], text: Optional[str]) -> bool:
"""Whether a completion may be stored, or a stored one replayed.

*cache_if* is the caller's own acceptance test. The judge passes "this reply
parses": without it an unparseable reply is stored like any other, and once
all three of the judge's attempts are stored that way, every later run
replays the failure without calling the model. Checking on replay too means
entries stored before the check existed stop poisoning the run.

An empty reply (see :func:`is_empty_reply`) is never kept, on store or on
replay: a stored empty answer would be replayed as a failed answer on every
later run, and a content-less one (stored as null) as "no answer". ``text`` is
``None`` for a content-less reply.
"""
if text is None or is_empty_reply(text): # the None test narrows the type for cache_if
return False
return cache_if is None or cache_if(text)


Expand Down
3 changes: 2 additions & 1 deletion dokimi/dokimi/commands/arms.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ def cmd_arms_run(args: argparse.Namespace) -> None:
delta = stats.get("delta_vs_baseline")
avg_s = "n/a" if avg is None else f"{avg:.1f}"
delta_s = "" if (delta is None or arm == args.baseline_arm) else f" (delta {delta:+.1f})"
print(f" {arm:<24} {avg_s}{delta_s}")
err_s = f" ({stats['errors']} errors)" if stats.get("errors") else ""
print(f" {arm:<24} {avg_s}{delta_s}{err_s}")


def cmd_arms_matrix_run(args: argparse.Namespace) -> None:
Expand Down
47 changes: 24 additions & 23 deletions dokimi/dokimi/eval/arm_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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", {}),

Copy link
Copy Markdown
Contributor

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_reply cell keeps for the skill-selection summary.

The point of keeping the cell's tool_usage is that summarize_skill_selection still scores the skill the agent loaded before its reply came back empty (arm_summary.py:161). Mutation check: dropping tool_usage=result.get("tool_usage", {}) from this call leaves tests/test_arm_cell_errors.py and tests/test_skill_selection.py at 75 passed. test_an_agentic_empty_reply_stays_an_error_cell_when_an_output_shaper_adds_text feeds the loop "tool_usage": {}, and tests/test_skill_selection.py:119 builds its cell by hand, so neither goes through this return.

If tool_usage is lost, first_turn_loaded is empty. An agent that loaded the right skill then counts as having loaded none, and selection_accuracy moves with no test failing.

Suggestion: give the fake loop result a tool_usage with a first-turn skill load, assert cell["tool_usage"] equals it, and run summarize_skill_selection([rec]) on the record to assert the choice is scored.

Copy link
Copy Markdown
Contributor Author

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).

iterations=result["iterations"], stopped_reason=result["stopped_reason"])
Comment on lines +573 to +577

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An agentic empty reply is now an error cell, so summarize_skill_selection counts it as a failed run and drops the skill choice that the transcript did record.

The cell keeps tool_usage, but summarize_skill_selection skips every cell with error (arm_summary.py:150). For an agent that loaded the expected skill and then gave an empty final reply (e.g. a max_iterations stop), the selection summary goes from n: 1, n_failed: 0, selection_accuracy: 1.0 before f3717a9 to n: 0, n_failed: 1, selection_accuracy: None now (checked by calling summarize_skill_selection on both cells). Which skill was picked does not depend on the final text, so routing loses a measured data point, and selection rates move for the same transcripts.

Suggestion: either keep counting the selection of an empty_reply cell that has tool_usage (only the answer is missing), or say in the changelog that such runs now count under n_failed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in f3717a9: the selection summary skips error cells except an empty_reply cell, so a loaded skill still scores (test_an_empty_final_reply_keeps_the_skill_choice_it_made). a3a74df now pins that the agentic cell keeps its tool_usage through the runner.

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 "")
)
Expand Down Expand Up @@ -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):
Comment thread
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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
57 changes: 54 additions & 3 deletions dokimi/dokimi/eval/arm_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: paired_delta repeats the complete-case pairing of dokimi.eval.stats.paired_vectors.

The same rule now lives in two places, and significance.delta_mean is computed from the other one, so a later change to one (how a NaN aggregate is treated, say) makes delta_vs_baseline and delta_mean disagree.

Suggestion: build it from paired_vectors(evaluations, arm_name, baseline_arm) and take the mean of the differences.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
18 changes: 16 additions & 2 deletions dokimi/dokimi/eval/checklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,30 @@ def consistent(self) -> Optional[bool]:
return self.covered != self.absent


def check_answer(ask: Ask, question: str, answer: str, points: Sequence[str]) -> List[PointVerdict]:
"""Both polarities for every point."""
class JudgeFailed(RuntimeError):
"""A judge call raised, or its reply had no YES or NO after every draw (``strict`` checks only)."""


def check_answer(ask: Ask, question: str, answer: str, points: Sequence[str], *,
strict: bool = False) -> List[PointVerdict]:
"""Both polarities for every point.

By default a failed call or an unparseable reply leaves its point unscored. With ``strict``
it raises :class:`JudgeFailed` instead, for a caller that must not grade an answer on the
points that did parse.
"""
def one(point: str, q: str) -> Optional[bool]:
try:
# Retried under new draws, and an unparseable reply is never cached.
verdict, _ = ask_parsed(ask, _TEMPLATE.format(question=question, answer=answer, point=point, ask=q),
parse_yes_no)
except Exception as exc: # one failed call is an unparsed point, not a lost run
if strict:
raise JudgeFailed(str(exc) or type(exc).__name__) from exc
logger.warning("Checklist call failed, point left unscored: %s", exc)
return None
if verdict is None and strict:
raise JudgeFailed("no YES or NO in the reply")
return verdict

return [
Expand Down
Loading
Loading