Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
2226252
feat(eval): flag arms items at the ceiling or floor with no headroom
napetrov Oct 29, 2026
87335ba
fix(eval): compare the unrounded headroom mean to the ceiling
napetrov Oct 29, 2026
0965221
fix(headroom): flag means exactly at a threshold despite float rounding
napetrov Oct 29, 2026
df78ff5
fix(headroom): refuse unknown no-skill arms, count distinct runs
napetrov Oct 29, 2026
050de2e
fix(headroom): reps follow the no-skill arm, unscored arms skip the f…
napetrov Oct 29, 2026
38d219c
docs(eval): document the headroom threshold range checks
antonwolfy Oct 29, 2026
536a3b7
Merge branch 'main' of https://github.com/intel/dokimi into feat/eval…
napetrov Oct 29, 2026
d5113e0
Address review: pin the baseline term of the floor test
napetrov Oct 29, 2026
15695ac
Address review: drive non-default thresholds through the headroom com…
napetrov Oct 29, 2026
4bbc04a
Address review: run the headroom command through the real parser
napetrov Oct 29, 2026
329baac
Address review: pin each summary counter with a mixed item set
napetrov Oct 29, 2026
19b25df
Address review: pin the item order of the headroom result
napetrov Oct 29, 2026
8e5245e
Address review: match the threshold refusals and pin their boundaries
napetrov Oct 29, 2026
544394e
Address review: cover empty and unjudged headroom input
napetrov Oct 29, 2026
fe66ffc
Address review: pin a given no-skill arm on the success path
napetrov Oct 29, 2026
95f84dd
Thread: IC_kwDOVBg7g88AAAABgreQtQ: already fixed in 544394ef
napetrov Oct 29, 2026
e5faa92
fix(headroom): exit 2 on an input nested too deeply to read
napetrov Oct 30, 2026
37cd2c8
fix(headroom): --min-reps refuses 0 with the usage message
napetrov Oct 30, 2026
744e1ca
fix(headroom): keep a non-ASCII digit question id instead of refusing it
napetrov Oct 30, 2026
b3a0b78
fix(headroom): refuse -o naming one of the input runs
napetrov Oct 30, 2026
49a4d54
fix(headroom): count a re-serialized copy of a run once
napetrov Oct 30, 2026
340879b
fix(headroom): summarize the unscored-arm note per arm
napetrov Oct 30, 2026
2c22659
fix(headroom): a floor needs the no-skill arm scored on the item
napetrov Oct 30, 2026
aa8c3ad
fix(headroom): refuse a file where no run scored the no-skill arm
napetrov Oct 30, 2026
f4328af
Merge remote-tracking branch 'intel/main' into pr279-1029
napetrov Oct 30, 2026
3df832a
docs(headroom): a repeat is skipped by JSON content, not by bytes
napetrov Oct 30, 2026
95642eb
docs(headroom): keep the tolerance comment to two lines
napetrov Oct 30, 2026
2563839
docs(headroom): a floor needs the no-skill arm scored
antonwolfy Oct 30, 2026
9a191ba
docs(headroom): list the other exit-2 cases and the score scale
antonwolfy Oct 30, 2026
108ad9f
Merge remote-tracking branch 'intel/main' into pr279-1029
napetrov Oct 30, 2026
7b69f5a
test(cli): allowlist the four eval headroom flags with reasons
napetrov Oct 30, 2026
f259a4f
fix(headroom): the -o guard survives an unresolvable path; floor word…
napetrov Oct 30, 2026
9872a50
docs(cli): regenerate the CLI snapshot for eval headroom
napetrov Oct 30, 2026
df428e3
fix(headroom): refuse a positive ceiling or floor below one score step
napetrov Oct 30, 2026
1e164b8
fix(headroom): guard the .rejected sibling of -o, sort long digit ids…
napetrov Oct 30, 2026
6a1e7f2
docs(headroom): state the threshold ranges the code accepts
napetrov Oct 30, 2026
dfd6946
test(headroom): pin the -o guard against a dotdot spelling of an input
napetrov Oct 30, 2026
5902eda
fix(headroom): refuse a directory as -o with exit 2, not a ValueError
napetrov Oct 30, 2026
438c642
fix(headroom): refuse a directory spelled with a trailing slash as -o
napetrov Oct 30, 2026
ebe6575
fix(headroom): refuse "newdir/." and an unstat-able -o without a crash
napetrov Oct 30, 2026
d56fd0d
fix(headroom): name the -o refusal as "not a file name" and pin the c…
napetrov Oct 30, 2026
8118952
docs(headroom): the provisional rule counts reps across files; name t…
napetrov Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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; `-o` writes a `headroom.v1` artifact with every item. The flags change no score.
40 changes: 40 additions & 0 deletions dokimi/docs/evaluating-treatments.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,46 @@ Two gates are deliberately statistical rather than absolute:
questions both arms scored. It pools questions; a mean of per-cell means differs slightly
when the sets have different sizes — say which one a report uses.

## Headroom: items a comparison cannot learn from

A skill cannot raise an item the no-skill arm already answers, and an item no arm
answers points at the question or its grader, not at the skill. `eval headroom` flags
both, from one or more arms files of the same question set (one file per repetition):

```bash
python cli.py eval headroom run1/arms.json run2/arms.json run3/arms.json \
--no-skill-arm baseline --ceiling 0.9 --floor 0 --min-reps 3 -o headroom.json
```

- **ceiling**: the no-skill arm's mean over the runs is at least `--ceiling` (0-1; the
0-100 aggregate divided by 100). Default 0.9.
- **floor**: the no-skill arm was scored, every arm that a run scored has a mean at most
`--floor`, and at least one arm other than the no-skill arm scored. Default 0. An arm no run scored is
left out of this test and listed in the item's `unscored_arms`; the no-skill arm alone at zero
is not a floor.
- **provisional**: fewer than `--min-reps` runs scored the item with its no-skill arm (the
reps are those runs). Default 3. A file gives each item zero or one rep (zero when its
no-skill arm was not scored in that file), so one file alone makes every item provisional at the
default of 3; the reps of several files add up.

A run is one file. A file repeated on the command line, or holding the same JSON content as
an earlier one (whitespace and key order do not count), is skipped with a note, so it does
not add a rep. A question id repeated within one
file is an error (exit 2): it would count as two runs.

The table always goes to stdout: the flagged and provisional items, with the no-skill arm
in its own column, a note naming any arm no run scored, and the counts. The JSON goes to
`-o` only, as a `headroom.v1` artifact with every item; if that file cannot be written the
command prints `Error: ...` on stderr and exits 2, after the table. Scores are compared unrounded
and shown rounded down, so 0.89996 prints as 0.89. Without `--no-skill-arm`, the runs must
declare the same baseline; the default is that baseline. With `--no-skill-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.01, 1],
`--floor` 0 or in [0.01, ceiling), `--min-reps` at least 1. It also exits 2 when no run scored the
no-skill arm, or when `-o` is one of the input files (or its `.rejected` sibling), or names a directory or a path ending in a separator. Scores are read as the 0-100
aggregate divided by 100 and are not range-checked. Only arms runs are read. The flags are descriptive: they do not
change any score or any gate.
Comment thread
antonwolfy marked this conversation as resolved.
Resolved

## Scope and caveats

- **Three levels of fidelity for skills/MCP.** (1) `skill:` / `mcp:` inject
Expand Down
120 changes: 120 additions & 0 deletions dokimi/docs/reference/cli-interface.json
Original file line number Diff line number Diff line change
Expand Up @@ -4201,6 +4201,126 @@
"subcommand_required": null,
"subcommands": {}
},
"headroom": {
"allow_abbrev": true,
"params": [
{
"action": "_StoreAction",
"choices": null,
"const": null,
"default": null,
"group_key": null,
"group_required": null,
"hidden": false,
"is_flag": false,
"key": "arms",
"kind": "argument",
"nargs": "+",
"position": 0,
"required": true,
"type": null
},
{
"action": "_StoreAction",
"choices": null,
"const": null,
"default": 0.9,
"group_key": null,
"group_required": null,
"hidden": false,
"is_flag": false,
"key": "--ceiling",
"kind": "option",
"nargs": null,
"position": null,
"required": false,
"type": "float"
},
{
"action": "_StoreAction",
"choices": null,
"const": null,
"default": 0.0,
"group_key": null,
"group_required": null,
"hidden": false,
"is_flag": false,
"key": "--floor",
"kind": "option",
"nargs": null,
"position": null,
"required": false,
"type": "float"
},
{
"action": "_HelpAction",
"choices": null,
"const": null,
"default": "==SUPPRESS==",
"group_key": null,
"group_required": null,
"hidden": false,
"is_flag": true,
"key": "--help/-h",
"kind": "option",
"nargs": 0,
"position": null,
"required": false,
"type": null
},
{
"action": "_StoreAction",
"choices": null,
"const": null,
"default": 3,
"group_key": null,
"group_required": null,
"hidden": false,
"is_flag": false,
"key": "--min-reps",
"kind": "option",
"nargs": null,
"position": null,
"required": false,
"type": "dokimi.cli.build_parser.<locals>.positive_int#faf57db5ff9f"
},
{
"action": "_StoreAction",
"choices": null,
"const": null,
"default": null,
"group_key": null,
"group_required": null,
"hidden": false,
"is_flag": false,
"key": "--no-skill-arm",
"kind": "option",
"nargs": null,
"position": null,
"required": false,
"type": null
},
{
"action": "_StoreAction",
"choices": null,
"const": null,
"default": null,
"group_key": null,
"group_required": null,
"hidden": false,
"is_flag": false,
"key": "--output/-o",
"kind": "option",
"nargs": null,
"position": null,
"required": false,
"type": null
}
],
"path": "dokimi eval headroom",
"subcommand_required": null,
"subcommands": {}
},
"judge-validity": {
"allow_abbrev": true,
"params": [
Expand Down
1 change: 1 addition & 0 deletions dokimi/dokimi/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"result": "result.v1",
"repo_budget": "repo_budget.v1",
"task_inspect": "task_inspect.v1",
"headroom": "headroom.v1",
}

# kind -> superseded versions still accepted on *load*, newest first. An artifact written under an
Expand Down
4 changes: 4 additions & 0 deletions dokimi/dokimi/commands/evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,10 @@ def register(sub, positive_int) -> None:

answer_formats.register_eval(eval_sub, positive_int) # eval reference, eval native-import

from dokimi.commands import headroom

headroom.register_eval(eval_sub, positive_int) # eval headroom

# eval panel-score
panel_p = eval_sub.add_parser("panel-score", help="Score answers using a multi-judge panel")
panel_p.add_argument("--answers", required=True, help="Path to answers JSON file")
Expand Down
128 changes: 128 additions & 0 deletions dokimi/dokimi/commands/headroom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

"""``eval headroom``: flag the ceiling, floor and provisional items of arms runs.

Reads one or more ``arms.v1`` files of the same question set (one per repetition) and writes a
``headroom.v1`` artifact with ``-o``, always printing the human table. A file repeated on the
command line, or holding the same JSON content as an earlier one, is one run: the repeats are skipped with a
note on stderr. See :mod:`dokimi.eval.headroom` for the rules.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import sys
from pathlib import Path

from dokimi import records


def _content_digest(path) -> str:
"""Digest of the parsed JSON, so one run re-serialized (whitespace, key order) counts once."""
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()


def _same_file(a, b) -> bool:
"""Whether two paths name one file. A path that cannot be resolved (a symlink loop on some Pythons)
is not taken as an input: its own open reports the error."""
try:
return Path(a).resolve() == Path(b).resolve()
except (OSError, RuntimeError):
return False


def _names_a_directory(output) -> bool:
"""Whether ``-o`` spells a directory: its last component is empty, ``.`` or ``..`` (a trailing
separator, with or without ``/.``), or it is an existing directory. A path that cannot be
stat-ed is not one: the save reports it, as before."""
text = str(output)
if text.replace(os.sep, "/").rsplit("/", 1)[-1] in ("", ".", ".."):
return True
try:
return Path(text).is_dir()
except (OSError, ValueError):
return False


def _clobbers_an_input(output, inputs) -> bool:
"""Whether a save to ``output`` would overwrite an input: the file itself, or the ``.rejected``
sibling save_artifact writes on a schema failure and deletes after a good save."""
out = Path(output)
rejected = out.with_name(out.name + ".rejected")
return any(_same_file(out, p) or _same_file(rejected, p) for p in inputs)


def cmd_eval_headroom(args: argparse.Namespace) -> None:
from dokimi.artifacts import ArtifactValidationError, save_artifact
from dokimi.eval.headroom import format_table, headroom, repeated_ids

if args.output and _names_a_directory(args.output):

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: python cli.py eval headroom valid-arms.json -o "" sets args.output to an empty string, so all three truthiness checks skip destination validation and saving. The command exits successfully without producing the explicitly requested artifact, rather than reporting an invalid destination with exit 2. Use args.output is not None for the checks at lines 64, 70, and 103; _names_a_directory("") already rejects the empty destination. Add a regression test through the real parser.

Suggestion:

Suggested change
if args.output and _names_a_directory(args.output):
if args.output is not None and _names_a_directory(args.output):

# A directory has no file name for save_artifact's sibling paths ("." has an empty name), and
# Path() drops a trailing slash, so "newdir/" would otherwise write a plain file named newdir.
print(f"Error: -o {args.output} is not a file name (a directory, or a path ending in a separator)",
file=sys.stderr)
sys.exit(2)
if args.output and _clobbers_an_input(args.output, args.arms):
print(f"Error: -o {args.output} would overwrite one of the input arms files (or its .rejected sibling); "
"the run would be lost", file=sys.stderr)
sys.exit(2)
runs: list[records.Run] = []
inputs: list[str] = []
first_seen: dict[str, str] = {}
for path in args.arms:
try:
run = records.load(path)
digest = _content_digest(path)
except (records.RecordError, OSError, RecursionError) as e: # RecursionError: nesting too deep for json
print(f"Error: {e}", file=sys.stderr)
sys.exit(2)
if digest in first_seen:
print(f"note: skipped {path}: same content as {first_seen[digest]}, counted once", file=sys.stderr)
continue
dupes = repeated_ids(run)
if dupes:
print(f"Error: {path}: question_id {dupes[0]!r} appears more than once; "
"a file holds each question once", file=sys.stderr)
sys.exit(2)
first_seen[digest] = str(path)
runs.append(run)
inputs.append(str(path))
try:
result = headroom(runs, no_skill_arm=args.no_skill_arm, ceiling=args.ceiling,
Comment thread
napetrov marked this conversation as resolved.
Resolved
floor=args.floor, min_reps=args.min_reps)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(2)
result["inputs"] = inputs
print(format_table(result))
if args.output:
try:
save_artifact("headroom", result, args.output)

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.

-o can point at one of the input arms files and overwrites it.

eval headroom run1/arms.json -o run1/arms.json exits 0 and replaces the arms run with the headroom artifact; its answers and evaluations are gone.

Suggestion: refuse an output path that resolves to one of the inputs (Path(args.output).resolve() against the resolved inputs) with exit 2.

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: the -o guard refuses an output that resolves to an input (commands/headroom.py _same_file, f259a4f), with an alias test (see the reply on that thread).

except (OSError, ArtifactValidationError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(2)
print(f"Wrote headroom artifact to {args.output}")


def register_eval(eval_sub, positive_int) -> None:
from dokimi.eval.headroom import DEFAULT_CEILING, DEFAULT_FLOOR, DEFAULT_MIN_REPS

h = eval_sub.add_parser(
"headroom",
help="Flag arms items with no headroom: no-skill ceiling, all-zero floor, provisional reps")
h.add_argument("arms", nargs="+", help="arms.v1 JSON, one per repetition of the question set")
h.add_argument("--no-skill-arm", default=None,

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.

After #254 the new flags fail the flag registry check and the CLI snapshot, so the PR breaks main's tests once merged.

On a merge of this head with origin/main, tests/test_cli_flags.py fails 2 tests: test_every_spelling_on_the_tree_is_canonical_or_allowlisted (--no-skill-arm, --ceiling, --floor, --min-reps) and test_the_cli_interface_snapshot_is_current. @antonwolfy noted this in their review; one addition: the registry already has a concept for this flag. baseline ("the run or arm a comparison is made against") has the canonical --baseline-arm NAME, which arms run uses for the same arm.

Suggestion: merge main, rename --no-skill-arm to --baseline-arm (docs, changelog and tests too), allowlist --ceiling, --floor and --min-reps with a reason (no registry concept for headroom thresholds), then run make cli-snapshot.

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 7b69f5a: the four spellings (--ceiling, --floor, --min-reps, --no-skill-arm) are allowlisted with a reason each, following the file's convention. tests/test_cli_flags.py and tests/test_import_architecture.py: 36 passed, 1 skipped. The rows name the PR and the F1b migration that moves them.

help="Arm read as the no-skill control (default: the baseline the runs declare)")
h.add_argument("--ceiling", type=float, default=DEFAULT_CEILING,
help=f"Flag an item when the no-skill arm scores at least this, 0-1 (default {DEFAULT_CEILING})")
h.add_argument("--floor", type=float, default=DEFAULT_FLOOR,
help=f"Flag an item when every arm scores at most this, 0-1 (default {DEFAULT_FLOOR})")
h.add_argument("--min-reps", type=positive_int, default=DEFAULT_MIN_REPS, dest="min_reps",
help=f"Items with fewer runs than this are provisional (default {DEFAULT_MIN_REPS})")
h.add_argument("-o", "--output", default=None, help="headroom.v1 JSON to write")
h.set_defaults(func=cmd_eval_headroom)
Loading
Loading