Skip to content

fix(eval): infra errors and empty replies are counted as errors, never scored - #280

Merged
napetrov merged 27 commits into
intel:mainfrom
napetrov:feat/run-hygiene
Oct 31, 2026
Merged

napetrov merged 27 commits into
intel:mainfrom
napetrov:feat/run-hygiene

Conversation

@napetrov

@napetrov napetrov commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part of #220.

eval reference and arms runs stop turning failed or empty answers into scores.

  • A judge call that raises, or whose reply has no YES or NO, makes the answer an error: no score, no pass, counted in errors. The other checks no longer decide the pass. The block carries error, as an arm cell does.
  • An errored answer is counted as errors, not unscored. An empty or content-less reply is never graded, judged or cached.
  • Emptiness is read on the model's reply before the output shapers run, so a footer added to an empty reply is not an answer.
  • delta_vs_baseline is paired over the questions both arms scored, and is always present (null when there are none).
  • The arms report and the console summary show the errors count per arm.

Evidence

  • Red then green. Nine new tests fail on the previous head (a judge failure passed on the match check alone; a shaper footer made an empty reply an answer; delta_vs_baseline key missing; reference cells had no error; the report had no errors column) and pass on this one. Files: tests/test_reference_checks.py, tests/test_arm_cell_errors.py, tests/test_arms_report.py.
  • Fresh-review rounds found blockers, each fixed in its own commit. Round 2: an empty reference reply graded as "", an empty single-turn arm reply scored, a judge exception with an empty message dropped, content-less replies cached, an unpaired delta. Round 3: the findings above.
  • Self-review was in-session only. No fresh-context subagent was available, so this is a single-reviewer pass. The four repro scripts from the fresh review were re-run on the fixed package and show the intended output.
  • Local checks on 16710beb, run directly (make ci-local was not run; its schema, workflow and bandit gates are not covered here):
    • ruff check clean on the touched files; mypy: Success: no issues found in 271 source files.
    • pytest -n auto: 9673 passed, 7 skipped, 1 failed: test_probe_reports_a_writable_docker_socket (AF_UNIX path too long under the box's temp dir).
    • The same test fails in the full run on intel/main too (9670 passed, 1 failed). It passes alone on both trees, so this is pre-existing and environmental, not from this PR.
    • A throwaway merge of current intel/main into this head: 9699 passed, 7 skipped.

Merge Danger

  • Results from before this change are not comparable. eval reference graded an empty answer as 0 (or 1.0 under a negated check), and a single-turn arm left it unscored. A judge failure used to be decided by the other checks. Rerun those artifacts before comparing pass rates.
  • Two existing tests asserted that a None reply is cached. They are replaced: test_a_content_less_reply_is_never_stored_or_replayed (tests/test_caching.py), and the refactor(llm): one LLM module behind every call; old entry points delegate #78 test, now test_a_reply_without_content_is_asked_again_not_replayed (tests/test_llm.py). A replay-only run that reaches an empty reply raises CacheMiss for that call; judge and panel redraws move on to the next draw.
  • Summary shape: errors appears in summary (eval) and in per_arm (arms, schema widened). delta_vs_baseline is now always present. The arms Markdown table gains an Errors column.
  • Known limits, not fixed here:
    • dokimi/eval/judge.py:320 and dokimi/eval/answerer.py:182 still store a bare str(e), so an empty-message exception there is dropped as before. Legacy paths, not the arm path.

🤖 Generated with Claude Code

An answer whose call failed was tallied with the answers no check could
score, so the summary's unscored count mixed provider failures with
grading gaps. It is now counted as errors, per condition in eval reference
and per arm in arms summaries. Errors leave n, the pass rate and the mean
out, as unscored answers do; an arms cell whose answer errored or whose
judge raised is no longer a silent None.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
A stored empty answer was replayed on every later run as a failed answer,
so a one-off empty reply stuck. keeps() now refuses empty and
whitespace-only text on store and on replay; a rerun asks the model again.
A content-less reply (None, stored as null) is kept as before. Docs and
changelog cover both commits of this branch.

Note for replay-only runs: a cache that holds an empty reply now raises
CacheMiss for that call instead of replaying the empty answer.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
keeps() treated None as acceptable, so a reply with no content was stored
as null and replayed as "no answer" on every later run. It is now refused
on store and on replay, like an empty or whitespace-only reply, and a rerun
asks the model again. The one shared predicate is caching.is_empty_reply.

No caller of complete() offers tools, and chat() (the tool-calling path)
is never response-cached, so no tool-call-only reply is lost.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
A single-turn arm whose reply is empty or whitespace (or content-less) was
recorded as an answer: the judge's own empty check skipped it silently (no
score, not counted) or judged a whitespace reply. It is now an error cell,
empty_reply, as a session that stopped on an empty turn already was; the call's
usage still counts. The judge treats an empty answer the same way, with the
shared is_empty_reply predicate.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The judge's exception handler stored str(exc). Some exceptions have an
empty message (TimeoutError()), so the cell held {"error": ""}; the record
reader drops an empty error, and the cell was neither scored nor counted as
an error. The exception's class name is the error when the message is empty.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
eval reference passed an empty or whitespace-only reply to the checks as
text, so a negated check scored it 1.0 and a plain one 0. Only a missing
reply was treated as no answer. An empty reply is now an errored answer:
it leaves n, the pass rate and the mean out, and is counted as an error,
as an errored call is. The emptiness test is the shared is_empty_reply.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…cored

The headline delta was the difference of two unpaired means, so a question
one arm errored on moved the delta (the repro gave -8.3 where the paired
difference over the two shared questions is -10.0). It is now the mean of
arm minus baseline over the questions both arms scored, via paired_delta.
The unpaired avg_aggregate is still reported.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The empty-reply cells added to arm_runner put it at 809 lines (cap 800) and
pushed ArmRunner.judge past its complexity baseline. The unjudged cell (an
error for an errored or empty answer, None for no answer) moves to
arm_summary.unjudged_cell, and the single-turn empty-reply return is shorter.
No behaviour change.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…ors count, delta is paired

Describes the four fixes on this branch: empty reply as an error cell in arms
and eval reference, the judge exception with an empty message, the response
cache refusing content-less replies, and the paired delta_vs_baseline.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…y empty

In eval reference, a judge check whose call raised, or whose reply had no
YES or NO, was dropped, and the checks that ran decided the pass. The answer
is now an error: no score and no pass, counted in errors, with the reason on
the block as `error`. The checklist report keeps its per-point behaviour; the
strict mode is opt-in.

Arms: emptiness is read on the model's reply before the output shapers run,
so a footer added to an empty reply is not an answer. delta_vs_baseline is
always present (null when no question has both arms scored). The arms summary
shows the errors count in the Markdown table and the console summary.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@codecov-commenter

codecov-commenter commented Oct 29, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.69%. Comparing base (b8afa8e) to head (d604fbb).
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #280      +/-   ##
==========================================
+ Coverage   93.68%   93.69%   +0.01%     
==========================================
  Files         277      277              
  Lines       35954    36016      +62     
  Branches     6092     6107      +15     
==========================================
+ Hits        33683    33745      +62     
  Misses       1463     1463              
  Partials      808      808              
Files with missing lines Coverage Δ
dokimi/dokimi/caching.py 98.71% <100.00%> (+0.03%) ⬆️
dokimi/dokimi/commands/arms.py 97.85% <100.00%> (+<0.01%) ⬆️
dokimi/dokimi/eval/arm_runner.py 95.87% <100.00%> (-0.05%) ⬇️
dokimi/dokimi/eval/arm_summary.py 97.84% <100.00%> (+0.42%) ⬆️
dokimi/dokimi/eval/checklist.py 93.90% <100.00%> (+0.19%) ⬆️
dokimi/dokimi/eval/reference_checks.py 90.57% <100.00%> (+0.71%) ⬆️
dokimi/dokimi/eval/reference_run.py 100.00% <100.00%> (ø)
dokimi/dokimi/llm.py 95.82% <100.00%> (ø)
dokimi/dokimi/report/arms_report.py 97.56% <100.00%> (ø)
dokimi/dokimi/task_formats/imports/native_logs.py 89.33% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@antonwolfy antonwolfy left a comment

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.

Review at 16710be. The single-turn and eval reference paths hold up: the strict judge, the errors/unscored split and the paired delta behave as described. Four things to fix, the first two before merge: the agentic arm still scores an empty reply once an output shaper is active, which is the case this PR sets out to close.

What I ran, on a Linux review host at 16710be:

  • make ci-local: 26 passed, 2 failed. Run tests: 9719 passed, 4 skipped, 1 failed. The failure is test_unbalanced_quote_in_hook_completes_quickly (1.11 s against a 1 s bound), which passes when run alone and sits in a file this PR does not touch. pip-audit fails because it reports known vulnerabilities in the lock files, which this PR does not touch. The changed-line coverage step passed.
  • Reproductions for the code findings, with the LLM transport mocked.
  • A mutation run of the new tests (about 37 mutants across the reference, cache and arms code): the survivors are the replay guard inline below, the per-check error serialisation and the baseline's delta_vs_baseline: null.
  • All ten commits carry Signed-off-by.

Findings outside the diff:

  1. Nit: three places still describe the old behaviour. dokimi/docs/evaluating-treatments.md:720-724 and dokimi/scripts/check_treatment_batch.py:15-17 say the artifact's delta_vs_baseline averages each arm over what it scored, so a failed call subtracts means over different questions. It is now the paired mean. dokimi/dokimi/task_formats/imports/native_logs.py:59 still says a failed sample "stays unscored in eval reference"; it is counted under errors now. Suggestion: reword the three to match answer-formats.md.

Checked, no issues

  • Schemas: errors under summary.per_arm is an optional integer, so widening arms.v1.json in place needs no version bump. delta_vs_baseline was already number|null. schema_compat against the base passes.
  • Cache callers: the only replay_only users, judge.py and panel.py, pass cache_if=parses, which already rejects empty text, so the new keeps rule does not change their redraws. The only other check_answer caller stays non-strict.
  • Precedence: an answer's own error wins over empty_reply, and a no-retrieval placeholder without an error stays unscored. Both orders are pinned.
  • Repo rules: growth ratchet holds (arm_runner.py is at 797 of 800 lines), no generated artifacts, no new doc pages, new tests write nothing outside tmp_path.

Comment thread dokimi/dokimi/eval/arm_runner.py Resolved
Comment thread dokimi/dokimi/llm.py Resolved
Comment thread dokimi/dokimi/report/arms_report.py Resolved
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>

@antonwolfy antonwolfy left a comment

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.

I pushed a commit per nit, up to d13fcb3, which fix the two nits from my review:

  • 21ddc76: dokimi/dokimi/report/arms_report.py:168: an absent errors count now prints — instead of 0.
  • d13fcb3: dokimi/docs/evaluating-treatments.md:720, dokimi/scripts/check_treatment_batch.py:15 and dokimi/dokimi/task_formats/imports/native_logs.py:59: reworded to say delta_vs_baseline is paired and a failed sample is counted under errors.

make ci-local at d13fcb3: 27 passed, 1 failed (pip-audit, as at the reviewed head), 7 skipped.

Not approving yet. Still open: the bug and the risk from my review.

@vchamarthi

vchamarthi commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 1 issue(s) in this PR.

By severity: 1 low

Severity Category Location Finding
low test test_caching.py:300 Nit: the test name still says the null entry is "replayed, not a miss", but the new rule is the opposite (complete() refuses to replay it...
Review details
  • Commit reviewed: cbb879f against merge base f0f95a2
  • Coverage: 20 files in 6 groups
  • Engine: OpenCodeReview v1.13.3, us.anthropic.claude-sonnet-5-5
  • Usage: 600k tokens (444k from cache), 42s
  • Tool calls: 35 (18 code_search, 12 file_read, 2 file_find, 2 file_read_diff, 1 code_comment)
  • Changelog and docs for run hygiene: 3 files
  • Caching and LLM changes with tests: 4 files
  • Arm runner, summary, command, schema and cell error tests: 5 files
  • Arms report and its tests: 2 files
  • Reference checks/run and checklist evaluation with tests: 4 files
  • Scripts and native logs import tweaks: 2 files

Comment thread dokimi/tests/test_arms_report.py Resolved
An empty or null entry stored before the rule must not be replayed as the
answer: complete() asks the model again. The test fails when the replay
guard is reduced to cache_if alone.

Thread: PRRT_kwDOVBg7g86xh-Dg
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
A row without an errors key reads as unknown, not as a false zero, and a
real zero stays a zero. The test fails when the fallback is changed to 0.

Thread: PRRT_kwDOVBg7g86xlq7F
Review: PRR_kwDOVBg7g88AAAABU7EbFQ
Comment: IC_kwDOVBg7g88AAAABg13P_A
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…hapers run

_answer_agentic_arm applied the output shapers before any emptiness check, so
a footer added to nothing made the text non-empty and the judge scored it.
Read the loop's reply first, as the single-turn path does, and build both
error cells with one empty_reply_cell in arm_summary.py (arm_runner.py is at
its 800 line limit). Covers the answered and max_iterations stops. The test
is red on the old code.

Thread: PRRT_kwDOVBg7g86xh-DN
Review: PRR_kwDOVBg7g88AAAABU5TpUw
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The docs nit of the review (three places describing the old behaviour) was
fixed by the reviewer in d13fcb3; no change in this round.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@napetrov

Copy link
Copy Markdown
Contributor Author

"Findings outside the diff, 1": fixed in d13fcb3 by antonwolfy; evaluating-treatments.md, check_treatment_batch.py and native_logs.py now describe the paired delta and the counted errors. The agentic bug is in f3717a9, the replay pin in c026063, the — nit in 21ddc76.

@napetrov

Copy link
Copy Markdown
Contributor Author

Thanks for d13fcb3 and 21ddc76. The two items still open here are fixed: the bug (agentic empty reply under a shaper) in f3717a9, the risk (replay half of keeps) in c026063. Details are in the replies on those threads.

@napetrov

Copy link
Copy Markdown
Contributor Author

This review has no text of its own; its inline nit (— fallback test) is fixed in 823787c, answered on that thread.

@napetrov

Copy link
Copy Markdown
Contributor Author

No change in this PR: the report says all modified and coverable lines are covered. Installing the Codecov GitHub app is an organization setting outside this PR.

@napetrov

Copy link
Copy Markdown
Contributor Author

The one inline comment of this summary (— fallback test) is fixed in 823787c, answered on that thread.

Comment thread dokimi/tests/test_caching.py Outdated
@@ -298,7 +298,7 @@ def test_usage_values_of_every_field_type_replay(monkeypatch, tmp_path):


def test_null_text_is_replayed_not_a_miss(monkeypatch, tmp_path):

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.

test · low
Nit: the test name still says the null entry is "replayed, not a miss", but the new rule is the opposite (complete() refuses to replay it via keeps). The name and the docstring now contradict each other. Rename it, e.g. test_a_null_entry_still_loads. The test also doesn't cover replay-only mode: an empty or null stored entry there now raises CacheMiss instead of replaying. Add a case in test_llm.py for it.

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

Renamed the test to test_a_null_entry_still_loads.

Added test_an_empty_entry_is_a_miss_in_replay_only in test_llm.py, over None, "" and whitespace. In replay-only mode a stored empty or null entry raises CacheMiss and is not replayed. The test fails when the keeps() replay guard is removed.

@vlad-perevezentsev vlad-perevezentsev left a comment

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.

Review at cbb879f. The fix is right: an errored, empty or content-less reply and a failed judge call no longer become a score or a pass, in single-turn, session and agentic arms and in eval reference, and delta_vs_baseline is paired. What is left: the changelog files changes that move numbers under Fixed and does not say old runs are not comparable, and a few smaller points in the line comments.

What I ran, at cbb879f:

  • make ci-local --offline: 0 failed, 17 passed, 13 skipped (network and Docker jobs).
  • At 16710be, the changed test files on the merge-base: 19 of the new tests fail there, so they cover the new behaviour.
  • summarize_skill_selection on an agentic cell that loaded the expected skill, as an empty answer before f3717a9 and as the empty_reply error cell after it.

Checked, no issues

  • Agentic empty reply: f3717a9 checks the loop's reply before the shapers and keeps the usage, the transcript and tool_usage on the error cell, so cost and tool-use rollups still count the run.
  • Cache: keeps() refuses empty and None replies on store and on replay; c026063 pins the replay half.
  • Report and docs: an absent errors count prints —, and the docs and check_treatment_batch.py now describe the paired delta.
  • eval reference tallies: an error grade has no score and no pass and goes to errors, apart from unscored; a no-retrieval placeholder stays unscored.
  • Schema: errors in arms.v1 per-arm stats is a new optional integer, so widening in place is fine.

@@ -0,0 +1,11 @@
### Fixed

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.

This PR moves published numbers for the same inputs, but the fragment files everything under Fixed and does not say that old runs are not comparable.

delta_vs_baseline is now paired, so the same evaluations give a different delta whenever one arm errored on a question. In eval reference, a judge timeout or a reply with no YES/NO used to let the other checks decide the pass, and an empty reply was graded as "" (a negated check passed it); now both are errors and leave n, the pass rate and the mean. An agentic arm's empty reply is now an error cell too, which also takes it out of skill_selection_summary. changelog.d/README.md puts "anything that moves scores for the same inputs" under Changed, and the new errors fields and the Errors column are Added.

Suggestion: move the delta, eval reference judge/empty-reply and agentic bullets to a ### Changed section and say that pass rates, deltas and selection rates from earlier runs are not comparable with new ones (re-run arms judge / eval reference to compare); put the errors field and column under ### Added.

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 b301b5a: the fragment now files the score-moving changes under Changed, says earlier pass rates, deltas and selection rates are not comparable, and keeps the errors field and column under Added.

Comment on lines +573 to +577
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"])

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.

Comment on lines +448 to +451
result = grade_check(check, question, answer or "", ask)
out.checks.append(result)
if out.error is None and result.error is not None:
out.error = result.error

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.

Once a judge check fails and the grade is an error, grade() still runs every remaining judge check.

The grade can no longer change: it has no score and no pass. Each further judge check is up to two polarities times the retry draws of paid calls, so an answer with 4 judge checks whose first one timed out still sends the other 3 checks' calls, usually into the same outage.

Suggestion: after out.error is set, append the remaining judge checks as CheckResult(kind, None, None, weight, "not judged: an earlier judge check failed") without calling the judge (deterministic checks can still run).

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 0f31dad: after a failed judge check the remaining judge checks are recorded as not judged and make no call (test_reference_checks pins the detail string).

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

Comment thread dokimi/dokimi/llm.py Outdated
Comment on lines 721 to 722
# A content-less reply (None) is not stored: keeps() refuses it, so it is never replayed.
text = cast(str, reply.text)

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: keeps() now handles None on purpose, but it is still typed text: str and the callers cast the text to str to fit.

The docstring has to say "text is None at runtime ... whatever its annotation says", and mypy cannot see the None case, so a later change that calls cache_if(text) before the empty check type-checks and crashes on None.

Suggestion: type it text: Optional[str] (as is_empty_reply is) and drop the cast here and at line 713.

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 d3fae29: keeps() is typed text: Optional[str] and refuses None before cache_if runs, so the cast calls in complete() are gone. Reply.text is passed through and narrowed by a None test before the store. test_caching.py pins the annotation.

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.

Dropped test_keeps_is_annotated_for_the_none_of_a_content_less_reply as you and exai-bot suggested, in d604fbb (merge of main, which also resolves the test_caching.py conflict with #178). The behaviour tests for a None reply stay.

…erence

Once a judge call fails the grade is an error, so no further judge check
can change it. The remaining judge checks are now recorded as not judged
instead of each sending its paid calls, usually into the same outage.
Deterministic checks still run.

Thread: PRRT_kwDOVBg7g86xypBx
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…on summary

The loop loads its skills before the final reply comes back empty. That
reply is an error cell, and the selection summary skipped every errored
cell, so the choice the transcript recorded was dropped. The run still
counts under n_failed; its selection now counts under n.

Thread: PRRT_kwDOVBg7g86xypBu
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The complete-case rule lived in both paired_delta and stats.paired_vectors,
so a later change to one could make delta_vs_baseline and the significance
delta_mean disagree. paired_delta now takes its pairs from paired_vectors.
The test fails until paired_delta uses it; the numbers do not change.

Thread: PRRT_kwDOVBg7g86xypBy
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…mplete()

keeps() was annotated text: str while its callers passed a content-less
reply's None, so complete() cast the text twice to fit. It is now typed
Optional[str] and refuses None before cache_if runs, and complete() passes
Reply.text through, narrowed by a None test before the store.

Thread: PRRT_kwDOVBg7g86xypB0
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
… the null-entry test

The null-entry test was named "replayed, not a miss" while the rule it
pins is the opposite: the entry loads, and complete() refuses to replay
it. Rename it. Add the replay-only case: an empty or null stored entry
raises CacheMiss instead of replaying, so a replay-only run asks for a
fresh answer. The new test fails when the keeps() replay guard is removed.

Thread: PRRT_kwDOVBg7g86xtVB4
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…Changed

Changes that move pass rates, deltas or selection rates for the same
inputs go under Changed, as changelog.d/README.md says, with a note that
earlier figures are not comparable. The errors fields and the Errors
column are Added. The fragment has no test to fail; this is a prose change.

Thread: PRRT_kwDOVBg7g86xypBs
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@exai-bot

exai-bot commented Oct 30, 2026 •

Copy link
Copy Markdown

✅ OpenCodeReview: Review complete: 0 finding(s) across 21 selected item(s).

Review details
  • Commit reviewed: d604fbb against merge base b8afa8e
  • Coverage: 21 files in 4 groups
  • Engine: OpenCodeReview v1.13.3, us.openai.gpt-6.1-sol
  • Usage: 429k tokens (248k from cache), 33s
  • Tool calls: 46 (39 file_read, 6 code_search, 1 file_read_diff)
  • Arm execution hygiene, summaries, and reporting: 10 files
  • Reference evaluation, checklists, and skill selection: 5 files
  • LLM calls and cache behavior: 4 files
  • Answer formats and native log imports: 2 files

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@napetrov
napetrov requested a review from a team as a code owner October 30, 2026 05:28
Comment thread dokimi/tests/test_caching.py Outdated
assert caching.keeps(lambda _text: True, "") is False


def test_keeps_is_annotated_for_the_none_of_a_content_less_reply():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · low
This test asserts only the annotation, not behavior, so it checks no runtime outcome. mypy already enforces this, and test_a_content_less_reply_is_never_stored_or_replayed covers the None behavior. Consider dropping it.

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.

Keeping this one: it pins the Optional[str] annotation that d3fae29 relies on, so a re-added cast or a changed annotation fails a test. mypy would not notice a cast that keeps type-checking. Happy to drop it if you prefer the type-only check.

@antonwolfy antonwolfy left a comment

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.

Review at fb60380. The delta since my last review holds up. The agentic empty reply is an error cell before the shapers, the replay half of keeps is pinned, the absent errors count reads —, and the paired delta, the judge-failure-as-error rule and the remaining-judge-check skip all behave as described. One risk on the new agentic cell: nothing pins what it keeps for the skill-selection summary.

What I ran, on a Linux review host at fb60380:

  • make ci-local: 28 passed, 1 failed, 7 skipped. The failure is Run tests: 10663 passed, 4 skipped, 1 failed. The failed test is test_the_regex_limit_is_shared_by_every_pattern_of_one_check, a wall-clock bound (1.53 s against 1.5 s); it passes when run alone, and the PR does not touch it. The changed-line coverage step, mypy, schema_compat and the growth ratchet passed.
  • Reproductions for the code findings, with the LLM transport mocked.
  • A mutation run of the new tests (about 52 mutants across the reference, cache, arms and summary code). The survivor that matters is the one inline.
  • All 23 non-merge commits carry Signed-off-by.

Checked, no issues

  • Changelog: the score-moving items now sit under Changed, the errors field under Added, as the changelog README asks.
  • Judge failure: a judge outage gives errors=1, n=0, and an error on the check and on the cell; a negate check never turns a failed call into a pass; later judge checks make no call.
  • Empty replies: "", whitespace and None are never graded, judged, stored or replayed; in replay-only mode a stored empty entry raises CacheMiss.
  • Paired delta: delta_vs_baseline and the significance delta both read paired_vectors, so they use the same questions.

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

@vlad-perevezentsev vlad-perevezentsev left a comment

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.

Review at fb60380. All six points from my last review are fixed, and the new commits bring no new problems. One test gap is still open.

Agreed with @antonwolfy: no test checks that an agentic empty_reply cell keeps its tool_usage. The agentic empty-reply test passes "tool_usage": {} (tests/test_arm_cell_errors.py:148), and the new skill-selection test builds its cell by hand.

Agreed with @exai-bot: the new test in test_caching.py only checks the annotation of keeps(), which mypy already does.

What I ran, at fb60380:

  • slot ci: 18 passed, 0 failed, 13 skipped.

Checked, no issues

  • Changelog: the changes that move the numbers are now under Changed, errors is under Added, and a line says earlier figures are not comparable and how to re-run them.
  • Empty agentic reply in routing metrics: an empty_reply cell now counts toward the skill-selection n, and the run still counts in n_failed. So the same run now shows in both n and Failed of the Skill selection table. The commit message says this is on purpose, and I agree with it.
  • Judge checks after a judge error: the later judge checks are recorded as not judged and are not called. The deterministic checks still run.
  • paired_delta: it now takes its pairs from stats.paired_vectors.
  • keeps() typing: it takes Optional[str], and both cast calls in complete() are gone. The renamed test_a_null_entry_still_loads and the new replay-only case in test_llm.py test what their names say.

… summary scores

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>

# Conflicts:
#	dokimi/tests/test_caching.py

@vlad-perevezentsev vlad-perevezentsev left a comment

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.

LGTM

I checked d604fbb. Everything from my earlier rounds is fixed, and so are the open points from @antonwolfy and @exai-bot. An errored, empty or content-less reply and a failed judge call never become a score or a pass, and delta_vs_baseline is paired.

Merge: safe now: it merges cleanly into current main, 1 commit behind (a workflow file only). Once it is in, #255 conflicts with it in llm.py and needs a rebase.

What I ran, at d604fbb:

  • make ci-local --offline (lint, types, the full test suite, coverage of changed lines, docs build): 0 failed, 19 passed, 13 skipped (network and Docker jobs).

Checked, no issues

  • Agentic skill choice: a3a74df runs an empty agentic reply through the runner with a first-turn skill load, so the test fails if the cell loses its tool_usage.
  • Merge of main: the test_caching.py conflict with #178 is resolved, and the annotation-only test is gone. The behaviour tests for a None reply stay.

@napetrov
napetrov merged commit 72d7add into intel:main Oct 31, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants