Repository navigation
fix(judge): refuse a record with no question text on every judge path - #81
Conversation
evaluate_answers, the panel, ArmRunner.judge, pairwise, checklist, score_measure and the degradation pairs disagreed on a record whose question text is missing or blank: some sent "" to the judge, degrade skipped the record, evaluate_answers recorded a per-question error only for a missing key. A shared helper (dokimi.eval.question_text) now checks every record before the first judge call and raises EmptyQuestionError naming the record; the CLI prints "Error: ..." and exits 1. Follow-up to review on intel#80. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW8mAicb3ibXdzHMV83wDn Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
cross_judge.score_measure: keep main's judge_materials `store` argument and this branch's question-text check before any judge call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW8mAicb3ibXdzHMV83wDn Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #81 +/- ##
==========================================
+ Coverage 90.06% 90.19% +0.13%
==========================================
Files 205 206 +1
Lines 24193 24275 +82
Branches 3892 3904 +12
==========================================
+ Hits 21790 21896 +106
+ Misses 1724 1708 -16
+ Partials 679 671 -8
🚀 New features to boost your workflow:
|
The question-set reader accepts a whitespace-only question, and `arms run --judge` reached the judge's refusal only after every arm had answered every question; the run then exited 1 without writing its output, so the paid answers were lost. Check the questions where the judge is built, before the sampling probe and the first answer. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
Every judge path the fragment lists refuses before its first call, and each has a test that fails without the guard. One gap: Not covered, and not claimed by the fragment: RAGAS ( |
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 6140ec4. The guard itself is right: all seven listed paths refuse before their first judge call, the new tests fail without the fix, and the behaviour is consistent across paths. Three things are worth fixing before merge: one judge path still sends an empty question, the up-front check refuses records that would never be judged, and the exit code collides with the verdict code of eval judge-validity.
What I ran, on a Linux review host at 6140ec4:
make ci-local: 19 passed, 0 failed, 4 skipped; 5041 tests; changed-line coverage 100%.- Reproductions for the code findings, with the LLM transport mocked.
- A mutation run over the new code and the new tests: 39 mutants, 17 of which pass the new tests plus 23 related test files (1179 tests). The survivors are noted in the findings below.
Findings that cannot be anchored inline:
-
RAGAS still scores an empty question, so the title's "every judge path" is not true yet. I agree with @xaleryb that this path is outside the fragment's list. One detail matters for the fix:
_run_ragas_eval(commands/evaluate.py:66) wraps the call inexcept Exceptionand prints "RAGAS evaluation failed", so a guard added ateval/ragas_eval.py:112would be reduced to a warning unless that wrapper letsEmptyQuestionErrorthrough. Suggestion: add the guard there in this PR, or narrow the title and the Summary to the listed paths. -
Nit:
eval pairwise,eval checklistandeval cross-judge --judgesmake a billed probe call before the refusal. With--judge-temperature 0.2and a blank record,_check_judge_temperature(commands/judge_reliability.py:71,:120,:296) calls_probe_samplingfirst. Output:Error: question 'q2' has no question text ...,exit 1,probe calls before the refusal: 1. The cost is one 16-token call, but it contradicts "before the first judge call".arms run --judgeavoids this because its check sits before_probe_sampling. Theeval pairwiseCLI test omits--judge-temperature, so it cannot see this.
Checked, no issues
- Order of checks:
Judge.evaluate_answers,JudgePanel.evaluate_answers(after thelimitslice) andArmRunner.judgerun the check before the pool starts. The re-checks insideexcept Exception(judge.py:313,arm_runner.py:458) cannot fire after it, so the refusal is never turned into a per-row error. - Entry points: every command goes through
dokimi.cli.main; the refusal is raised on the main thread, so the handler catches it. No traceback. - Pins: the new tests fail against the base modules (23 of 31 fail; the 8 that pass are helper-only). Mutants that drop a guard in
judge.py,panel.py,arm_runner.py,degrade.pyorcommands/arms.py, change thestrip()check, drop thequestion/textfallbacks, or change the exit code or stream incli.pyare all killed. - Repo rules: SPDX headers present, changelog fragment follows
changelog.d/README.md, no schema orwith_docs/without_docschange, no nav change, tests offline.
| concurrency: int = 1, | ||
| progress: Optional[Callable[[int, int], None]] = None, | ||
| ) -> ArmPairwiseReport: | ||
| require_question_texts(records) # before any judge call: refuse, do not compare against "" |
There was a problem hiding this comment.
The up-front check refuses records that would never reach a judge, so one bad record now blocks the whole file. The check runs over every record before any filtering. Before the PR these were skipped. Cases that now raise EmptyQuestionError (all reproduced, judge never called):
run_arm_pairwise: the blank record has no baseline answer, or only arms excluded byarms=["skill"].run_checklist(checklist.py:283): the blank record has no expected points.score_measure(cross_judge.py:240): the blank record has only empty answers.build_pairs(degrade.py:603): the blank record has only an answer under the length floor, orarms: {}.Judge.evaluate_answers(judge.py:290): an answer-error record.answerer.py:168builds it withq.get("question") or q.get("text", ""), which ignoresquestion_text. With{"question_id": "q2", "question_text": "", "error": "timeout", "with_docs": None, "without_docs": None}plus one good record, the base returns rows for['q1', 'q2']after 2 judge calls. The PR head raisesquestion 'q2' has no question textwith 0 judge calls.
So one failed answer in a file blocks scoring of every other question.
Mutation check: deleting require_question_texts(...) from pairwise.py, checklist.py and cross_judge.py all pass the full related suite (1179 tests), because the per-record guard in the job loop still fires for every test record. No test pins which behaviour is intended.
Suggestion: decide the rule. If a blank question is only fatal when the record would be judged, drop the up-front call and check inside the job loops (still before the first judge call, since jobs are built first). If refusing everything is intended, say so in the fragment and add one test per path with a blank record that would otherwise be skipped.
|
|
||
| try: | ||
| args.func(args) | ||
| except EmptyQuestionError as exc: # a bad input record, not a bug: no traceback |
There was a problem hiding this comment.
Exit 1 for a bad input record collides with the verdict code of the judge-reliability commands. eval judge-validity documents "Exit 1 on FAIL" (docs/benchmarking-and-comparison.md:422, help at commands/evaluate.py:461), and eval judge-wording --strict and eval cross-judge --strict use 1 for a failed gate. Their input errors use 2 (evaluate.py:258-329, judge_reliability.py:95, :133).
Reproduction: eval judge-validity --answers a.json with a record {"question_id": "q9", "arms": {"baseline": {"answer": "short"}}}:
Error: question 'q9' has no question text (question_text is missing or empty); ...
EXIT 1
CI that reads 1 as "the judge failed validity" now reports a malformed answers file as a failed gate.
Suggestion: exit 2 here, as the other input errors in those commands do. The test at test_empty_question_rule.py pins exit 1 for eval pairwise, so it needs the same change. If 1 is kept, the exit-code paragraph in the docs should say a malformed record exits 1 as well.
| "deltas_vs_baseline": deltas, | ||
| } | ||
|
|
||
| require_question_texts(records) # before any judge call: refuse, do not grade against "" |
There was a problem hiding this comment.
The guard accepts the question and text spellings, but this method still reads rec["question_text"] two lines above, so such a record crashes after judge calls are paid. _score returns "question_text": rec["question_text"] at line 485. With a hand-built record that has only question: "How?", the check at 492 passes, score_answer is called once, then the call raises KeyError: 'question_text' and the whole judge() run fails. The refusal rule says "refuse before any call", so the guard and the reader must agree.
Suggestion: take the text once, e.g. text = require_question_text(rec), and use it for both the score_answer call and the returned row. The same applies to judge.py:313, where the error row now carries the text the guard found, but _evaluate_answer_pair was not exercised by a test on a question-only record (the Judge tests call evaluate_answers with a MagicMock as self).
| from dokimi.eval.question_text import require_question_texts | ||
|
|
||
| # The judge refuses a blank question; here, that is before any answer is paid for. | ||
| require_question_texts(questions) |
There was a problem hiding this comment.
Nit: two small mismatches around this check.
EmptyQuestionErroris aValueError, andcmd_arms_run(arms.py:84) andcmd_arms_matrix_run(arms.py:137) catchValueError. The refusal printsError building arms/plugins: question 'q2' has no question text ...(matrix:Error running matrix cell 'c1': ...), which blames arm setup for a bad input record. The handler incli.mainis never reached for these commands. The test only matches'q2'in stderr, so it passes either way.- The runner builds
question_textasquestion or text or question_text(arm_runner.py:224, first truthy wins), whilerequire_question_textstakes the first non-blank ofquestion_text,question,text. For{"id": "q1", "question": " ", "question_text": "Real?"}the check passes, every arm is answered, the record getsquestion_text = ' ', andArmRunner.judgerefuses after the answers are paid for. This is the case this check was added for.
Suggestion: re-raise EmptyQuestionError ahead of the ValueError handlers, and let the runner use require_question_text to build the record's text.
| if qid is None: | ||
| continue | ||
| # Refused, not skipped: a pair built without its question is graded against nothing. | ||
| qtext = require_question_text(item, i) |
There was a problem hiding this comment.
Nit: a record with no id is still skipped silently, so "refuses instead of skipping" and the record #N label do not apply on this path. The qid is None check above runs first, so {"question_text": " ", "arms": {...}} returns no pairs and no error (build_pairs(answers + [noid_blank]) returned without raising), and the record #N label can never come from here. Mutation check: removing the qid is None skip and changing i to i + 1 both pass the full suite, because every test record has an id. Suggestion: call require_question_text before the qid check, or say in the fragment that records without an id are still skipped.
| class EmptyQuestionError(ValueError): | ||
| """A record handed to a judge has no question text. | ||
|
|
||
| A ``ValueError``, like ``dokimi.eval.records.RecordError``: both mean a bad |
There was a problem hiding this comment.
Nit: the docstring cites dokimi.eval.records.RecordError, which does not exist at this head (grep -rn RecordError dokimi/dokimi finds only this line), and the getattr(record, "question_text") and getattr(record, "question_id") branches (lines 37 and 48) are unreachable because every caller passes dicts. They are covered only with a SimpleNamespace. Suggestion: drop the branches and the reference until #80 lands, and let the second PR to land add them with the real class.
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…e probe; exit 2 - RAGAS: check rows before the first RAGAS call; the command wrapper lets EmptyQuestionError through instead of turning it into a warning. - pairwise/checklist/cross-judge: the jobs are built (and checked) before the --judge-temperature sampling probe, so no billed call precedes the refusal. - A record that would never be judged (answer-error, empty answers, no points, no baseline, under the length floor) is skipped, not refused. - Exit 2, the input-error code of these commands; 1 stays the verdict code. - arms run/matrix: the refusal is no longer reported as an arm setup error; the runner reads the question with the guard's key order; ArmRunner.judge reads the text once for the call and the row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW8mAicb3ibXdzHMV83wDn Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Resolve conflicts with intel#81 (empty-question rule): the judge paths keep the record objects and call require_question_texts / require_question_text on the parsed records before the first judge call; the duplicate RecordError question check in Judge.evaluate_answers is replaced by intel#81's EmptyQuestionError (exit 2). checklist_jobs, pairwise_jobs and scored_answers read records. A record without question_id stays a RecordError (intel#80) in the degradation pairs; unreproducible_arms keeps intel#87's skip of malformed records. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW8mAicb3ibXdzHMV83wDn Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Resolve the conflict with intel#81 (empty-question rule) in build_pairs: keep the judge-material filter and per-arm counts, then refuse a blank question on every row that can still become a base, before any judge call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW8mAicb3ibXdzHMV83wDn Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Summary
Follow-up promised in the review of #80 (thread #80 (comment),
eval/judge.py:297): @antonwolfy noted that the judge paths disagree on a record whose question text is missing or empty. Onmain:Judge.evaluate_answers: a missingquestion_textkey raisedKeyErrorinside the per-answer try, so it became a per-questionerrorrow; an empty string went to the judge.degrade._answer_pool(judge-validity / judge-wording pairs): skipped the record silently.JudgePanel.evaluate_answers,ArmRunner.judge,run_arm_pairwise,run_checklist,score_measure: sent""to the judge.Rule chosen: refuse. Every path checks all its records before the first judge call and raises
EmptyQuestionError(aValueError) naming the record (question 'q2', orrecord #Nwithout an id). The CLI printsError: …and exits 1, no traceback.Changes
dokimi/eval/question_text.py:require_question_text(record, index)/require_question_texts(records)andEmptyQuestionError. Acceptsquestion_text, thenquestion/text(the spellings the panel already accepted); blank/whitespace counts as empty.judge.py,panel.py,arm_runner.py(judge),pairwise.py,checklist.py,cross_judge.py(score_measure),degrade.py(refuses instead of skipping).dokimi/cli.pymain()catchesEmptyQuestionError→Error: …, exit 1.changelog.d/20261016_190000_napetrov_empty-question-rule.md. No doc described the old behavior, so no doc change.Risk
question_text.RecordErrorinevaluate_answers. The helper here is small and self-contained; on rebase, refactor(eval): give answer and eval records one reader (eval/records.py) #80's check can callrequire_question_text(orEmptyQuestionErrorcan subclassRecordError). Expect a small conflict aroundjudge.py:297.Testing
tests/test_empty_question_rule.py(29 tests): helper; each path with missing /""/ whitespace question, asserting the judge is never called; question still passed through; CLIeval pairwiseexits 1 withError:.python -m pytest -q: 4742 passed, 1 failure (sandbox-only IPv6 test).ruff check .clean;mypy: only the 2 knownmcp_protocol.pyerrors (sandbox);config_check,schema_checkpass.Checklist
pytestpassesruff,mypy, and schema checks pass.github/.licenserc.yaml)🤖 Generated with Claude Code
https://claude.ai/code/session_01RW8mAicb3ibXdzHMV83wDn