Skip to content

feat(eval): add eval headroom to flag items with no headroom - #279

Open
napetrov wants to merge 63 commits into
intel:mainfrom
napetrov:feat/eval-ceiling-floor
Open

napetrov wants to merge 63 commits into
intel:mainfrom
napetrov:feat/eval-ceiling-floor

Conversation

@napetrov

@napetrov napetrov commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part of #220.

eval headroom reads one or more arms.v1 files of the same question set, one per repetition, and lists the items that a treatment comparison cannot learn from:

  • ceiling: the no-skill arm already scores at least 0.9 (default). A skill cannot raise it.
  • floor: every arm that some run scored is at most 0 (default), and at least one non-baseline arm scored. An arm no run scored is left out of this test.
  • provisional: fewer than 3 runs (default) scored the no-skill arm for the item.

It only reports. No score, gate or comparison changes. The table always goes to stdout; --out writes a headroom.v1 JSON file. Why: a ceiling or floor item shows nothing about a skill, and one run's flag can be noise.

Evidence

  • Red then green: each new test failed before its fix and passes after. The regressions cover the review findings: a mean of 89.996 is not a ceiling; a mean of exactly 0.9 or 0.3 across runs is flagged despite float error; an unknown --baseline-arm exits 2 and lists the arms; the same file given twice counts once; a question id repeated in one file exits 2; a non-ASCII digit id is named, not a traceback; 0.89996 prints as 0.89 and is not flagged.
  • Fresh-context review (FRESH-REVIEW-A1, on 0965221) found two blockers, both fixed in df78ff5: a mistyped --baseline-arm gave zero ceilings with exit 0, and repeated inputs counted as extra runs. Its unscored-arm nit is fixed in 050de2e. An in-session pass found the float-boundary blocker, fixed in 0965221.
  • Local CI on 050de2e (make ci-local --offline): ruff, schema, registry, guidance scan, schema_compat, mypy (273 files), deptry, mkdocs and the CLI-surface job (which reports the added eval headroom) pass. Pytest: 9729 passed, 5 skipped, 1 failed: test_container_agent.py::test_probe_reports_a_writable_docker_socket (AF_UNIX path too long). The same test failed on a clean intel/main checkout in an earlier run, so it is not from this change. bandit flagged only files in a local .venv inside the package directory; with that excluded, bandit is clean.

Merge Danger

  • New command eval headroom and new persisted format headroom.v1 (schema, fixture, registry entry, widening-only compatibility). Both are new in this PR.
  • Exit codes: 0 on success, including when nothing is flagged. 2 on an unreadable input, an arms or run_config of the wrong top-level shape (arms must be a list of strings), inputs of different configurations or judge contexts (a mixed judge_context is refused on its own), an unknown --baseline-arm, a question id repeated within one file, a file where no run scored the no-skill arm, an --out that cannot be written, names a directory, or is one of the inputs, and any other usage error (for example a threshold out of range). 1 only when --out is refused by the schema (an internal failure).
  • The thresholds (0.9, 0, 3) are defaults, not calibrated values. The command is report-only; do not gate on it yet.
  • JSON goes only to --out. The table always goes to stdout.
  • Known limits:
    • With an explicit --baseline-arm, the baselines the runs declare are not compared. Without it, the runs must declare the same baseline (documented).
    • reps counts the runs in which the no-skill arm scored the item. Pass one file per repetition; repeated copies are skipped with a note.
    • Scores are not range-checked. The judge writes 0-100; an aggregate of 150 reads as 1.5 and flags a ceiling, and -5 flags as a floor.
    • There are no confidence intervals. Paired statistics stay in dokimi.eval.stats.

🤖 Generated with Claude Code

The headroom command reads arms.v1 runs of one question set and flags an item
as a ceiling when its no-skill arm's mean is at least 0.9, or as a floor
when every arm's mean is at most 0. An item with fewer than three runs
behind it is provisional. The table goes to stdout; -o writes a
headroom.v1 artifact. Flags are descriptive: no score or gate changes.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The per-arm mean was rounded to four decimals before the threshold check,
so an item at 89.996 (below 0.9) was flagged as a ceiling. The flags now
compare the exact mean; the artifact still shows four decimals.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
A mistyped --no-skill-arm read as "no ceiling anywhere" and exited 0.
It is now refused (exit 2), naming the arms the runs list.

reps counted score entries, so one file passed three times, or a
question id repeated within one file, faked three runs and lifted the
provisional guard. The CLI now drops repeated input files by content
digest with a note, and a question id repeated within one run is an
error. The library counts each Run as one run.

Also: the JSON keeps the unrounded mean (no 4-decimal rounding that
turned 0.89996 into 0.9), the table rounds down with the flag's
tolerance, the table lists the control arm once, a non-ASCII digit
question id is named in the error instead of crashing, and the docs
say the explicit --no-skill-arm skips the baseline agreement check.

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

reps was the smallest run count over the arms, so one arm the skill
missed in a run lowered the item's reps. It is now the number of runs
in which the no-skill arm scored the item.

An arm no run scored is listed in the item's unscored_arms (JSON and a
table note) and is left out of the floor test. The floor also needs at
least one non-baseline arm that scored, so the baseline alone at zero
is not a floor.

-o that cannot be written prints "Error: ..." and exits 2. The
duplicate-input note says "byte-identical", as the docs do. The docs
state that JSON goes to -o only and the table always goes to stdout.

headroom.v1 is not on intel/main yet, so the schema gains unscored_arms
in place without a version bump.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@github-actions github-actions Bot added the cli-interface-change Changes dokimi's user-facing CLI surface label Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

CLI interface change detected

This PR changes dokimi's user-facing CLI surface (commands and/or options):

+ added command `dokimi eval headroom`
    + argument `arms`
    + option `--baseline-arm`
    + option `--ceiling`
    + option `--floor`
    + option `--help/-h`
    + option `--min-reps`
    + option `--out`
~ `dokimi runs list` parameters changed:
    ~ changed option `--kind`: choices: ["'answers'", "'arms'", "'cli_routes'", "'eval'", "'explore_runs'", "'guidance_reach'", "'matrix_rollup'", "'novelty'", "'plan'", "'plugin_delta'", "'questions'", "'repo_budget'", "'repo_environment'", "'repo_readiness'", "'result'", "'subject_scorecard'", "'task_inspect'", "'task_runs'", "'tool_preference'"] -> ["'answers'", "'arms'", "'cli_routes'", "'eval'", "'explore_runs'", "'guidance_reach'", "'headroom'", "'matrix_rollup'", "'novelty'", "'plan'", "'plugin_delta'", "'questions'", "'repo_budget'", "'repo_environment'", "'repo_readiness'", "'result'", "'subject_scorecard'", "'task_inspect'", "'task_runs'", "'tool_preference'"]

Updated on every push; the cli-interface-change label is on while the diff is non-empty.

@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

❌ Patch coverage is 98.27586% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.09%. Comparing base (b4a0a8c) to head (61b942d).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
dokimi/dokimi/eval/headroom.py 97.33% 2 Missing and 2 partials ⚠️
dokimi/dokimi/commands/headroom.py 98.97% 0 Missing and 2 partials ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #279      +/-   ##
==========================================
+ Coverage   94.05%   94.09%   +0.04%     
==========================================
  Files         297      299       +2     
  Lines       38018    38366     +348     
  Branches     6424     6496      +72     
==========================================
+ Hits        35756    36102     +346     
- Misses       1452     1453       +1     
- Partials      810      811       +1     
Files with missing lines Coverage Δ
dokimi/dokimi/artifacts.py 95.12% <ø> (ø)
dokimi/dokimi/cli_routes.py 100.00% <ø> (ø)
dokimi/dokimi/commands/evaluate.py 90.00% <100.00%> (+0.06%) ⬆️
dokimi/dokimi/runs/kinds.py 100.00% <ø> (ø)
dokimi/dokimi/commands/headroom.py 98.97% <98.97%> (ø)
dokimi/dokimi/eval/headroom.py 97.33% <97.33%> (ø)

... and 3 files with indirect coverage changes

🚀 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 050de2e. The flag logic is sound and the edge cases I probed hold. Two test gaps let a real regression change the verdict; I'd close both before merge. One doc nit.

What I ran, on a Linux review host at 050de2e:

  • make ci-local: 27 passed, 1 failed, 7 skipped. 9731 tests passed, 4 skipped; the changed-line coverage step passes. The failing pip-audit step lists advisories against filelock 3.32.7 in the lock files, which this PR does not touch.
  • Reproductions of the CLI on hand-built arms files (empty, non-UTF-8, directory, non-arms, mixed question sets, unknown --no-skill-arm, odd thresholds). No traceback, and exit codes match the docs.
  • A mutation run of 79 broken copies of the new code: 30 passed the 36 headroom tests. The two that decide a verdict are inline; I re-ran both by hand.

Checked, no issues

  • Schema: the CLI's -o output and the fixture validate against headroom.v1.json; headroom is registered in SCHEMA_VERSIONS and the schema is new, so schema_compat has nothing to widen.
  • Commits: all five carry Signed-off-by.
  • Dedupe and reps: a repeated or byte-identical file counts once; a repeated question id in one file exits 2; an item missing from some runs gets fewer reps and is provisional.

Comment thread dokimi/tests/test_headroom.py Resolved
Comment thread dokimi/dokimi/commands/headroom.py Resolved
Comment thread dokimi/docs/evaluating-treatments.md Resolved
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 38d219c, which fix the nit from my review:

  • 38d219c: dokimi/docs/evaluating-treatments.md:757: added the sentence that an out-of-range threshold (--ceiling in (0, 1], --floor in [0, ceiling), --min-reps at least 1) exits 2.

make ci-local at 38d219c: 27 passed, 1 failed (pip-audit, unchanged from the reviewed head), 7 skipped.

Not approving yet. Still open: the two risks 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 bug headroom.py:33 Nit: an arms file that is deeply nested JSON (e.g.
Review details
  • Commit reviewed: 95f84dd against merge base f0f95a2
  • Coverage: 8 files in 2 groups
  • Engine: OpenCodeReview v1.13.3, us.anthropic.claude-sonnet-5-5
  • Usage: 266k tokens (148k from cache), 1m0s
  • Tool calls: 10 (5 code_search, 4 file_read, 1 code_comment)
  • Headroom evaluation feature: core logic, command, schema, and tests: 6 files
  • Documentation and changelog for headroom feature: 2 files

Comment thread dokimi/tests/test_headroom.py Resolved
Comment thread dokimi/tests/test_headroom.py Resolved
Comment thread dokimi/tests/test_headroom.py Resolved
Comment thread dokimi/tests/test_headroom.py Resolved
Comment thread dokimi/tests/test_headroom.py Resolved
Comment thread dokimi/tests/test_headroom.py Resolved
…-ceiling-floor

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
An item with baseline 40 and skill 0 must not be a floor. Red when the floor
test drops the baseline (all(... if a != control)), green on head.

Thread: PRRT_kwDOVBg7g86xhYFw
Thread: PRR_kwDOVBg7g88AAAABU4x1TA
Thread: PRR_kwDOVBg7g88AAAABU5Bc8g
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…mand

Red when the command calls headroom() with the default thresholds; the test
asserts the flags and the thresholds of the -o artifact for ceiling 0.5,
floor 0.2, min-reps 1.

Thread: PRRT_kwDOVBg7g86xhYFz
Thread: PRR_kwDOVBg7g88AAAABU4x1TA
Thread: PRR_kwDOVBg7g88AAAABU5Bc8g
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Red when an option is renamed, its dest changes or a default drifts from the
DEFAULT_* constants; one test also runs the command from parsed arguments.

Thread: PRRT_kwDOVBg7g86xlrgs
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Five items cover ceiling+provisional, floor, unflagged+provisional and
unflagged+steady. Red when flagged_provisional counts every provisional item,
no_flag is hard-coded, or the floor count is read as the ceiling count.

Thread: PRRT_kwDOVBg7g86xlrf-
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Numeric ids come first in numeric order, then the other ids. Red when the sort
is lexicographic, insertion order, or ignores the numeric key.

Thread: PRRT_kwDOVBg7g86xlrgN
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Each rejected threshold now names its own message (ceiling 0, negative and
above 1; floor equal to the ceiling and negative; min_reps 0 and negative),
and ceiling=1.0, floor=0 and min_reps=1 are accepted. Red when a bound moves.

Thread: PRRT_kwDOVBg7g86xlrgX
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
headroom([]) is refused as "no runs to read"; a run with no questions and a run
nothing was judged in give no flag, at library and command level. Red when the
empty-input refusal is removed or the command stops printing the table.

Thread: PRRT_kwDOVBg7g86xlrgc
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
With no_skill_arm="skill" on runs that declare "baseline", no_skill, reps and
both flags follow "skill"; the mixed-baseline case now asserts its item too.
Red when a given arm is ignored and the declared baseline is returned.

Thread: PRRT_kwDOVBg7g86xlrgh
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The patch-coverage report named lines of dokimi/eval/headroom.py (the empty-input
refusal) and the output-less branch of dokimi/commands/headroom.py. The tests of
544394e and 4bbc04a cover both: test_headroom.py alone reaches 100% of lines
and branches of the two files.

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

Copy link
Copy Markdown
Contributor Author

Your two test gaps are closed: the floor baseline term in d5113e0 and the non-default thresholds through the command in 15695ac. The doc nit was already fixed on the branch by you in 38d219c. The pip-audit failure is in lock files this PR does not touch.

@napetrov

Copy link
Copy Markdown
Contributor Author

The two open risks are fixed: d5113e0 (baseline term of the floor test) and 15695ac (non-default thresholds through the command). Thanks for the doc commit 38d219c.

@napetrov

Copy link
Copy Markdown
Contributor Author

This review is the empty run marker of the OpenCodeReview bot; it carries no claim. Its six findings were the inline threads, each answered there.

Inputs count as repetitions, but runs of different models, judges,
run configs or libraries were averaged per question id and lifted the
provisional flag. Compare report.model_compare.replicate_key and the
library, as model compare does, and exit 2 on a mismatch.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
@xaleryb

xaleryb commented Oct 31, 2026

Copy link
Copy Markdown
Contributor
  • Ran on Python 3.13: the full suite, ruff, mypy, bandit and the benchmark gate, on the head plus the commits below, alone and merged with current main. All green.
  • The runs kinds table row for headroom.v1 is fixed in b971c61. The earlier runs list / runs show KeyError no longer reproduces.
  • Pushed:
    • 97f0f7a: when --out cannot be written (--out f/out.json with f a file), the command exited 1. The exit-code table in docs/quickstart.md gives 2 for this case. It now uses exit_on_unwritable. A schema refusal still exits 1.
    • 976f06a: arms files from different models, judges, run configs or libraries were averaged per question id as repetitions, which lifted the provisional flag. They now exit 2, using report.model_compare.replicate_key plus the library. Runs that differ only in seed still pool.
  • Left for the author, in the PR body (it becomes the squash message):
    • Known limits says an unjudged file exits 0. It exits 2.
    • It says -5 never flags. It is flagged floor.
    • The test count is stale. Suggest dropping the exit-0 bullet, adding "no run scored the no-skill arm" to the exit-2 list, and dropping the count.- With refactor(cli): bad input exits 2 through one InputError #316: tests/test_headroom.py::test_command_exits_2_on_an_unreadable_input asserts file not found, and refactor(cli): bad input exits 2 through one InputError #316 changes that message to cannot read <file>: No such file or directory (exit code still 2). Whichever of the two lands second changes that one assertion.

Comment thread dokimi/dokimi/commands/headroom.py Outdated
or library: averaging such runs per question id would mix different measurements."""
from dokimi.report.model_compare import replicate_key

keys = [(replicate_key(dict(run.meta)), run.meta.get("library_name")) for run in runs]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: records.load preserves top-level metadata without validating run_config, but replicate_key calls .items() on it. For example, running eval headroom bad.json with {"baseline_arm":"baseline","run_config":"bad","evaluations":[{"question_id":"1","scores":{"baseline":{"aggregate":95}}}]} raises an uncaught AttributeError, even with only one input. This call is outside the command's error handlers, so malformed input produces a traceback rather than a diagnostic and exit 2. Validate that each non-null run_config is an object before computing the key, report the offending input path, and add a command regression test.

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.

Confirmed on 976f06a: eval headroom with an input whose run_config is a string ("bad") crashed in replicate_key with an uncaught AttributeError and a traceback, exit 1.

Fixed in ea85151. Right after each input loads, the command checks that a non-null run_config is a JSON object and otherwise exits 2 with Error: <path>: run_config must be a JSON object, not str (the path names the offending input). Regression test test_command_exits_2_when_run_config_is_not_an_object covers a string, a number and a list; it failed before the change and passes after.

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 aa49e53 (a non-object run_config exits 2) and eabdcda (any top-level arms or run_config of the wrong shape exits 2, naming the file and field). Tests: test_command_exits_2_when_top_level_metadata_has_the_wrong_shape, test_command_exits_2_when_run_config_is_not_an_object. Head c574718. The earlier reply citing ea85151 is not on this branch.

@napetrov
napetrov force-pushed the feat/eval-ceiling-floor branch from ea85151 to aa49e53 Compare October 31, 2026 19:31
@napetrov

Copy link
Copy Markdown
Contributor Author

Correction to my reply on the run_config thread: the fix is now aa49e53b, not ea851518. I re-signed the commit for the DCO check (Signed-off-by) and moved the new check into a helper, so cmd_eval_headroom stays under the complexity cap of the growth ratchet (the Tests job failed on that). The behaviour is the same as described in the reply.

records.load keeps the top-level metadata as is, and replicate_key calls
.items() on run_config, so a scalar or list run_config crashed eval
headroom with a traceback. The command now exits 2 and names the input.
The check is a helper, which keeps cmd_eval_headroom under the
complexity cap of the growth ratchet.

Thread: PRRT_kwDOVBg7g86yOcdc
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Comment thread dokimi/dokimi/commands/headroom.py Outdated
or library: averaging such runs per question id would mix different measurements."""
from dokimi.report.model_compare import replicate_key

keys = [(replicate_key(dict(run.meta)), run.meta.get("library_name")) for run in runs]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: validate top-level arms before calling replicate_key. For example, an input containing {"baseline_arm":"baseline","arms":5,"evaluations":[{"question_id":"1","scores":{"baseline":{"aggregate":95}}}]} passes records.load, which preserves the malformed metadata, but replicate_key executes tuple(5) and raises an uncaught TypeError. Even a single input therefore produces a traceback instead of the expected exit-2 diagnostic. Extend the metadata validation to reject a non-null arms value that is not a list of arm names, and add a command regression test.

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 eabdcda and c574718: a non-null top-level arms that is not a list of strings exits 2, naming the file and field. Regression tests in test_headroom.py (the shape test, parametrized over arms). Head c574718.

first_seen[digest] = str(path)
runs.append(run)
inputs.append(str(path))
_exit_unless_replicates(runs, inputs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: replicate_key does not include judge_context, so this check accepts runs scored under different judge protocols. For example, two otherwise identical scored arms files, one with judge_context: "treatment_material.v1" and one without that field, pass this guard and have their scores averaged. The latter judge saw only report snippets, not the stored treatment material, so these are different measurements; report/model_compare.py::_check_judge_context explicitly refuses this pairing. Check the protocol stamps here as well, including refusing to pool treatment_material.v1+snippet_fallback runs, and add a CLI regression expecting exit 2 for incompatible protocols.

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 c574718: judge_context joins the replicate key, so differently stamped runs are refused as not repetitions, and a mixed-protocol input exits 2 (the rule of report.model_compare._check_judge_context). Tests: test_command_exits_2_when_runs_were_judged_under_different_contexts, test_command_exits_2_on_a_mixed_judge_context.

…hape

replicate_key reads run_config as a mapping and arms as a list, so an
arms file with "arms": 5 (or a scalar run_config) crashed eval headroom
with a traceback. The command now checks both fields before grouping
and exits 2, naming the file and the field.

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

replicate_key calls .items() on run_config and tuple() on arms, so a wrong
type there crashes it, or a string or object arms is silently split.
The comment names both, instead of calling every wrong shape a traceback.

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

replicate_key does not read judge_context, so runs judged on the stored
treatment material and runs judged on report snippets were averaged as
repetitions. judge_context now joins the replicate key, and a run with the
mixed protocol exits 2, as report.model_compare already refuses it. A list
of arms holding a non-string now exits 2 too.

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

Copy link
Copy Markdown
Contributor Author

@xaleryb mapping your items (head c574718):

first_seen[digest] = str(path)
runs.append(run)
inputs.append(str(path))
_exit_unless_replicates(runs, inputs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: matching configuration metadata does not establish that shared question IDs refer to the same questions. For example, give two otherwise identical arms files question_id "1", but question_text "What is X?" with baseline aggregate 100 in one and "What is Y?" with baseline aggregate 0 in the other. This guard accepts both, and headroom pools them into one item with mean 0.5 and two reps, hiding the first question's ceiling. Before pooling, reject conflicting non-empty question text (and available ground truth) for shared IDs, naming the input and ID; add a command regression test. Partial runs can still be allowed without requiring identical ID sets.

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 6699e21. eval headroom now exits 2 when two inputs give one question id a different non-empty question text or ground truth, and names the id and both files. Partial runs with different id sets still pool.
Tests: the conflict case (red without the fix) and a partial-run pooling check that asserts the pooled reps.

@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 c574718. The flag registry point is fixed: 6399536 takes the registry's --baseline-arm and --out, and the branch merges cleanly into current main. One point from my first round is still partly open, and I replied in its thread: an item whose no-skill arm is unscored still counts as unflagged.

Agreed with @exai-bot: files that share a question id but not its text are pooled into one item.

What I ran, at c574718:

  • make ci-local --offline: 0 failed, 19 passed, 13 skipped (network and Docker jobs).
  • Two arms files with the same config, seeds 1 and 2, and question 1 worded differently (baseline 100 in one, 0 in the other) give one item at 0.5 with 2 reps and no flag.
  • A file where item 2's baseline is unscored: unflagged 1 for that item. --out "" exits 2.

Checked, no issues

  • Repetition guard: 976f06a and c574718 refuse files whose model, judge, judge context, run config other than the seed, harness, arms or library differ. Runs that differ only in seed still pool. eabdcda refuses a run_config or arms of the wrong shape before replicate_key reads them.
  • Exit codes: an unwritable --out exits 2 through exit_on_unwritable (97f0f7a); a schema refusal of our own payload exits 1. The docs say the same.
  • Earlier rounds: the --out guard (inputs reached through .., a symlink, the .rejected sibling, a directory, an empty name), the 0.01 threshold floor, with_docs-named arms and long digit ids all hold, each with a test.

Copilot AI balanced review requested due to automatic review settings November 1, 2026 05:14
…tions

Two arms files that share a question id but not its question text (or
ground truth) were pooled into one item, so a ceiling question could hide
behind a question with no headroom. The command now exits 2 and names the
id and the two inputs. Partial runs with different id sets still pool.

Thread: PRRT_kwDOVBg7g86yUIUh
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@@ -0,0 +1,3 @@
### Added

- **`eval headroom` flags arms items with no headroom** — reads one or more `arms.v1` files of the same question set and flags an item as a `ceiling` when the no-skill arm's mean is at least `--ceiling` (default 0.9, on 0-1), or as a `floor` when the no-skill arm was scored, every arm that a run scored has a mean at most `--floor` (default 0), and at least one arm other than the no-skill arm scored. An item with fewer than `--min-reps` runs (default 3) behind it is `provisional`. The human table goes to stdout; `--out` writes a `headroom.v1` artifact with every item. The flags change no score. Inputs that are not repetitions of one configuration (model, judge, run config other than the seed, harness, arms or library differ) exit 2.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

documentation · low
Nit: specify that only runs scoring the item's no-skill arm count toward --min-reps. With three files containing an item but only one scoring its baseline, the implementation reports one rep and keeps it provisional (covered by test_reps_follow_the_baseline_even_when_a_skill_arm_was_scored_in_more_runs). Replace “runs ... behind it” with “runs ... scoring its no-skill arm” to match the detailed documentation.

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 02d5d91. The changelog now says the runs scoring the no-skill arm count toward --min-reps, as the detailed docs do.

Comment thread dokimi/dokimi/commands/headroom.py Outdated
Comment on lines +68 to +69
keys = [(replicate_key(dict(run.meta)), run.meta.get("library_name"), run.meta.get("judge_context"))
for run in runs]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: this checks arm names but not the recorded profile prompt identity. Two files with identical configuration and questions, both listing profile:x, but different answers[].arms['profile:x'].metadata.profile_prompt_sha256 values pass this guard and have their scores averaged. This occurs when a profile is edited without changing its frontmatter ID (AgentProfileTreatment retains the name). It pools different treatments and can hide a floor or, with --baseline-arm profile:x, alter the ceiling verdict. report.model_compare already explicitly rejects this case using prompt_digests; apply an equivalent check to each pooled arm, including nested combined-treatment metadata, and add a regression test.

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 02d5d91. Pooling now refuses an arm whose recorded profile_prompt_sha256 differs between inputs or within one, for combined arms too, using model_compare.prompt_digests.
Test: test_command_refuses_a_profile_prompt_that_changed_under_the_same_arm_name.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Headroom results can hide floors or ceilings by pooling incompatible runs and counting cached replays as independent measurements.

8 open issues
What changed in this PR

9 new findings.

This PR adds report-only eval headroom analysis, supporting the measurement work in #220 by identifying questions with ceiling scores, floor scores, or too few repetitions.

Changes:

  • Adds configurable headroom analysis and a CLI table without changing scores or gates.
  • Introduces the headroom.v1 artifact, schema, and run-discovery integration.
  • Adds input validation, regression tests, CLI reference updates, and usage documentation.
File Description
dokimi/​tests/​test_runs_show.py Adds expected headroom artifact counts.
dokimi/​tests/​test_runs_scan.py Covers headroom artifact discovery.
dokimi/​tests/​test_headroom.py Tests analysis, CLI behavior, validation, and output safety.
dokimi/​tests/​fixtures/​artifacts/​headroom.v1.json Provides a representative headroom artifact.
dokimi/​tests/​data/​cli_flags_allowlist.txt Registers the new threshold flags.
dokimi/​dokimi/​schemas/​headroom.v1.json Defines the persisted report format.
dokimi/​dokimi/​runs/​kinds.py Adds headroom counts to run inspection.
dokimi/​dokimi/​eval/​headroom.py Implements scoring aggregation, flags, and table formatting.
dokimi/​dokimi/​commands/​headroom.py Implements input checks, deduplication, and report output.
dokimi/​dokimi/​commands/​evaluate.py Registers the new evaluation command.
dokimi/​dokimi/​cli_routes.py Classifies headroom as a free command.
dokimi/​dokimi/​artifacts.py Registers the headroom schema version.
dokimi/​docs/​reference/​cli-interface.json Records the new command and artifact option.
dokimi/​docs/​evaluating-treatments.md Documents thresholds, repetitions, outputs, and limits.
dokimi/​changelog.d/​20261028_120000_napetrov_feat-eval-headroom.md Announces the new report-only analysis.

Review effort: Balanced

Comment thread dokimi/dokimi/commands/headroom.py Outdated
Comment on lines +68 to +69
keys = [(replicate_key(dict(run.meta)), run.meta.get("library_name"), run.meta.get("judge_context"))
for run in runs]

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 02d5d91. A file scored by eval reference next to a judged one is refused (exit 2), since the rescored file keeps the judge's metadata but is a different measurement.
Test: test_command_refuses_a_reference_scored_file_pooled_with_a_judged_one.

Comment on lines +66 to +69
from dokimi.report.model_compare import replicate_key

keys = [(replicate_key(dict(run.meta)), run.meta.get("library_name"), run.meta.get("judge_context"))
for run in runs]

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 02d5d91, same check as the profile-prompt reply above: each arm's digests are compared across inputs, combined arms included, and a conflict exits 2.
Test: test_command_refuses_a_profile_prompt_that_changed_under_the_same_arm_name.

Comment on lines +84 to +86
for name, value in (("question text", rec.question_text), ("ground truth", rec.ground_truth)):
if not value:
continue

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 02d5d91. A run that records grounded_questions is compared per question id: grounded in one input and ungrounded in another exits 2. A legacy run with no such count has unknown grounding and is not compared.
Test: test_command_refuses_a_grounded_question_judged_ungrounded_in_another_run.

Comment thread dokimi/dokimi/commands/headroom.py Outdated
for path in args.arms:
try:
run = records.load(path)
digest = _content_digest(path)

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.

Pending the maintainer's decision. Replay is not recorded in arms artifacts: the replayed flag lives only in the in-process usage record, so headroom cannot tell a cached repetition from a fresh one. Fixing it needs replay provenance stored per answer, which is an arms.v1 schema change. Until then the repeats count as runs, as before.

Comment thread dokimi/dokimi/commands/headroom.py Outdated
Comment on lines +28 to +29
data = json.loads(Path(path).read_text(encoding="utf-8")) # records.load has parsed it already
return hashlib.sha256(json.dumps(data, sort_keys=True).encode("utf-8")).hexdigest()

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 02d5d91. The digest maps integral floats to ints before hashing, so 95 and 95.0 are one run; booleans and strings are not converted.
Test: test_a_copy_with_an_integral_score_written_as_a_float_counts_once.

Comment thread dokimi/dokimi/eval/headroom.py Outdated
Comment on lines +76 to +79
if ceiling < _MIN_THRESHOLD:
raise ValueError(f"ceiling must be at least {_MIN_THRESHOLD}, not {ceiling!r}")
if 0.0 < floor < _MIN_THRESHOLD:
raise ValueError(f"floor must be 0 or at least {_MIN_THRESHOLD}, not {floor!r}")

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 02d5d91. The minimum positive threshold is now 1e-6 (far below a score step, far above the float tolerance), so --floor 0.005 is accepted and the docs say so.
Test: test_command_accepts_a_threshold_below_one_score_step.

f"run {index} lists question_id {dupes[0]!r} more than once; "
"a run holds each question once (a repeat would count as a second run)")
for rec in run:
names = _listed_arms(rec) | set(rec.scores)

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 02d5d91. Each item now lists every arm its run declares, so a declared arm the item lacks shows in unscored_arms and in the table note.
Test: test_an_arm_the_run_declares_but_an_item_lacks_is_unscored_for_that_item.

Comment thread dokimi/dokimi/eval/headroom.py Outdated

def _fmt(value: Optional[float]) -> str:
# Rounded down, with the flag's tolerance: 0.89996 shows 0.89, an exact 0.9 shows 0.90.
return "-" if value is None else f"{math.floor((value + _EPS) * 100) / 100:.2f}"

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 02d5d91. Values are floored to six decimals with trailing zeros dropped, so 0.9049 prints below a 0.905 ceiling and 0.00004 prints non-zero.
Test: test_a_threshold_below_one_score_step_splits_scores_the_table_shows_exactly.

… profile prompt

Runs scored by eval reference, judged with and without ground truth, or
run with a changed profile prompt under the same arm name are different
measurements; they are now refused rather than averaged. Declared arms an
item lacks are listed as unscored. Integral floats hash as their int.
Positive thresholds below one score step are accepted, and the table
shows values floored to six decimals so no value reads as a threshold.
Also the changelog wording on the reps and the docs.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Comment thread dokimi/docs/evaluating-treatments.md Outdated
id judged grounded in one file and ungrounded in another (a file with no `grounded_questions` count is not
compared). With `--baseline-arm`, the arm must
be one the runs list (exit 2 otherwise, naming the arms that exist), and the declared
baselines are not checked. A threshold out of range exits 2: `--ceiling` in (0, 1],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

documentation · low
Nit: the documented threshold ranges omit the implemented minimum positive threshold of 1e-6. --ceiling 0.0000001 and --floor 0.0000001 are within the stated ranges but exit 2 in check_thresholds. Document ceiling in [1e-6, 1] and floor as zero or in [1e-6, ceiling), so the CLI contract matches validation.

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 by d363baf (Anton Volkov's commit): the threshold ranges now give the 1e-6 minimum, ceiling in [1e-6, 1] and floor 0 or in [1e-6, ceiling). That matches check_thresholds.

Comment thread dokimi/dokimi/commands/headroom.py Outdated
Comment on lines +86 to +87
keys = [(replicate_key(dict(run.meta)), run.meta.get("library_name"), run.meta.get("judge_context"))
for run in runs]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: the arm comparison only uses the top-level arms field through replicate_key. Supported legacy/wrapped runs without that field therefore pass even when their actual treatments differ. For example, two files with baseline_arm: "baseline" and one evaluation for question "1", the first scoring {baseline: 100, "skill:a": 100} and the second {baseline: 0, "skill:b": 0}, are pooled successfully instead of exiting 2; the resulting baseline mean is 0.5 and hides the first configuration's ceiling. Compare the effective arm identities as well (using observed conditions when no arm list is declared), and add a regression for differing legacy arm sets.

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 998f1f5: a run with no top-level arms is now keyed by the arms its records show, so two legacy files whose arms differ exit 2. Red before the fix: test_command_refuses_legacy_runs_without_a_top_level_arms_list_whose_arms_differ.

def _exit_on_mixed_scoring(runs, inputs) -> None:
"""Exit 2 when some inputs were scored by ``eval reference`` and others by the judge. A rescored file keeps
the judge's metadata, so it passes the replicate check, yet its scores are a different measurement."""
kinds = ["reference" if "reference_scoring" in run.meta else "judge" for run in runs]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: two reference-scored files are treated as the same measurement regardless of their grading configuration. For example, score otherwise identical arms runs against two question files with the same id/text but different grading targets (baseline answer x, exact target x versus y). Both inputs have the same replicate key and kind here, and the question checks also pass, so scores 100 and 0 pool to 0.5 and hide a ceiling. Validate the reference grading identity as well as the scorer kind; at minimum refuse different recorded reference_scoring.questions sources, and use a grading-content digest to detect edits at the same path.

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 998f1f5: two reference scorings must share their scorer and questions file (per-run tallies are left out), else exit 2. Red before: test_command_refuses_reference_scored_runs_graded_against_different_question_files.
Open decision: an edit at the same questions path is still not caught. Catching it needs eval reference to stamp a digest of the questions file (an arms.v1 change). Should I do that in a follow-up, or keep the path check?

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.

Maintainer chose option (b): a follow-up, not this PR. Issue: #356 (stamp a digest of the questions file in reference_scoring, and compare it in headroom).
No change in this PR: the same-path check stays as it is.

"""Per arm of ``run``, the profile prompt digests its records stamp (none for an arm without a profile)."""
from dokimi.report.model_compare import prompt_digests

return {arm: prompt_digests(data, arm) for arm in run.conditions}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: records.load accepts a bare record list, but prompt_digests calls run_data.get(...), so this raises an uncaught AttributeError for input such as [{"question_id":"1","scores":{"baseline":{"aggregate":100},"skill":{"aggregate":100}}}], even with --baseline-arm baseline. Either extract prompt pins from the parsed Run or explicitly reject non-object input with a diagnostic and exit 2; add a command-level regression test so malformed/unsupported input does not produce a traceback.

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 998f1f5: a top-level JSON array exits 2 ("expected a JSON object"), and answer metadata that is not an object, or treatments that is not an array, exits 2 naming the file. Red before: test_command_exits_2_on_a_bare_record_list_instead_of_a_traceback and the metadata-shape cases.

@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 02d5d91. The pooling guards cover most of the cases the earlier rounds named, but a bare record list still ends in a traceback, two kinds of run are pooled that should not be, and three exit-code paths have no test. One doc nit.

What I ran, on a Linux review host at 02d5d91:

  • make ci-local: 28 passed, 2 failed, 7 skipped; 12,077 tests passed, 59 skipped; changed-line coverage step passes. The two failures are the gitleaks install and scan steps, which did not run because the tool could not be downloaded; the PR does not touch them.
  • A mutation run of 48 broken copies of the changed code against tests/test_headroom.py: 12 passed the 102 tests; 4 of them decide an exit code and are inline.
  • CLI runs on hand-built arms files (bare list, legacy files with different arms, two eval reference files, answer metadata of the wrong type, grounded/ungrounded mixes, 95 vs 95.0, huge and non-finite numbers), with the LLM transport never reached.

Open bot points I confirm:

  • I agree with Copilot that a cached replay of an arms run counts as an independent repetition; the author says arms.v1 does not record replays and holds it for a decision.

Checked, no issues

  • Commits: all non-merge commits carry Signed-off-by.
  • Pooling guards: differing question text or ground truth, grounded vs ungrounded, judged vs reference-scored, a changed profile prompt across files, a mixed judge_context and a wrong-shaped arms or run_config all exit 2 with a message.
  • Table: _fmt floors to six decimals: 0.9049 prints 0.9049, 0.89996 prints 0.89996, 0.00004 prints 0.00004; with --ceiling 0.905, 90.5 is flagged and shown as 0.905.
  • Schema and registry: headroom.v1 is a new kind in SCHEMA_VERSIONS; the CLI snapshot lists eval headroom with --baseline-arm and --out; no growth-baseline change is needed.
Bot points I checked that no longer apply at this head (19)
  • exai-bot's point on the floor definition does not apply: the docs and changelog now say the no-skill arm must be scored.
  • exai-bot's point on a symlink loop in the --out guard does not apply: _same_file catches OSError and RuntimeError and has a test.
  • exai-bot's point on the 1e-9 tolerance against tiny thresholds does not apply: positive thresholds below 1e-6 are refused, so the tolerance cannot swallow one.
  • exai-bot's point on the documented threshold ranges does not apply: the ranges were rewritten to match check_thresholds; the 1e-6 minimum is a separate nit inline.
  • exai-bot's point on the .rejected sibling of --out does not apply: _clobbers_an_input checks it and a test pins it.
  • exai-bot's point on with_docs and without_docs arms does not apply: _listed_arms keeps them and a test pins it.
  • exai-bot's point on digit question ids past the int limit does not apply: _sort_key no longer calls int().
  • exai-bot's point on a non-object run_config does not apply: it exits 2 and names the file.
  • exai-bot's point on a non-list arms does not apply: it exits 2 and names the file.
  • exai-bot's point on judge_context in the pooling check does not apply: it joins the replicate key and a mixed stamp exits 2.
  • exai-bot's point on shared ids with different question text does not apply: it exits 2 and names the id and both files.
  • exai-bot's point on the profile prompt identity does not apply: a differing profile_prompt_sha256 across files exits 2.
  • Copilot's point on reference-scored runs pooled with judged runs does not apply: a mix exits 2 (the grading content is the separate inline point).
  • Copilot's point on conflicting prompt digests does not apply: a differing digest across files exits 2 for every arm.
  • Copilot's point on grounded against ungrounded questions does not apply: a mismatch exits 2 when the runs record grounded_questions.
  • Copilot's point on 95 against 95.0 does not apply: integral floats hash as ints and a test pins it.
  • Copilot's point on the 0.01 minimum threshold does not apply: the minimum is now 1e-6.
  • Copilot's point on declared but missing arms does not apply: each item lists every arm its run declares.
  • Copilot's point on the displayed score against the threshold does not apply: values are floored to six decimals; I checked 0.9049 and 0.89996.

"""Per arm of ``run``, the profile prompt digests its records stamp (none for an arm without a profile)."""
from dokimi.report.model_compare import prompt_digests

return {arm: prompt_digests(data, arm) for arm in run.conditions}

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.

A bare record list, or an answer whose metadata is not an object, ends in a traceback instead of exit 2.

records.load accepts a top-level list of records, but _prompt_pins hands the raw list to prompt_digests (report/model_compare.py), which calls .get on it.

  • eval headroom bare.json --baseline-arm baseline with [{"question_id":"1","scores":{"baseline":{"aggregate":100},"skill":{"aggregate":100}}}] raises AttributeError: 'list' object has no attribute 'get'.
  • An answer with arms.skill.metadata set to ["a"] or "zz" raises the same AttributeError from model_compare.py (metadata.get("treatments")).
  • Other wrong top levels (string, number, null, bool) exit 2 cleanly, so only this path is open. It comes from the latest commit.

Suggestion: build the pins from the parsed Run records, or skip _prompt_pins and exit 2 with Error: <path>: expected a JSON object when data is not a dict, and catch AttributeError around the digest read for the metadata case. Add a command test for each input.

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 998f1f5, same change as the bare-list thread above: a list top level and the metadata shapes you listed (list, string, treatments as a number) now exit 2 with the file named, no traceback. Tests cover each input.

from dokimi.report.model_compare import replicate_key

keys = [(replicate_key(dict(run.meta)), run.meta.get("library_name"), run.meta.get("judge_context"))
for run in runs]

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.

Legacy files without a top-level arms are pooled even when their arms differ.

replicate_key reads only meta["arms"]. Two files with baseline_arm: baseline and one question each, the first scoring {baseline: 100, skill:a: 100} and the second {baseline: 50, skill:b: 50}, exit 0 and print one item with reps 2, no-skill 0.75, skill:a 1.00, skill:b 0.50. The baseline mean mixes two treatment sets, and a ceiling can hide.

Suggestion: when meta["arms"] is absent, add the observed run.conditions to the key and refuse a mismatch with the same message. Add a test with two legacy files whose skill arms differ.

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 998f1f5, same change as the legacy-arms thread above: legacy runs are compared by their observed arms, and a mismatch exits 2. Test: test_command_refuses_legacy_runs_without_a_top_level_arms_list_whose_arms_differ.

def _exit_on_mixed_scoring(runs, inputs) -> None:
"""Exit 2 when some inputs were scored by ``eval reference`` and others by the judge. A rescored file keeps
the judge's metadata, so it passes the replicate check, yet its scores are a different measurement."""
kinds = ["reference" if "reference_scoring" in run.meta else "judge" for run in runs]

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.

Two eval reference files with different grading are pooled as one measurement.

_exit_on_mixed_scoring tests only whether the reference_scoring key exists. Two otherwise identical files with reference_scoring: {"source": "x1", "grading": "exact"} (score 100) and {"source": "x2", "grading": "fuzzy"} (score 50) exit 0 and print reps 2, no-skill 0.75.

Suggestion: compare the recorded reference_scoring value (canonicalized) across the inputs and exit 2 when it differs. Add a test with two differing values.

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 998f1f5, same change as the reference thread above: the scorer and questions file must match across reference-scored inputs, else exit 2. Test: test_command_refuses_reference_scored_runs_graded_against_different_question_files. The same-path edit question is open, see that thread.

seen: dict[str, tuple[str, str]] = {} # arm -> (digest, first input)
for pinned, path in zip(pins, inputs, strict=True):
for arm, digests in pinned.items():
for digest in sorted(digests):

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.

No test pins that a profile prompt changing within one input exits 2.

Mutation check: change for digest in sorted(digests): to for digest in sorted(digests)[:1]:. All 102 tests of test_headroom.py still pass. The docstring promises a conflict "between inputs, or within one"; with the mutant a single file whose skill records stamp two different profile_prompt_sha256 values is read as one treatment and exits 0. The prompt tests only use one digest per file.

Suggestion: add a case with one arms file of two questions whose skill records carry different digests, and assert exit 2 with ran with a different profile prompt.

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.

Added in 998f1f5: test_command_refuses_a_profile_prompt_that_changes_between_two_questions_of_one_file. With the [:1] mutant it fails; on the fix it passes.

save_artifact("headroom", result, args.output)
except ArtifactValidationError as e: # a payload the schema refuses is an internal failure: exit 1
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

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.

No test pins exit 1 for a payload the schema refuses.

Mutation check: change sys.exit(1) to sys.exit(2) after except ArtifactValidationError. All 102 tests still pass, and the file never references ArtifactValidationError. The exit-1 versus exit-2 split is documented and is what a caller acts on.

Suggestion: monkeypatch dokimi.artifacts.save_artifact to raise ArtifactValidationError and assert exit 1 with Error: on stderr.

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.

Added in 998f1f5: test_command_exits_1_when_the_payload_fails_the_artifact_schema. With sys.exit(1) changed to 2 it fails.

raise ValueError(f"ceiling must be in (0, 1], not {ceiling!r}")
if not 0.0 <= floor < ceiling:
raise ValueError(f"floor must be in [0, ceiling), not {floor!r} (ceiling {ceiling!r})")
if ceiling < _MIN_THRESHOLD:

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.

The exact _MIN_THRESHOLD boundary is not pinned.

Mutation check: change ceiling < _MIN_THRESHOLD to <= (line 77), or 0.0 < floor < _MIN_THRESHOLD to <= (line 79). Both pass all 102 tests, and --ceiling 1e-6 or --floor 1e-6 would then exit 2, although the docs accept a threshold of that size.

Suggestion: add check_thresholds(1e-6, 0.0, 1) and check_thresholds(1.0, 1e-6, 1) as accepted, and check_thresholds(1e-7, 0.0, 1) and check_thresholds(1.0, 5e-7, 1) as ValueError.

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.

Added in 998f1f5: test_the_minimum_positive_threshold_is_accepted_and_just_below_it_refused. With <= at either _MIN_THRESHOLD check it fails.

Comment thread dokimi/docs/evaluating-treatments.md Resolved
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Comment thread dokimi/dokimi/eval/headroom.py Outdated
# reads as the threshold: 0.89996 shows 0.89996, an exact 0.9 shows 0.90, 0.00004 shows 0.00004.
if value is None:
return "-"
whole, _, frac = f"{math.floor((value + _EPS) * 1e6) / 1e6:.6f}".partition(".")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: downward truncation protects the ceiling comparison but misrepresents the floor comparison. With baseline and skill aggregates both 30.00004, floor=0.3 and the default min_reps, the provisional row displays both means as 0.30 with no flag, although the footer says floor <= 0.3. The actual means (0.3000004) correctly fail the floor test. Preserve additional digits near either threshold (or use threshold-aware formatting) so an above-floor score does not display as equal to the floor; add a regression alongside the below-ceiling display tests.

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 998f1f5: the table prints nine decimals (floored), so 0.3000004 shows as 0.3000004 and no longer as the 0.30 floor. Test: test_a_mean_just_above_the_floor_is_not_shown_as_the_floor.

@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 d363baf, which fix the nit from my review:

  • d363baf: dokimi/docs/evaluating-treatments.md:767: the threshold ranges now read --ceiling in [1e-6, 1] and --floor 0 or in [1e-6, ceiling), as check_thresholds enforces.

make ci-local at d363baf: 28 passed, 2 failed (gitleaks install and scan, host), 7 skipped.

Not approving yet. Still open: the bug and the five risks from my review; Copilot on cached (replayed) repetitions count as independent runs; the author holds it for a maintainer decision (arms.v1 change).

…rmed input

- A run without a top-level arms list is compared by the arms its records show.
- Two reference scorings graded against a different scorer or questions file are refused.
- A bare record list, or answer metadata of the wrong shape, exits 2 with a diagnostic, not a traceback.
- The table keeps nine decimals, so 0.3000004 is no longer shown as the 0.30 floor.
- Tests pin the profile prompt check within one file, the exit 1 path and the minimum threshold.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Comment thread dokimi/docs/evaluating-treatments.md Outdated
in its own column, a note naming any arm no run scored, and the counts. The JSON goes to
`--out` only, as a `headroom.v1` artifact with every item; if that file cannot be written the
command prints `Error: cannot write ...` on stderr and exits 2, after the table. Scores are compared unrounded
and shown rounded down to six decimals, so 0.89996 prints as 0.89996 and a value just under a threshold never

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

documentation · low
Nit: the documented precision differs from the implementation. _fmt floors to nine decimal places, and test_headroom.py explicitly expects 0.3000004 to remain visible (seven decimal places). Change “six decimals” to “up to nine decimal places” so the output contract matches the code.

Suggestion:

Suggested change
and shown rounded down to six decimals, so 0.89996 prints as 0.89996 and a value just under a threshold never
and shown rounded down to up to nine decimal places, so 0.89996 prints as 0.89996 and a value just under a threshold never

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 61b942d. The sentence now says rounded down to up to nine decimal places, as _fmt floors. test_headroom keeps 0.3000004 (seven places), so no test change.

Keeps the reason (floored, never up) and the float-error note; the
examples were the third line that check_comment_blocks rejects.

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli-interface-change Changes dokimi's user-facing CLI surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants