Repository navigation
feat(judge-validity): --context stored — gate the judge the way arms are scored - #73
Conversation
The judge's context for each arm lived in an in-memory store for the answering
run only, so anything reading an arms artifact later — `eval cross-judge
--judges`, a library re-judge — gave its judge the 200-char report snippets
instead of what the arms run's judge saw (28 characters against 11,079 in one
review), and `cross-judge` had to refuse such comparisons outright.
* `ArmRunner.build_output` stamps each arm record with `judge_context_sha256`
and writes `judge_materials` ({digest: text}) once per distinct text: a skill
arm's body is the same for every question and is stored once (~4,500 chars
for the perf skill, +0.3% on an 81-question run). Judged or not, so an
answers-only artifact can be judged later on the same evidence. Each stored
score also names the material it saw.
* `JudgeContextStore.from_artifact` rebuilds the store from a file, skipping
(and warning about) any text missing or not matching its digest, so that
answer falls back to snippets visibly rather than being judged on other text.
* `cross-judge`: re-scored judges get the file's material, so `--judges` no
longer refuses an arm whose material the file keeps. `--pairwise` and
`--checklist` still do — those judges see only the answers.
* `arms.v1` gains `judge_materials` (additive; `schema_compat` OK).
Part of intel#48; the `judge-validity --context stored` mode builds on this.
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…are scored The gate judged every answer with no context, while an arms run judges skill and docs arms with the arm's full treatment material. So the setting the arm deltas come from was never validated, and it behaves differently: on 81 skill-arm answers the material raised intact scores 85.9 -> 89.9 while pairwise accuracy fell 0.869 -> 0.808, better on contradiction and worse on wrong tool names. `eval judge-validity --context stored` reads the artifact's `judge_materials` (the preceding commit) and judges each pair — intact and degraded alike — with its arm's stored material; a baseline arm's empty material is the no-context marker, as the arms run showed it. Bases whose arm has no stored material are dropped and counted (`n_without_context`), not scored without it, so the run measures one setting. The report records `context`. Sequential mode takes the same store. The typed and pairwise scorers have no context slot and are refused before any model loads; a file without materials exits 2, and a run left with no usable pair now exits 2 with the reason instead of a traceback. Part of intel#48. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #73 +/- ##
==========================================
+ Coverage 91.29% 91.46% +0.16%
==========================================
Files 216 216
Lines 26565 26877 +312
Branches 4337 4411 +74
==========================================
+ Hits 24252 24582 +330
+ Misses 1645 1631 -14
+ Partials 668 664 -4
🚀 New features to boost your workflow:
|
…producible unreproducible_arms(with_material=True) accepted any digest present as a key of judge_materials, while JudgeContextStore.from_artifact skips text that does not hash to it. An edited material passed the guard and the re-scored judge silently read 200-char snippets. The guard now uses the same check. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
digest_text raised UnicodeEncodeError on a lone surrogate, which a JSON \u escape can carry. In an arms run that aborted the judge phase before the artifact was written, and in JudgeContextStore.from_artifact it broke the documented skip. Encode with surrogatepass; digests of valid text are unchanged. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
Ran the stack (#73 Two defects, fixed in commits pushed here (red before):
Open, your call:
|
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 497a489. The core is sound: intact and degraded copies get the same material, a baseline arm gets the no-context marker, the typed and pairwise refusals happen before a model loads, and --context none is unchanged. Two things are worth fixing before merge: the [stored context] label means a stored-context report can never gate a cross-judge run, and --bases N is sampled before the bases without material are dropped.
xaleryb's two open points (sampling before filtering, and the label suffix not matching in apply_validity) are still unaddressed at this head. Both are reproduced below.
What I ran, on a Linux review host at 497a489:
make ci-local: 18 passed, 1 failed, 4 skipped. 4311 tests passed, 1 skipped. Changed-line coverage 95% (118 lines).ruff,mypy, bandit, pip-audit, mkdocs build all pass. The one failure isschema_compat: it reportstool_preference*.v1.jsonas removed becauseorigin/mainhere has moved past this branch's base (8193ce0). The only line that concerns this PR is a warning for the new optionalarms.v1.jsonjudge_materials, which is a widening and needs no version bump.- Reproductions for the code findings, with the LLM transport mocked (fake judge scorers, no model calls).
- A mutation run over the new code: 85 mutants, 28 survive the three test files and the same 28 survive the other test files that import these modules; about 19 are real gaps. The ones I re-ran are called out inline.
- The PR's new tests against the base modules: 17 of 18 new test items fail there, as they should. The one that passes is the "called exactly as before" guard.
Findings that cannot be anchored inline:
- The PR is stacked on #69.
git log origin/main..HEADholds656cf2c, andAGENTS.mdsays PRs are not stacked on each other's branches. The description already says to rebase after #69 lands; the diff reads as the sum of both until then.judge_context.pyalso ends with a blank line at EOF (\n\n). - Nit: some statements are now stale.
cross_judge.py:160still says the arms run "keeps it in memory only" and that a re-scored judge sees the 200-char snippets (true only for files withoutjudge_materials). Theget()warning injudge_context.pysays the same, andreport/model_compare.py:310says "re-judging an answers file only sees the 200-char report snippets".docs/evaluating-treatments.md:64says bases "whose arm has none" are dropped; the code drops bases with no stored entry, and a baseline arm (empty material) is kept and judged with the no-context marker. - Nit:
n_noopcounts no-ops from bases that were then dropped. With 30 no-ops and 10 bases dropped for lack of material, the summary printed30 no-opfor 20 scored bases (build_pairscounts beforeattach_stored_context).
Checked, no issues
- Schema:
judge_materialsis an optional addition toarms.v1.json, the same in-place wideningjudge_context_sourcesgot. A generated artifact validates and saves. NoSCHEMA_VERSIONSchange, nowith_docs/without_docschange, nomkdocs.ymlchange needed. - Pairs: every member of a pair,
mismatched_answerincluded, is scored with its base's arm material; the intact cache is keyed by(base_id, arm). - Refusals:
--scorer typed:...and--mode pairwiseexit 2 withJudgenever constructed. A list-shaped answers file and a file withoutjudge_materialsexit 2.--sequential --context storedruns and reportscontext: stored. - Digests:
from_artifactskips text that does not hash to its digest;digest_textwithsurrogatepassleaves valid-text digests unchanged. - Fixes 41316ba and 497a489: the digest check in
unreproducible_armsand the surrogate encoding are both killed by the mutants I tried.
…n apply_validity - Remove ' [stored context]' suffix from judge label in evaluate.py (line 348). The context is tracked separately in the summary report; the label should be clean. - Update apply_validity() to match on (judge_label, context) pairs instead of label alone. A stored-context validity gates only measures scored with material (those with ' (stored)' suffix). A none-context validity gates only measures scored without material. This prevents a none-context validity from incorrectly gating stored measures (THREAD 0). - Update test to verify both paths: stored-context validity gates stored measures, none-context validity gates non-stored measures. - Fixed: remove the non-empty material check enhancement to evaluate.py since it was already covered by the existing logic. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…l before sampling - Add `context` field to Measure class: "stored" when scores come from stored material or have material in store, "none" otherwise - Update apply_validity() to match on (label, context) pairs - Filter answer pool by material in store before slicing n_bases - Count dropped (qid, arm) pairs and report in n_without_context - Refuse when store is provided but contains no material - Update test to verify draw parameter forwarding - Remove stale docstring about material being "in memory only" - Fix stale docs statement about dropped bases - Remove trailing blank line at EOF Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…-count The pre-count of dropped bases was buggy: it used hardcoded 20 instead of DEFAULT_MIN_CHARS and counted from the entire pool, not from the n_bases sample. Move the count into build_pairs (which filters by material before sampling) and use the result from attach_stored_context, which sees what actually got dropped. Also fix cross_judge.py:253 to check for non-empty materials correctly: a store with only empty baseline shouldn't be marked as "stored". Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Add 4 targeted tests: - M1 (cross_judge context forced to none): test store with only empty materials - M3 (build_pairs pre-filter disabled): test dropped count from filtering - M4 (store=None in cross-judge): test fallback to context_chunks - M5 (exit code 2 to 1): test exit code on edited material Each test fails when the corresponding fix is reverted. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…-2 error - Report per-arm kept/dropped (summary["by_arm"], printed) for --context stored, and refuse when an arm that has answers has no verified material. - Check for no-op pairs before "no stored material" so an all-no-op run is not reported as missing material. - Add NoUsablePairs(ValueError) and catch only it for exit 2, so a ValueError from a bug in a degradation stays a traceback. - Reword "none stored" to "no verified material" and the JudgeContextStore.get warning that wrongly claimed a snippet fallback on the validity path. - Replace the vacuous *_kills_m1/m4/m5 tests: score_measure context with real vs empty material, CLI cross-judge wiring (material reaches the judge, edited material exits 2, --judges with --pairwise exits 2), judge-validity exit 2, --context stored in fixed and --sequential mode, baseline-only artifact exit 2, and judge-validity --context stored then cross-judge --validity. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…efix Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
On the review-body points:
|
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 81725fa. Seven of the eight earlier threads are fixed, and the fixes are tested: the label is provider/model again, --bases samples after the filter, a dropped arm is refused, NoUsablePairs keeps real ValueErrors loud. What is left sits in the new (label, context) matching and in one report field that is wrong in sequential mode. One earlier thread (unreproducible_arms) is still open. I'd fix the first three inline comments before merge.
What I ran, on a Linux review host at 81725fa:
make ci-local(Python 3.13): 19 passed, 0 failed, 4 skipped. 4,325 tests passed, 1 skipped. Changed-line coverage 98% (156 lines).ruff,mypy, bandit, pip-audit,schema_compatand the mkdocs build pass.- Reproductions for the code findings, with the LLM transport mocked (fake judge scorers, no model calls).
- A mutation run over the new code: 44 mutants, 43 killed by the touched test files. The survivor is the
n_without_contextassignment inrun_sequential(see the inline comment). - The new tests against the base modules: they fail there, as they should.
Still open from my earlier review:
cross_judge.py:184:unreproducible_arms(with_material=True)decides from each score'sjudge_context_sha256, whileJudgeContextStore.from_artifactreads the digest from the answer's arm record. With the arm digests removed and the scores andjudge_materialsintact, the guard returns[],cross-judge --judgesexits 0, and the re-scored judge sees'snip'where the intact file gives'FULL SKILL BODY...'. You said you would move this into #69; it is not in this branch yet. Non-blocking.
Findings that cannot be anchored inline:
- The PR is still stacked on #69.
git log origin/main..HEADstarts with656cf2c. You said you will rebase once #69 lands; the diff reads as the sum of both until then. - Nit: the comment in
commands/judge_reliability.py:223is stale. It says every other measure "sees at most the report snippets", but a re-scored judge now getsjudge_materials, as the lines below say. - Nit: the
--contexthelp text, the changelog fragment and both docs pages list fewer exit-2 cases than the code.--context storedwith--mode pairwiseor a typed scorer also exits 2 (evaluate.py:284). The docs sayeval pairwise/eval checklistpass no context by default, but those commands have no--contextflag.
Checked, no issues
- Label and matching: a
--context storedPASS or FAIL report gates both the stored measure and the--judgesmeasure that saw material. A--context nonereport gates only the no-material measure. A report with a(stored)suffix matches nothing, as on main. - Sampling: with 27 of 120 eligible answers holding material,
--bases 10and--bases 20score exactly 10 and 20 bases, fixed and sequential.--bases 30scores all 27.n_without_contextis not double counted, andby_armreconciles with it, duplicate rows included. - Refusals: a whole arm without verified material, a baseline-only artifact, typed and pairwise scorers, and
--judgeswith edited material or with--pairwiseall exit 2 before any scorer call. A bugValueErrorinside a degradation is not caught. - Schema and repo rules:
judge_materialsis an optional addition toarms.v1.json, soschema_compatreports a widening, not a break. No generated artifacts, nomkdocs.ymlchange needed, all commits signed off. - Tests: dropping
store=materialsin thecross-judgewiring, never skipping on context, and droppingwith_material=Trueare each caught now.
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 1210bd3. Nothing in the code changed since my last review at 81725fa; the only new commit adjusts how two tests assert the exit code, and it does not weaken them. All nine earlier threads still apply, so this is not ready to merge yet: the --sequential report blames the cap when the pool ran out, and the (label, context) matching in cross-judge has two gaps.
What I ran, on a Linux review host at 1210bd3:
make ci-local(Python 3.13): 19 passed, 0 failed, 4 skipped. 4,325 tests passed, 1 skipped. Changed-line coverage 98% (156 lines).- Reproductions for every open thread below, with the LLM transport mocked (fake judge scorers, no model calls).
- The mutation from my earlier review,
report.n_without_context = dropped[0]->= 0inrun_sequential: all 384 tests intest_judge_validity.pyandtest_judge_reliability.pystill pass.
Still open from my earlier review:
cross_judge.py:184:unreproducible_arms(with_material=True)reads digests from the scores,from_artifactfrom the arm records. With the arm digests stripped it returns[]and the re-scored judge seessnip(4 chars) where the intact file gives the 320-char material. Non-blocking: hand-edited or merged files only. (risk)judge_sequential.py:177:stopped_byiscapwhen the pool with material ran out. 100 long skill answers, 10 with material,--max-bases 50reports "cap reached" after 10 pairs; with all 10 holding material it reportspool. (bug)cross_judge.py:189:stored_measureis alwayscontext="stored". Asnippet_fallbackor pre-judge_contextfile can only be gated by anonereport, and that report now matches nothing (gate: None, "matches no measure"). (risk)cross_judge.py:301: a FAIL does not cross contexts. A--context storedFAIL leaves the coverage and pairwise measures voting; a--context noneFAIL leaves the grounded measure voting. The docstring andbenchmarking-and-comparison.md:399-400say otherwise. (risk)judge_sequential.py:145: nothing pinsn_without_contextin sequential mode (mutant above survives). (risk)judge_validity.py:361: "N bases dropped" andby_armcount answers before--basessampling, not bases. (nit)degrade.py:655:row in has_materialis a list scan;build_pairswith a store takes 0.21 s at 5k rows, 0.84 s at 10k, 3.49 s at 20k (no store: 0.02 s at 20k). (nit)benchmarking-and-comparison.md:402: the stored measure isprovider/model (stored), notprovider/model. (nit)evaluate.py:344: the "this file has none" error is printed for files that do holdjudge_materialsbut no usable text (only the empty baseline text, edited text, or no record citing a matching digest). (nit)
Findings that cannot be anchored inline:
- Nit: the exit-2 cases are still listed incompletely. The
--contexthelp (evaluate.py:500-503), the changelog fragment (line 7) and both docs pages name only the lost-arm and no-usable-pair cases.--context storedwith--mode pairwiseor a typed scorer (evaluate.py:284) and with a file that has no usable material (evaluate.py:344) also exit 2.benchmarking-and-comparison.md:410andevaluating-treatments.md:62sayeval pairwise/eval checklistpass no context "by default", but those commands have no--contextflag. I raised this at 81725fa; it is unaddressed. - Nit:
report/model_compare.py:310(outside this diff) still says re-judging an answers file "only sees the 200-char report snippets". That is false for a file withjudge_materials. - The PR is still stacked on #69.
git log origin/main..HEADstarts with656cf2c. You said you will rebase once #69 lands.
Checked, no issues
- Test change in 1210bd3:
codestaysNoneunless aSystemExitis raised, and the command exits only 1 or 2, soassert code in (None, 1)accepts whatexc.code == 1accepted. The exit-2 refusal is pinned bytest_cli_refuses_stored_context_it_cannot_honour. n_noop: counts only kept bases now (20 long answers, 10 with material, 5 no-op forwrong_numbers->n_noop {'wrong_numbers': 5}).- Exit 2: only
NoUsablePairsis caught; a bugValueErrorin a degradation stays loud. - Schema and repo rules:
judge_materialsis an optional addition toarms.v1.json; noSCHEMA_VERSIONS,with_docs/without_docsormkdocs.ymlchange needed; no generated artifacts; tests are offline. digest_textwithsurrogatepass: artifacts are written withensure_ascii=True, so a lone surrogate round-trips; digests of valid text are unchanged.
…ure, FAIL across contexts - run_sequential counts only answers with verified material toward the pool when a store is given, so an exhausted pool reads 'pool', not 'cap'. - stored_measure is context 'stored' only when its scores were judged on stored material; snippet_fallback and pre-judge_context files are 'none'. - apply_validity: a FAIL reaches every measure of the judge in any context; a PASS only its own context. A context mismatch is named in the warning. - build_pairs decides kept/dropped in one pass and records sampled bases per arm; the summary says the dropped count is before --bases sampling. - Reword the no-material error, the from_artifact warning, a stale comment, the docs label paragraph and the changelog. 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>
apply_validity depended on report order: a stored-context FAIL applied before a no-context PASS was replaced by PASS. A FAIL is now final, so any order gives the same gates. 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>
135301a to
40ffb49
Compare
|
Re-ran at 40ffb49: sampling after the filter (30 of 30 bases on every seed, was 19-23), the whole-arm refusal, the warning text and Remaining for the rebase: this branch sits on #69's first commit 656cf2c, not on #69's head, and conflicts with it in 5 files. On this branch alone |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
9 open issues
Measure.contextis documented as “stored when every score saw the stored material, none when none… · Newscore_measure()setsMeasure.contextbased on whether any non-empty material exists in the… The stored-context plumbing relies on two “hidden channel” mutations: (1)droppedas a… · New The stored-context plumbing relies on two “hidden channel” mutations: (1)droppedas a… · New The stored-context plumbing relies on two “hidden channel” mutations: (1)droppedas a… · New Relying on a dict-key check plus# type: ignore[call-arg]makes it easy for signature mismatches… · New When pairs carrycontext,_call()unconditionally invokes the scorer with acontext=kwarg.… Using a sentinel key like\"_kept_count\"inside the public-ishby_armstructure makes the data… This error is raised fromrun_validity()(fixed-n mode), but the guidance mentions--max-bases,…
Review effort: Lite
antonwolfy
left a comment
There was a problem hiding this comment.
Review at ccf04a1. The --scorer cleanup is right, and the fixed-mode refusal now catches an arm whose bases were all no-ops. Two of the three threads from my last review are not closed, and one earlier point (a re-scored measure labelled stored) is still in the code. I'd fix those before merge.
What I ran, on a Linux review host at ccf04a1:
make ci-local: 19 passed, 0 failed, 4 skipped. 5,219 tests passed, 1 skipped. Changed-line coverage 100%.ruff,mypy, bandit, pip-audit,schema_compatand the mkdocs build pass.- Reproductions for the code findings and for each open thread, with the LLM transport mocked (fake scorers, no model calls).
- A mutation run over the new tests:
sum(1 for _, a in scored_bases)withoutif a == armpasses all 430 tests intest_judge_validity.pyandtest_judge_reliability.py.
Findings that cannot be anchored inline:
- A re-scored measure is still labelled
storedwhen some answers were judged on snippets, so a--context storedPASS validates it. I raised this in my last review body;cross_judge.pydid not change.score_measuresetscontext="stored"as soon as the store holds any non-empty material (cross_judge.py:282-283) and ignores the per-answer source thatresolve_with_sourcereturns. Two answers, one with intact material and one whose material was edited after the run:
seen [('a', 'TREATMENT MATERIAL'), ('b', 'snip2')]
context stored
apply_validity(stored PASS) -> gate PASS
stored_measure on the same file -> mixed
Through the CLI, cross-judge --no-stored with a stored PASS report shows ✓ in the Gate column for the arm judged on a snippet. Suggested fix: derive the context from the sources of all scored jobs, with the rule stored_measure uses (stored if all, none if none, mixed otherwise). test_judge_reliability.py covers all-material, empty-store and no-store only; add a store with one arm edited.
Still open from my earlier review:
judge_validity.py/judge_sequential.py:190-192: the starved-arm refusal exists only inrun_validity.run_sequentialhas none, and the oldsampledcheck was removed from_build. 100 baseline answers and 3 skill answers with material,max_bases=50,variants=["truncate_20"], fake length scorer: seeds 0, 1, 3 and 5 of 8 finish PASS with skillscored: 0; the summary showsper arm : skill: 3 kept, 0 dropped, 0 bases scoredandRESULT: PASS. The help text, changelog and docs promise exit 2 for an arm that "gets none sampled". Suggestion: run the check in_buildon the arms present inpairs(see the inline comment), and in sequential mode onscored. (bug)judge_sequential.py:191: the per-arm filter inscoredis still not pinned.test_sequential_per_arm_scored_counts_differuses one arm, so it cannot tell the filtered count from the total. Mutation check: droppingif a == armpasses all 430 tests. The test needs two arms where one is scored on fewer bases than the other, asserting the twoscoredvalues differ. (nit)
Checked, no issues
--scorercheck:--scorer bogus, with and without--context storedand with--mode pairwise, exits 2 withunknown --scorer 'bogus'and never builds aJudge.--scorer typed:...and--mode pairwisewith--context storedstill exit 2 with their own messages. The changelog no longer claims the check as new._kept_count: it does not reachby_armin the returned report, the summary orto_dict()in fixed or sequential mode.- Fixed-mode no-op case:
variants=["strip_code"], 10 baseline answers with a fence and 3 skill answers without one, raisesNoUsablePairsnaming['skill']. - Repo rules: no generated artifacts or
uv.locktracked, no foreign commits on the branch, one changelog fragment, all commits signed off, no schema ormkdocs.ymlchange needed, tests offline.
…d arms before scoring - cross_judge: a measure is stored/none/mixed from each answer's own context source, not from whether the store holds any material, so a partly snippet-judged measure can no longer validate a --context stored PASS. - judge_validity: the fixed-mode starved-arm check runs before any judge call and reads by_arm directly; the _kept_count sentinel is gone (sequential too). - A scorer without a context= argument is refused up front when a store is used. - Tests: strip_code no-op leaving an arm unpaired, scorer signature refusal, mixed vs stored measure labels. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Replace the one-element list out-param for the dropped count with a frozen result dataclass, so the stored-context path no longer threads mutable state through _build. Callers in judge_sequential and judge_reliability updated. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
… too The arm-without-pair refusal ran only in run_validity. With --sequential --context stored, --max-bases could leave an arm with verified material out of the pool and the gate still passed on the other arms, which the help, changelog and docs say exits 2. Both modes now share one check. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
Re-ran fbb7017 and its merge with main: full suite, ruff, mypy, plus reproductions with a fake scorer (no model calls).
|
antonwolfy
left a comment
There was a problem hiding this comment.
Review at a3ba73b. The changes since ccf04a1 are right: score_measure now derives stored / none / mixed from the per-answer source, the starved-arm refusal runs before any judge call, and _build returns Built. One bug remains in --sequential, and two nits.
What I ran, on a Linux review host at a3ba73b:
make ci-local: 19 passed, 0 failed, 4 skipped. 5,223 tests passed, 1 skipped. Changed-line coverage 98% (160 lines).ruff,mypy, bandit, pip-audit,schema_compatand the mkdocs build pass.- Reproductions for the code findings, with the LLM transport mocked (fake scorers, no model calls).
- Mutation runs over
refuse_unpaired_armsandscore_measure:kept > 1, droppinga not in paired_arms, swapping the{True}/{False}branches, mappingmixedtostoredornoneare all killed. The empty-flags mutant survives (inline).
I agree with @xaleryb's point on the sequential early stop (comment of 2026-10-20). New evidence: seeds 0, 1 and 5 of 8 finish PASS with skill: scored 0 at this head, a3ba73b included.
Checked, no issues
mixedcontext: a store with an edited or missing digest for one arm givesmixedinscore_measure; a stored PASS leavesgate=Noneand a stored FAIL still gates. Real arms files cite the empty baseline digest (arm_runner.py:288), so baselines resolve asstored.require_context_scorer: acceptsjudge_scorer(...), aMagicMockand apartialover a function that takescontext; refuses apartialor callable object without it.- Fixed mode: the refusal runs on the built pairs before any scorer call; a
strip_codeno-op on the skill arm raisesNoUsablePairsnaming['skill']with zero calls. - Repo rules: no schema change, no
mkdocs.ymlchange needed, one changelog fragment, no generated artifacts, tests offline.
… scored answer; name the real remedy in refusals A measure whose answers are all blank sends no job to a judge, so it must stay context "none" even when a store is passed. Add a test that fails if that case is labelled "stored". The refusals for starved and lost arms told the user to "drop those arms", but the CLI has no such option; they now say to remove the arms from the answers file. Co-Authored-By: Claude Code <noreply@anthropic.com> Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 3aacf7c. The two nits from my last review are fixed and tested. The sequential-mode bug is still there, so this is not ready to merge.
What I ran, on a Linux review host at 3aacf7c:
make ci-local: 19 passed, 0 failed, 4 skipped. 5,224 tests passed, 1 skipped.ruff,mypy, bandit, pip-audit,schema_compatand the mkdocs build pass.- A reproduction of the sequential thread with a fake length scorer (no model calls).
- A mutation run over the
score_measurecontext rule: empty-flags ->stored/mixed, mixed ->stored/none, swapped branches and constant flags are all killed by the touched test files.
Still open from my earlier review:
judge_sequential.py:143:--sequential --context storedstill finishes PASS with an arm that has verified material and no scored base. a3ba73b checks the built pool only. 100 baseline answers, 3 skill answers with material,max_bases=50,variants=["truncate_20"], seeds 0, 1 and 5 giveskill: kept 3, sampled 1, scored 0and a PASS on 20 baseline bases; seeds 2, 4, 6 and 7 score 1-2 skill bases, seed 3 is refused. The help, changelog and both docs pages promise exit 2 for an arm that "gets none sampled". Suggestion: after the loop, refuse (or warn on stderr) when an arm withkept > 0hasscored == 0, or keep scoring until every pooled arm has one base, and add a sequential test over a few seeds.
Checked, no issues
- Empty-measure label:
test_score_measure_with_no_scored_answer_is_context_none_even_with_a_storefails when the empty-flags branch is changed tostoredormixed. - Refusal wording: both the starved-arm and lost-arm messages now end "raise , or remove those arms from the answers file"; no other "drop those arms" is left in the code, docs or changelog.
| sources = set() | ||
| for ev in data.get("evaluations") or []: | ||
| for arm, s in (ev.get("scores") or {}).items(): | ||
| if isinstance(s, dict) and s.get("aggregate") is not None: | ||
| m.scores[(ev["question_id"], arm)] = float(s["aggregate"]) | ||
| sources.add(s.get("judge_context_source") == SOURCE_STORED) | ||
| # Per score: "stored" only when every score saw the stored material, "none" | ||
| # when none did (snippet_fallback, pre-judge_context files). A file mixing | ||
| # both is "mixed": no single-context PASS describes it, so none validates it | ||
| # (a FAIL of the judge still reaches it). | ||
| m.context = {frozenset({True}): "stored", frozenset({False}): "none"}.get(frozenset(sources), "mixed") |
There was a problem hiding this comment.
This branch no longer merges with main: #80 replaced the dict reads here with records.arm_scores() and Score objects, so stored_measure and score_measure conflict. Please rebase and read judge_context_source from Score.raw (and the answer from Answer.raw in score_measure).
There was a problem hiding this comment.
Fixed in 50174e3. Merged main instead of rebasing, so the push needs no force. The conflict is in cross_judge.py: stored_measure now reads evaluations through arm_scores and takes judge_context_source from Score.raw, and score_measure takes the answer from Answer.text and resolves context from dict(Answer.raw). The branch merges cleanly with intel/main now. Tests: full suite green apart from one unrelated socket-guard failure in test_pipeline_question_set.py in this sandbox.
| if starved: | ||
| raise NoUsablePairs( | ||
| f"Arm(s) {starved} have verified judge material but no base was scored, " | ||
| f"so the gate would not cover them: raise {flag}, or remove those arms from the answers file" |
There was a problem hiding this comment.
When every kept base of the arm was sampled and only its degradations were no-ops, a higher --bases cannot help, but the message still says to raise it. Suggest the flag only when by_arm[a]["sampled"] < by_arm[a]["kept"], and name the no-op variants otherwise.
There was a problem hiding this comment.
Fixed in 50174e3. refuse_unpaired_arms now says "raise " only when an arm has a kept base that was not drawn (sampled < kept). When every kept base was sampled, the message names the requested variants that left the text unchanged and says a higher flag cannot help. Regression: test_refusal_does_not_say_raise_the_sample_when_every_kept_base_was_sampled, red on the old message.
| hits += 1 | ||
| elif m.context != v_context: | ||
| # A PASS validates only the (label, context) it was measured in. | ||
| other_contexts.add(m.context) |
There was a problem hiding this comment.
This also collects the context of grounded and coverage measures, which a PASS never validates.
With only a grounded "stored" measure the note says the measures are "stored", but a --context stored PASS still leaves gate=None. Only add m.context here when m.kind is "aggregate" or "pairwise".
There was a problem hiding this comment.
Fixed in 50174e3. apply_validity skips grounded and coverage measures for a PASS, so their context is never collected or named in the note. Regression: test_a_pass_in_one_context_never_names_the_context_of_a_grounded_measure, red on the old code.
Resolves the conflict with intel#80 (Score/Answer objects): stored_measure reads evaluations through arm_scores and Score.raw, score_measure resolves context from Answer.raw. Also: - apply_validity: a PASS no longer notes the context of grounded or coverage measures, which it never validates. - refuse_unpaired_arms: say "raise <flag>" only when a kept base was not drawn; when every kept base was sampled, name the requested variants that left the text unchanged instead. - judge-validity --context stored: re-read the artifact for from_artifact (the merge had left a reference to the removed `data` name). Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
009156a to
af67719
Compare
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…scored bases The SPRT stops once every variant is decided, which can happen before an arm with verified material is reached; the run then passed without covering it. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…nscored The post-loop refusal for an arm with no scored base also fired when the SPRT had decided FAIL, so a judge that failed exited 2 with no report. The refusal now applies only to a run that would pass; docs list the case. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
…ored arm The refusal said to raise --max-bases, but past the pool size the SPRT stops at the same base, so the same refusal comes back. It now points to fixed mode, whose own --bases refusal can be met, or to removing the arm. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
Re-ran every earlier point's original input at d441c7a with fake judges (no model calls), plus the full suite, ruff, mypy and bandit.
Pushed:
Left for the author: nothing in this PR. The red License header check also fails on main. |
antonwolfy
left a comment
There was a problem hiding this comment.
Review at b5b420b. The sequential-mode thread is fixed: a stored --sequential run now refuses after the loop when it would pass with an arm unscored, and a FAIL still stands. One gap in how "scored" is counted is worth fixing before merge, plus two nits.
What I ran, on a Linux review host at b5b420b:
make ci-local: 26 passed, 0 failed, 4 skipped. 6,405 tests passed, 1 skipped. Changed-line coverage 98% (171 lines).ruff,mypy, bandit, pip-audit,schema_compatand the mkdocs build pass.- Reproductions for the findings below, with the LLM transport mocked (fake length scorers, no model calls).
- A mutation run over the new block in
run_sequential: 18 mutants. 14 are killed (including dropping or inverting thepassedtest, and hard-codingscored), 4 survive:kept > 1, droppingsorted(...), rewording the second remedy, and the redundantstoreguard.
Still open from my earlier review: none.
Checked, no issues
- FAIL path:
strip_codePASS withtruncate_20FAIL, and a constant-score FAIL, return the report withpassed=Falseand no refusal. The CLI exits 1. - Refusal path:
--context stored --sequential --max-bases 50 --seed 0 --variants truncate_20 --output out.jsonexits 2, prints the arm on stderr and writes no output file. --max-basesclaim: with 100 baseline and 3 skill answers,--max-bases50, 103 and 400 give the same outcome on seeds 0-7 (refused on 0, 1, 2, 4, 5; pass on 3, 6, 7).- Other arms and concurrency: with three arms the message names only the starved one, and
concurrency=4behaves likeconcurrency=1. - Repo rules: no schema,
mkdocs.ymlor generated-artifact change; tests are offline.
| # The loop stops once every variant is decided, which can happen before | ||
| # an arm's bases are reached; the gate would then pass without covering it. | ||
| # A FAIL on the bases that were scored stands whatever the other arms hold. | ||
| unscored = sorted(a for a, c in by_arm.items() if c.get("kept", 0) > 0 and c["scored"] == 0) |
There was a problem hiding this comment.
A base scored only by a neutral variant counts as "scored", so the gate can still pass with no graded pair from an arm.
scored_bases is filled from every pair in todo (judge_sequential.py:169), including padding, which no gate decision uses. Fixed mode refuses this case because it looks at rows. 100 baseline answers with a code fence, 3 skill answers without one, max_bases=120, variants=["strip_code", "padding"], scorer len(a):
seed 3 OK skill: {'kept': 3, 'sampled': 3, 'scored': 1} skill rows: strip_code 0, padding 1 passed True
seed 8 OK skill: {'kept': 3, 'sampled': 3, 'scored': 1} skill rows: strip_code 0, padding 1 passed True
seeds 0, 1, 2, 4, 5, 6, 7, 9 -> refused
The skill arm has no graded row, yet the run reports PASS with context: stored. Whether it refuses depends on the seed.
Suggestion: count an arm as scored only when a graded (non-neutral) pair of one of its bases was scored, e.g. build the set from p["variant"] not in NEUTRAL_VARIANTS. Tests to add: this strip_code + padding case, one skill answer instead of three (mutation check: c.get("kept", 0) > 1 passes both test files), and two unscored arms asserting the sorted list in the message.
There was a problem hiding this comment.
Fixed in be965f2. scored_bases is now built only from pairs whose variant is not in NEUTRAL_VARIANTS, so a base scored only by padding no longer covers its arm. Your repro (100 fenced baseline / 3 skill answers, strip_code + padding) now refuses for seeds 0-9, including 3 and 8. Tests: test_sequential_padding_alone_does_not_cover_an_arm (3 skill answers and 1 skill answer, seeds 0-9) and test_sequential_unscored_arm_refusal_names_every_arm_sorted (asserts "['alpha', 'zeta']"). All three fail on the old code.
…ext help Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
antonwolfy
left a comment
There was a problem hiding this comment.
I pushed a commit per nit, up to 02d7397, which fix the two nits from my review:
2344f45:dokimi/dokimi/commands/evaluate.py:526: the--contexthelp and the first changelog fragment now list the--sequentialrun that would pass with none of an arm's bases scored.02d7397:dokimi/dokimi/eval/judge_sequential.py:209: the refusal now says to run without --sequential and with --bases at least the number of answers, or remove those arms.
make ci-local at 02d7397: 26 passed, 0 failed, 4 skipped.
Not approving yet. Still open: the risk from my review.
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
@antonwolfy thanks for 2344f45 and 02d7397, both kept. The open risk from your b5b420b review (a base scored only by a neutral variant counted as scored) is fixed in be965f2, on top of your commits: |
refuse_unpaired_arms counted a padding pair as covering an arm, so fixed mode with --variants strip_code,padding passed on baseline rows alone when strip_code left the skill answers unchanged, and the sequential refusal sent users to that run. Only a graded pair now covers an arm. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
Re-ran every earlier point's original input at be965f2 with fake judges and no model calls. Also ran the full suite, ruff, mypy and bandit.
Pushed:
Left for the author: nothing. |
…ntel#115) intel#73 grew test_judge_validity.py 1423 -> 2235, test_judge_reliability.py 1505 -> 1695, model_compare.py 1296 -> 1297, cmd_eval_judge_validity 25 -> 29, and took degrade.build_pairs (17) and judge_sequential.run_sequential (12) over the cap. intel#115 grew l0 _build_names 14 -> 15 and shrank _cmake (now under the cap, entry dropped) and Scan.resolve_path (20 -> 19). Each raise names its PR. Now 15 files and 99 functions. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
* ci: no-growth ratchet for file length and function complexity A first-party .py file over 800 lines (1000 under tests/) or a function over cyclomatic complexity 10 (ruff's C901 count, computed with ast so the test job needs no ruff) now fails tests/test_growth_ratchet.py unless scripts/growth_baseline.json records it. A recorded value is exact: the code may shrink but not grow, and a shrunk, gone or back-under-cap entry must be lowered or removed (--update does that, never raising). An entry raised or added relative to the base branch needs a rationale of at most 300 characters. The baseline holds the 14 files and 81 functions over the caps today. `check_growth.py --report --markdown` adds the totals to the static job's step summary, non-blocking. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * fix(ci_local): pass resolved base ref to growth check via DOKIMI_GROWTH_BASE ci_local.py resolves the base ref to handle non-origin remotes, but did not pass it to the pytest environment. This caused the growth check to use the wrong baseline or skip the rationale check if the baseline was missing, allowing misleading local CI passes when using --remote with a non-origin remote. Pass the runner's resolved base_ref as DOKIMI_GROWTH_BASE environment variable to the subprocess steps. Add a regression test that verifies the check correctly uses a non-origin remote's baseline. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * ci(growth): baseline evaluate_novelty at 26, as #72 left it on main Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * feat(ci-local): pass resolved base_ref as DOKIMI_GROWTH_BASE env var ci_local.py now passes the resolved base reference to workflow steps as DOKIMI_GROWTH_BASE, allowing check_growth.py to use it without requiring --base argument when ci_local is used to run workflows locally. Adds test to verify the env var is passed to all subprocess steps. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * fix: restore the files f105136 reverted by mistake f105136 was built on a stale tree and deleted the #72 and #84 changes from this branch. Bring every file back to 6c4cc20; only the test_ci_local.py addition of f105136 stays. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * fix(growth): a raised entry needs a new rationale; test loop/try else and match guards Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * fix(ci_local, check_growth): propagate DOKIMI_GROWTH_BASE to isolated jobs and guard TryStar Two fixes for growth ratchet compatibility: 1. Isolated jobs (package-smoke) did not receive DOKIMI_GROWTH_BASE from base_env, causing the growth check to compare against the wrong baseline. Now included in env. 2. ast.TryStar is only available on Python 3.11+ (introduced with except*). Guarded with hasattr() so the script remains compatible with Python 3.10. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * fix(ci_local, check_growth): cover isolated-job env and drop dead TryStar guard Add a regression test for DOKIMI_GROWTH_BASE reaching the isolated package-smoke job, and a try-star complexity case. ast.TryStar always exists on the supported Python (>=3.11), so the hasattr guard is removed. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * refactor: split functions over the complexity cap Extract helpers from cmd_answers_generate (runtime config, question loading, output path) and from the import-architecture test (import targets, owner module, Tarjan component pop, spawn-import aliases) so each is under the cap. cmd_answers_generate is back under its cap, so its baseline entry is removed. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * test(answers): cover the helpers answers generate was split into The refactor moved config loading, question loading and output-path choice into helpers that the existing tests never reach, leaving 12 changed lines uncovered (diff-cover gate: 61%, needs 80%). The new tests cover the missing-, empty-, invalid- and non-mapping config cases, the question load error, the output-path precedence, and the command end to end over fake Answerer and documentation-source clients. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * ci(growth): take the #101 growth into the baseline; changelog fragment Main's #101 grew dokimi/novelty/bundle.py to 1016 lines and evaluate_novelty to complexity 29 after the baseline was cut, so the ratchet failed on code this PR does not touch. Raise both entries with a rationale, as the #72 entry already does. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * fix(growth): compare rationale against the fork point, not the base tip The rationale check read the baseline at the tip of origin/<base>. Once main lowers an entry, every branch behind it, and a re-run CI merge commit, was told it raised that entry. Read it at git merge-base HEAD <ref> instead. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com> * test(growth): pin the C901 count and guards that no test reached Eleven single-line mutants of check_growth.py passed the whole suite: defs in except/case bodies, async for, nested async defs, class bodies, `(_ as z)`, duplicate names, GITHUB_BASE_REF, cli.py, dot dirs, non-int entries and the rationale strip. Expected counts come from ruff C901 at max-complexity 0. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com> * test(growth,answers): pin the match-case counts, the subdirectory baseline read, and the top_k default Three review gaps with no failing test: a decision inside a case body and a refutable as-pattern last case in the complexity count; load_base_baseline when the project sits in a subdirectory of the git root (the ./ prefix); and the answers command's top_k default from retrieval.top_k when the flag is unset. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * test(answers): make the flag row differ from config and default; fix growth comments The flag row used top_k=3, which is also the built-in default, so a command ignoring --top-k still passed (mutant: if True:). Use 5 with a config of 7. Correct the test and function counts in check_growth.py comments and drop the unused _product_config assignment in answers.py. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * ci(growth): --seed mode, reseed baseline after merging main, document --update after rebase Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * fix(growth): the --seed rationale does not explain a raise against a baselined base After this lands, `--seed` on a branch that grew llm.py wrote the entry at the new size with "pre-existing when the gate was introduced", and the gate passed. The rationale check now treats that text as missing, so only the one-time seeding (base with no baseline) can use it. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com> * ci(growth): reseed the baseline after the merge of main (main has no baseline) Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * ci(growth): seed the baseline after merging main, with a rationale per raise Main grew four baselined or near-cap items and added three functions over complexity 10 before the gate landed: test_repo_environment.py 1369 -> 1371 (#147), manifest._check_task 21 -> 22 (#113), ci_local.run 28 -> 29 (#146), cmd_repo_validate 10 -> 11 (#150), cli_surface._value 17 (#88), research_register.check 19 and main 15 (#139). Each entry names its PR. --update after the seed lowers nothing. Now 15 files and 98 functions. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> * ci(growth): re-seed the baseline after merging main again (#73, #115) #73 grew test_judge_validity.py 1423 -> 2235, test_judge_reliability.py 1505 -> 1695, model_compare.py 1296 -> 1297, cmd_eval_judge_validity 25 -> 29, and took degrade.build_pairs (17) and judge_sequential.run_sequential (12) over the cap. #115 grew l0 _build_names 14 -> 15 and shrank _cmake (now under the cap, entry dropped) and Scan.resolve_path (20 -> 19). Each raise names its PR. Now 15 files and 99 functions. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> --------- Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com> Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com> Co-authored-by: Alexander Rybkin <alexander.rybkin@intel.com>



Part of #48.
Summary
The gate judged every answer with no context; an arms run judges skill and docs arms with their treatment material. So the setting the arm deltas come from was never validated.
Intact and degraded copies of a pair get the same material. A baseline arm's empty material becomes the no-context marker, as in the arms run. The typed and pairwise scorers have no context slot and are refused before any model loads. A file without materials exits 2. A run left with no usable pair now exits 2 with the reason, instead of a traceback.
Evidence
Perf golden set, Sonnet answers, both arms, Haiku 4.5 judge, 30 bases, 4 variants,
--context nonevs--context stored. The skill arm's material was added to the stored run from the skill as it is today. That rebuild is an approximation (the skill may have changed since that run), stored judge_materials make it exact going forward.contradictiontruncate_20wrong_numberswrong_toolswrong_tools0.94 → 0.61). Paired McNemar on the skill arm: all p ≥ 0.5. Reading discrimination under context needs a much larger run, which this flag now makes possible.judge_scorerforwardscontextonly when given;cli.pywith a fake judge, the skill text reaches the judge and the report sayscontext: stored.pytest -q4309 passed, 2 skipped;ruff check .,mypyclean.Merge Danger
Door: two-way. New flag, default off.
Blast Radius: opt-in
--context stored, nothing changes: same pairs, same scorer calls, same report plus two fields (context: "none",n_without_context: 0).ValueErrortraceback (exit 1).Checklist
pytestpassesruff,mypy, and schema checks passevaluating-treatments.md,benchmarking-and-comparison.md🤖 Generated with Claude Code