Repository navigation
evaluation-harness v0.4.0
See your runs, not only tables of them. evh catalog builds static pages over a project's
runs, and evh catalog-serve runs an inspector that tests chosen arms against a baseline and
shows one item's outputs beside its reference. A project on the harness's layout needs no code;
one with its own layout writes one adapter. Goals record what an evaluation is for; items carry
the typed fields a project declares, and can live at a pinned URL. The paired test now lives in
one place, evaluation_harness.stats, exact at 30 items or fewer.
Upgrading can move a result only where pair-test or family-test ran on 30 items or fewer,
in a wins count or an order on a lower-is-better metric, or where --exclude-dataset named a
dataset whose items are not .txt files under data/sources/<id>/ (all below), and adds no
dependency. Design:
ADR-0002,
ADR-0003;
how to upgrade: UPGRADING.
This repository now deploys the harness on its own example experiments:
- the catalog and run inspector are public at
catalog.evals.humancopilot.eu; - the public site, the harness and its findings, moves from GitHub Pages to the same server;
- the docs move to docs.evals.humancopilot.eu, and old
/docs/links redirect there.
The deployment files are in infra/ (runbook: infra/README.md). They are not part of the
package.
The example experiments keep one record per arm, the latest: the rescored and re-parsed runs
are folded into data/runs as derived runs, and DailyMail's bart_s is its fp32 run
(coverage 0.4048; the fp16 run read 0.4039). CNN's bart_s stays the fp16 run, the one the
judge's verdicts were made on. The reports state the current figures only; their correction
logs are gone (git keeps them). Two published statements were stale and are restated:
SciFact's qwen_s e5-first against the e5-first leader is +0.0178, p 0.10 (it read +0.0092,
p 0.36, from the pre-fix run), and DBpedia's 56-item dev slice, over today's 28 arms, puts
bert_base_fy alone at the top rather than 13 arms in a tie.
Added
-
The rest of the inspector and catalog in the terminal. Every page and view now has a
command:evh search TEXTis the catalog's search box, ranked the same way, with the command that
shows each hit;evh inspect --itemsis the per-item table (each arm's score and its Δ against the
baseline);evh inspect --analysis [--x METRIC]gives the analysis charts as numbers: spread per run,
the difference from the baseline, and each arm's mean against a second metric.
-
pair-test --round rNtests within a round. A defaults to the round's leader, B to the
baseline the round started from, and the metric to the goal's measure. An arm that is not in
play is refused. -
On the goal page:
- each round has an inspect button that opens its arms against its leader (
evh inspect --round); - every suggested command has a copy button;
- a retired arm is greyed out.
- each round has an inspect button that opens its arms against its leader (
-
Experiment rounds, optional (ADR-0004).
Experiments go round on round: try some arms, keep what wins, drop what lost, change something,
compare again.evh round-open --goal G --dataset D [--from rN]records what a round sets out to
learn, what changed, the arms carried in unchanged (--keep) and those dropped with the reason
(--retire ... --reason); arms join by naming it in their config (round: rN, recorded on the
run).evh rounds --goal Greads the evidence the way the inspector does — the round's arms
against its leader, its best against the previous round's best and against the baseline it
started from, its spend againstbudget_usd— and suggests the next decision, each as the
command that makes it:evh round-retire,evh rank-stability,evh round-close --conclusion ... --promote <run>(which promotes the baseline with the conclusion as its reason). A closed
round refuses new runs; validate (V14) checks the records;leaderboardandfamily-testtake
--round. The goal page shows the rounds, newest first, with the open round's suggestions. The
catalog stays read-only: decisions are commands. A project that never opens a round sees none of it. -
A goal page leads with its answer. First the promoted baseline on each of its datasets and
why (or "no decision yet", with the command that makes one), then what the goal is for, then on
each dataset its references and its arms in groups by what they are scored against: gold,
silver, a judge. Arms are ranked only within a group, and a group's leader is never an arm graded
against its own outputs. A dataset page shows its baseline and reason, and the baseline's row is
tagged.evh goals --goalandevh datasets --dataset-idsay the same. -
Everything the catalog and the inspector show is a command.
evh datasets(one dataset:
its versions, references and arms),evh references(one: who wrote it, from which run, what
is scored against it),evh metrics,evh goals(one: its arms per dataset) and
evh inspect --runs …(the inspector's verdicts, conditions,--where/--byon fields,
--itemfor one item's outputs beside the reference). Same adapter, same computation: the
numbers are the pages'. Each is also amaketarget. -
The catalog says what every number is measured against. A metrics page (what each metric
measures, which way is better, where it is recorded, which goals decide on it) and a page per
reference (who wrote it, from which run, every arm scored against it); every metric and
reference on the pages links there. Each arm's row says what it is scored against, and rows
scored against silver, by a judge, or against the reference the arm wrote itself are tinted; a
judged arm reads "judge ", no longer "gold". A goal page flags the arm that wrote the
reference it is scored against, so it is never the leader. The inspector's charts section is
"analysis", and its verdict rows carry the same tints. Actions are compact and tinted. -
Long tables page. Every catalog table and the inspector's per-item table show 10 rows, with
"show 10 more" and "show all"; the filter and the sort work over every row, and a link to a row
opens the table at it. The verdict tables stay whole: their Holm thresholds are read together. -
Dataset versions keep their lineage.
evh dataset-create --dataset-id x_v2 --from x_v1
records where a version came from and the item ids it adds, removes and changes
(derived_fromin the dataset file);validateV2 fails a version whose parent is gone. The
catalog links versions both ways with what changed, and a version no arm has run on yet gets
a page when it is linked to one that has. -
The inspector says whether the compared runs ran under the same conditions, from their
fingerprints: the machine, library versions, the harness, the scorer (adapter code), the
items and the reference's bytes. One line when all match; otherwise the fields that differ,
arm by arm, with a warning when the scorer or the reference bytes differ (different
measurements) or latency is compared across machines. A dataset page names each arm's machine
when its arms ran on more than one. -
METRIC_DESCRIPTIONSin an adapter, one sentence per metric, is recorded on every run
(metric_descriptionsin metrics.json) and refreshed byevh rescore; the catalog reads the
adapter's current descriptions over the recorded ones, so describing a metric later needs no
new run. The harness describes its own metrics (cost, tokens, latency, judge scores). -
A run becomes a reference:
evh reference-create --from-run <run_id>(make reference-create RUN=<run_id>). The usual way to get references is to run the expensive
model as an arm, read its results, and score the cheaper arms against what it wrote. Until now
that took a second paid pass (--config), whose outputs could differ from the run you read,
and the reference did not say which run it came from.--from-runcopies a finished run's
outputs and calls nothing; the manifest records the run, its config, fingerprint, build and
cost. It refuses whatrun-promoterefuses (a dirty tree, an unknown build), an unfinished,
derived or other-dataset run, and--tier gold(gold is what a person wrote or checked).
With--not-redistributedonly the manifest is committed: it records the command that rebuilds
the texts from the run, and that command, on a checkout with the manifest alone, writes them
again and checks them against its checksums without touching it. -
reference-createrefuses an empty output, and outputs that stopped atmax_tokens, by
either path (--allow-cut-offfor outputs that end at the cap by design). A frontier arm at
max_tokens: 80wrote 19 of 20 summaries cut off mid-sentence and one empty reply, and they
were frozen as the reference with no word. -
The leaderboard marks the arm that wrote the reference it is scored against (
arm *, and
a note): that row is graded against its own outputs. -
reference-createsays when gold exists (new runs keep scoring against gold; how to rescore
against the new silver), and to commit the reference before the next run: an uncommitted
reference makes every later run dirty. -
A newcomer scenario for every release
(SCENARIO): a fresh
project, the published docs only, a summariser and a classifier with small CPU models, cheap
hosted LLMs, a frontier reference and a judge, browsed in the catalog, under $1. RELEASING
runs it before the merge. -
A goal weighs cost, quality and speed, and recommends
(ADR-0006).
Cost (USD per item, tokens beside it) and speed (latency p50 and p95) are built in;
success_criteria.axesadds limits, another measure,role: ignore, or the project's own
per-item metric in one line. Astrategysays who is tied with the quality leader
(balanced, the default: not separated by the paired test and within a margin, 2% of the
leader unless the goal sets one;quality_first;frugal) andtie_break(default cost,
then speed) picks among them. Limits come first; flagged and retired arms are never
recommended, nor is an arm measured on fewer items than the rest of its group; identical
scores are always tied; a missing number is "not recorded", and an axis a tied arm does not
record cannot break the tie; speed is not compared across machines the runs recorded. Some
groups show their leader and never recommend: a dataset markeddecides: false, a silver
group beside a gold one, and a group measured by unvalidated judges ("Shown, not
recommended", with the reason). The helpers are public asevaluation_harness.axes.
The goal page shows the decision with its reasons, an axis column per arm and quality
against each axis as charts; the home page's glance a recommended column with cost and
speed against the baseline; the dataset and arm pages cost and speed; the inspector's scatter
cost and tokens.evh goals --goalandevh overviewprint the same. Guardrails are shown
on the goal page and in the CLI. When the recommendation is not the promoted baseline, the
goal page andevh goals --goalsay by how much and whether the test separates them; with no
eligible arm, they count the reasons. The goals page andevh goalscount configs filed and
show each goal's recommendation. Cost and speed metrics are their own kinds, with the goals
that weigh them, and costs print to six places; chart names no longer overlap. -
Reference manifests and V16. Every reference carries a
manifest.jsonwith the checksum of
each item's text (schemas/reference.schema.json);evh reference-manifestwrites it for gold a
script builds,reference-createfor the references it makes. It is committed even when the texts
are not redistributed:evh validate(V16) checks a rebuilt copy against it, and the catalog lists
such a reference as fetched. The manifest names the dataset (dataset_id), so a second gold for
one dataset can have a name of its own (reference-manifest --dataset-id, e.g.
gold/ag_news_200_adj1 for ag_news_200). This repository's gold references now have one. -
round-retire --runs-dirretires one copy of a config (its runs in another runs directory,
such as a re-timing on another machine) and leaves the arm in the project's runs directory in play.
Changed
- A config with a key written into its params is refused (
api_key,access_token,
secret, ...): it would be copied into every run record and onto the arm page. Name the
variable withapi_key_envinstead; the catalog shows any such param as "(hidden)". success_criteria.secondaryis removed: its entries areaxes(UPGRADING); a goal that
still has it fails V14.- Documentation never moves a fingerprint (fingerprint version 4). Code is hashed by what it
does: its syntax tree without docstrings, comments, formatting orMETRIC_DESCRIPTIONS
(assigned, or imported from a shared module). That
holds for the adapter, the harness's measuring modules, judge_score's own hash and the examples'
code_digest. A judge scorer YAML is hashed by its parsed content, and a reference manifest's
caveatis outside the reference digest. Adding metric descriptions to an adapter used to make
run-compare and the inspector call every earlier run "not the same conditions". The tree is
serialised without empty fields, so one file hashes the same on Python 3.11, 3.12 and 3.14
(ast.dumpdoes not). v3 and v4 digests are not comparable. The committed v3 runs here were
migrated: each recorded hash was matched to the code or reference state it came from (git
history, unreachable objects included) and recomputed. No score changed. - import-run hashes the scoring code itself:
--scorer-filereplaces--scorer-sha256.
Give the file (or several, repeated in a fixed order) and the harness hashes it by the same
rule, so a docstring in your scorer is not a new scorer. The hash is now the run's
instrument.adapter.sha256, inside the fingerprint hash: before, it sat only inimported,
outside the hash, so two imports scored by different versions of the code looked like the same
conditions. - One install calls hosted models.
openaiandanthropicare core dependencies; the
[llm]extra is gone. A newcomer's first hosted arm failed on the import, and only one RUNBOOK
section named the extra. - The core times every call.
latency_msis recorded for any adapter that does not report
its own (the attempt that answered, not the retries' backoff); only the bundled adapter used to
time itself, so a project's adapter recorded no speed. This can move a result: a project's
runs gain a latency column, and the leaderboard its cost/speed frontier. - experiment-run warns when outputs stop at
max_tokens, by the provider'sfinish_reason
(the bundled OpenAI-compatible and Anthropic providers now record it) or tokens_out reaching the
cap, naming the items: they are scored as if complete. - A run that stops mid-pass says what to do: which run, after how many items, the exact
--resumecommand, or delete it; V7's failure says the same. - validate V5 no longer fails two different arms that are both perfect. On a saturated binary
metric their per-item scores agree too; different outputs now tell two measurements from a copy. - The inspector's item view shows the item itself (what every arm read), folded when long; the
local inspector only.evh inspect --itemprints it. - Bookkeeping fields (
resumed, the judge'sself_family_*) stay in the record but are listed as
metrics nowhere (leaderboard, runs-list, the spread, the inspector). - The leaderboard's version note fires only for one arm at two versions, not for different arms at
different versions (judge-score's too); "P(top 1)" is no longer printed beside "P(1st)" when they
are the same; the rounding note names the scorer as what rounds. evh init's.gitignoreignores__pycache__/; experiment-run ends "wrote N run(s) to …".- Exact p-values at 30 items or fewer.
pair-testandfamily-testnow count the
two-sided sign-flip p over all 2^n sign patterns when a pair shares 30 items or fewer, instead
of estimating it from 20,000 seeded permutations. Above 30 items the estimate is unchanged,
bit for bit, includingfamily-test's single stream across opponents. Near a threshold the
estimate could land on either side of it; the count cannot. This can move a result:- in this repository's reports: none moved. All 200 report claims re-derive unchanged,
because every published pair and family test is on more than 30 items; - in the tutorial (6 items):
pair-testreads 0.0312 (was 0.0330), and the family test
0.0938 againstlead1and 0.3125 againstauthor(lead2_noisy's is unseeded and moves
with every run). The verdicts are the same (getting started step 7, tutorial 3 step 4); - in your project: re-run
pair-test/family-teston any pair with 30 or fewer
shared items. Whether a verdict near its threshold moves depends on your data.
- in this repository's reports: none moved. All 200 report claims re-derive unchanged,
pair-testsays which it did:(exact, all 2^n sign patterns)or(20000 permutations).family-test's cache key includesstats.py, so a cached answer from 0.3.0 is recomputed.validateV4 no longer passes what it did not check. A materialized copy that has lost
an item file now fails; it used to be skipped. A dataset with no materialized copy on the
machine (materialized data is not committed, so a CI job has none) is named as not checked,
and the ok line says how many were checked, e.g. "3 of 14 datasets materialized here". A
project whose materialized copy is incomplete goes red where it was green.- The bundled adapter names its primary metric (
PRIMARY_METRIC = "overlap_f1"). New runs
on it recordprimary_metric: overlap_f1. The leaderboard already ranked them by it, first
by name; it no longer prints that the metric was "chosen by sort order", and the catalog and
the inspector open on it. Runs made before keep what they recorded. docs/GUIDE.mdis getting started, in your own project: install the package,evh init --tutorial, then the loop withevh(a dataset, arms, a reference from a run, a comparison, a
choice). It used to run inside a checkout of this repository, withmake, and to hold every
step. The rest is in five tutorials (docs/tutorials/): a local model against a baseline, a
hosted LLM with your own key (which variable each provider reads), a frontier model's run as
the reference with gold and judging, experiment rounds and dataset versions, and the catalog
and the inspector with the command behind each view.make guide-checkruns getting started
and the three offline tutorials, each from where getting started ends. The README and the
RUNBOOK lead withevh.- The default metric is the one your runs recorded.
pair-test,family-test,holdout
andrank-stabilitydefaulted tocoverage, the summarisation example's metric: in any
project that does not score it they found no runs, or crashed. Without--metricthey now
read the primary metric the runs on the dataset recorded, and refuse, naming the recorded
metrics, when none did. evh self-testin a project checks the installed harness: the paired test's own suite,
then the tutorial end to end in a scratch folder (dataset with fields, runs, pair-test,
rescore, validate, catalog). It used to run this repository's development suite, which
checks this repository's reports and failed 52 checks in every project; in a checkout it
still does.- A metric's direction is inferred, not remembered by a flag, by one rule for every command
and the inspector: a flag, else the goal the runs name, else the adapter'sMETRIC_DIRECTIONS
(new, recorded on the run) orimport-run --asc(a field nothing read until now), else the
name (cost, latency and token metrics go down); V14 fails a goal that contradicts its runs.
So lower is better without--ascforpair-test's "A wins",family-test's wins
and "ahead", the leaderboard's order and significance block,holdoutandrank-stability.
leaderboard --sort latency_msranked the slowest arm first. p-values do not change (the
tests are two-sided);--ascand--descoverride. This can move a wins or "ahead"
count, and an order, on a lower-is-better metric; no published claim is on one (200/200
re-derive). validatechecks the project's own run directories. V7, V8 and V9 read this
repository's six directory names, so a project'sdata/runs-adjudicatedwas never checked;
V10's scorer sources were this repository's example paths. They are project policy in
<data>/validate.json(committed,floors,scorer_sources,duplicate_ok); without it
every git-trackedruns*directory is checked (validate_floors.jsonis gone).- The package holds the harness, not this repository's tests. The wheel shipped ~14,500
lines of tests and report checkers; they are inharness/tests/andharness/tools/in the
checkout.self-test's own suite (test_stats.py) still ships. A run'scode_sha256hashes
the code that can move a number (the run-writing commands and what they import, read from
the source), so editing a test no longer shows inrun-compareas a confound; 0.4.0 runs
record a different value from 0.3.0 runs. The deploy installs the inspector from the wheel
too. - Refusals and arguments. Every command refuses with
ERROR: …on stderr and exit 1 (four
printed to stdout); a command that takes no arguments refuses one with exit 2 (validate,
self-testand five checkers ignored them). RUNBOOK: exit codes and every environment
variable. - The bundled adapter refuses an unknown
provider:(a typo fell back toechoand
recorded the echoed input as the model's output), and records what an OpenAI-compatible
provider billed when the response carries it. One price rule for it and the judges. - One run record, one format; a derived run says what it was derived from.
rescore,
judge-scoreand the SciFact re-parse writederived: {kind, from, at, with}, their own
created_at, andsource_created_at.rescored_from,rescored_with,judged_from,
judged_with,judge_label,reparsed_*andprimary_metric_ascare gone, and nothing
reads them: a 0.3.0 derived run is read as a measured one. Every field the harness writes
is declared inmetrics.schema.json, and V1 fails a run record with any other (0.3.0's
import-run --ascwroteprimary_metric_asc, which nothing read; 0.4.0 writes
metric_directions, which every command reads).EVAL_GOALS_DIRreads goals from outside
the data root;latency_sis a speed metric, lower is better, aslatency_msis. A goal may name no dataset (one checked by a gate) and may set
direction: non_inferior, a guard that ranks nothing (no significant drop against a baseline).rescoreno longer records a metric it kept as
dropped (rescore_dropped_metricsnamed whichever key a loop ended on). --resume-unverifiedis gone.experiment-run --resumerefuses rows written before
0.4.0 (no_extra); experiment-run writes_extraon every row, so its own rows resume.- The integration seam is
adapter.pyat the project root. An arm whose config names no
adapter:uses it, recorded asadapter.py, and the bundled adapter only when there is
none. The docs lead with the pin andevh initrather than a copiedharness/. - Importing the package changes nothing.
.envis read when a harness command starts,
not on import, and the project's paths resolve on first use, so an adapter's tests or a
notebook can setEVAL_HOMEafter the import.evaluation_harness.catalog_adapterreads
no path at import, and itsload_adapterisload_catalog_adapter. - Configs, judge scorers, judging sets and verdicts have schemas, and
validateV1 checks
them. A config key other thanconfig_id,dataset_id,params,adapter,goaland
formatfails (a mistyped key was read by nothing and passed). - A baseline carries what its number means:
run-promoterecords the run's reference
tier, adapter and fingerprint hash,runs-list --baselinesprints the tier and adapter, and
V6 fails a baseline that no longer matches its run. A 0.3.0 baseline fails V6 until it is
promoted again. Promoting replaces the baseline: the copy archived to
data/baselines/superseded/and thesupersedesfield are gone, and git keeps the old one. - A command run from a project's subfolder finds the project: without
EVAL_HOME, the
root is the nearest directory up with adata/folder (it was the current directory, so
evh validatein a subfolder read an empty project). - The judge panel is a command,
evh judge(it sat beside the package and could not run
from a pin); the tutorial kit shipstutorial/judge_panel.yaml.
Fixed
- V9 fails when git cannot list the committed runs. A
git ls-filesthat refused (a committed
directory behind a link that leaves the repository) listed nothing, and V9 read that as nothing
missing: a run with no rows passed. It now fails and quotes git's error; a project that is not a
git repository has nothing committed, and V9 says it was not checked. silver-calibrateno longer prints a ceiling it did not measure. With one set of runs
serving as both references and arms, it correlated a ranking with itself and printed "rho =
+1.000 … from two different sets of runs". The ceiling now counts only arms whose two sides are
different runs, and says "not measured" when fewer than three are.- From an external review of the core and the UI surfaces (0.4.0 candidate):
- A run scored against references for only some items says so. experiment-run took a
reference directory as covering the dataset, so gold for 20 of 200 items gave a 20-item mean
recorded as a 200-item gold run. It now counts coverage before any call. With no file for any
item it refuses. With some, it warns, recordsreference_coverageon the run (schema), and
the leaderboard names the run. This can move a result: a run that was refused now stops. - A pair opened from a goal page's group is held to the group's family. "test vs leader"
passesfamily=<group size − 1>, so it is not tested as if it were the only pair. The
inspector refuses afamilysmaller than the arms tested, and athresholdabove alpha or
below what the family allows, instead of obeying a URL. - rescore and judge-score keep a project adapter's call-time extras (named in the row's
_extra) andresumed; they were dropped and reported as metrics the scorer dropped. - The adapter contract:
- INTEGRATION gives
lower_is_better(metric, dataset), as the code calls it; catalog_adapterre-exportsload_fieldsetandfieldset_problems, so an adapter needs
no private_fields;- two new optional members:
arm_of(dataset, config_id)(rounds,inspect --round; it
followsEVAL_RUNS_DIR) andreference_free(metric)(from the metric's kinds, not its
name); - the structured view kinds (
insights,nodes,entities) are documented; evh roundsreads the project's catalog adapter;- the inspector's default baseline is the promoted baseline's arm.
- INTEGRATION gives
- A public inspector publishes text only when told.
catalog-serve --publicserved every
arm's output and the reference text for any item, while INTEGRATION and the adapter contract
said that text never leaves the machine. It now serves numbers, adds outputs or reference text
only for what--publish outputs,referencesnames, and never serves the item itself; the item
view says what it withholds. This can change a deployment: one that relied on the old
behaviour adds--publish. This repository's own publishes outputs (already public in the
repository), not references. - The catalog opens on the whole project, and every name is a link.
- Home is a dashboard: counts (goals, datasets, configs, arms, references, metrics, runs,
USD billed, tokens), each goal's answer at a glance (its baseline, the leader of the gold
group and the paired test between them), activity and spend side by side (spend in USD or
in tokens, so a free local model's use is tracked too), what each dataset covers, and
everything recorded; each for the last 14 days of activity or all time.evh overview
prints the same. - New pages: Datasets (what the home page listed) and References in the menu; a page per
arm (its metrics, runs, config without credentials, conditions, the same config scored other
ways, its rounds;evh arms --arm) and per metric (what it measures and how, which way is
better, its kind, the goals it decides, every arm recording it with a strip chart;
evh metrics --metric). - Links everywhere: arms, metrics, datasets, references and goals link to their pages
from every page and table, a reference's author to its arm, a round's configs to theirs; in
the inspector, arm names and the metric open their pages. - Metric explanations: the harness explains its own metrics at length; a project's
METRIC_DESCRIPTIONSvalue may be{"summary": ..., "details": ...}for its metric pages. - The catalog adapter gains optional
arm_runs,arm_configandspend. - From the second newcomer run: the inspector warns when the baseline wrote the reference
the arms are scored against; a judged arm's item shows each judge's score beside the output;
the goal page says what "filed" means.
- Home is a dashboard: counts (goals, datasets, configs, arms, references, metrics, runs,
- One provider layer for the systems under test and the judges, keys by role. The bundled
adapter, judge-score's judges and the judge panel call hosted models through one module,
_providers:openai(OpenAI or any compatible endpoint),anthropic,openrouterand
litellm, each takingmodel,base_url,api_key_env,temperatureandmax_tokens.- A judge goes where its
provider:says. Judges ignored it and went to a LiteLLM
proxy ifLITELLM_BASE_URLwas set, else to OpenRouter with an alias looked up in
models.json, while the record said the declared provider. The scorer schema now
requiresprovideron every judge. This can change a setup: re-running the
committed scorers (provider: litellm) needs a LiteLLM proxy. - Judging can have its own keys. A judge reads every variable as
EVAL_JUDGE_<NAME>
first, then<NAME>, so it runs on separate keys, budgets and spend logs, or shares the
experiments' with no setting.evh env-checkshows what each role reads. - A key is not identity.
api_key_envis left out of a run's fingerprint and a judge's;
no committed hash moved (all 2,800 committed verdicts keep theirjudge_fp). - judge-score names each judge's route and key, dry run included, and stops before any
call when a key or address is missing. - The judge panel's judges are declared like judge-score's (
id,provider,model,
prices). It called only Anthropic, priced Sonnet in code, and ran the fake judge when
ANTHROPIC_API_KEYwas missing; the fake judge now runs only with--use-fake-judge. - Anthropic takes
base_urlandapi_key_envlike the others. Arms passreasoning,
reasoning_effortandprovider_routingto the request as judges did, and record
reasoning_tokens(descriptive, as every committed run declares it), the model and the
host that answered.
- A judge goes where its
- The harness imports itself as a package. Its modules imported each other as bare
top-level names (import stats) with their own directory onsys.path, so a project's own
stats.pyfirst on the path replaced the harness's paired test inside
evaluation_harness.judging. Underevh, the harness'sadapter,statsorroundscould
also hide a project's module of that name. They now importevaluation_harness.*, and nothing
of the harness goes onsys.path. In a checkoutharness/evaluation_harnesslinks to
scripts/, andpython scripts/x.pystill runs. A project script that relied on the
harness's directory being on the path writesfrom evaluation_harness.adapter import Result,
as INTEGRATION documents. - The public server trusts no client header unless told (
--client-header NAME); a
spoofedCF-Connecting-IPno longer buys a fresh rate bucket. The deployment names
Cloudflare's header (origin lock). - The rest of the page features have commands:
evh inspect --no-rescore(the inspector's
box), its "Average only" table, and the dataset page's fields block in
evh datasets --dataset-id. - Statistics, consistently:
family-testmarks an estimated p (~) when a family mixes exact and estimated rows;- pair-test separates at
p <= threshold, as Holm and the inspector do; - the bootstrap ties ranks with the same tolerance as the within-item ranks;
- runs that disagree on a direction or a primary metric are named;
- rescore refreshes
metric_directions; - the inspector and
evh inspectshow each row's 95% interval of Δ (exact up to 30 items, a
t interval above, as its docstring now says).
- Accessibility: a sortable column's header works from the keyboard and states
aria-sort;
every chart mark with a tooltip carries an SVG<title>. - Messages: rescore's missing-reference message no longer sends a project to
examples/; RFC-002 tells_extra(adapter extras) from_meta(provider metadata).
- A run scored against references for only some items says so. experiment-run took a
- From the second newcomer run (
/work/evh-scenario-040; SCENARIO, 0.4.0 candidate):- A baseline promoted from one of an arm's repeats records every repeat and their mean
underrepeats, andrun-promoteprints the two side by side. The catalog, the inspector
and the tests read the mean;scoresstays the run's own (V6). - A judged run keeps its source's cost and speed kinds, so the judged leaderboard has its
frontier. That leaderboard also prints the judging pass's agreement and its position and
noise checks, fromsummary.json. - The arm that wrote a silver reference is left out of the ordering test and the frontier,
and the leaderboard says so (its row measures agreement with itself). - A judge reply that is not committed (not bare JSON, or repeating the source) keeps its
text in a git-ignored.withheld/beside its record, and the log names the file. An
unparsed reply's cause can then be read; the record keeps only the hash (V12). - The dataset page and
evh datasetssort arms by what they are scored against (gold,
silver, a judge, nothing) before the mean. A silver and a gold mean no longer interleave. - The goal page and
evh goals --goalshow a judge's group, read on its own score.evh goals --goalalso lists the rounds and marks retired arms. - One p format everywhere (
stats.fmt_p: four decimals,<0.0001below that).
family-testsays whether p is exact or a seeded estimate. Above 30 items it draws one
random stream for the family, sopair-testand the inspector, which estimate one pair at a
time, can differ in the third decimal. Its wins column is labelledA wins. - Round messages:
round-open --keepsays carried arms need no change and no new run;evh roundsdoes not suggest promoting the run that is already the baseline;leaderboard --rounddoes not warn about two versions of an arm in a round that compares
them.
evh cost-reportsees the bundled adapter's bills. A provider's bill (OpenRouter, or a
LiteLLM proxy passing it through) was the run's cost but was not kept where cost-report reads
it, so every run read as "price table only". It is now kept in the item's_meta.usage.cost.evh inspect --itemprints which run each output is from and, for a repeated arm, that it
is the latest of N repeats beside their mean (as the page does).evh datasetscounts configs.- Smaller items:
experiment-runprints what the command spent, and its repeat spread calls cost and speed
what they are, not "deterministic";judge-scoresays its spend is the provider's bill where one is reported, else the price
table;silver-calibrateexplains rho and sibling lift;- the catalog counts the configs behind its arms;
- "filed" reads "filed under this goal";
- the item view says it shows the latest of N repeats beside their mean;
- judged arms keep their version in the inspector;
- a heading scrolled to is not hidden under the header;
env-checknames the example arms only in a checkout;- the tutorial scorer points at tutorial 3.
- A baseline promoted from one of an arm's repeats records every repeat and their mean
- One reference per table and per arm. The leaderboard averaged an arm's runs whatever they
were scored against: a silver 0.90 next to a gold 0.24, in one table labelled silver. It now
prints one table per reference, gold first, each with its own tests. The catalog, the
inspector andevh roundsread an arm against the reference of its latest run (by
created_at), and they name the earlier runs they leave out. A judged run is grouped by its judge,
not by its source run's reference.evh roundsreads a round against the reference most
of its arms use and names any arm scored against another. This repository's data, the
trial and the podcast project have no such arm. provider: openrouteruses OpenRouter. It readOPENAI_API_KEYand called OpenAI's
address unless the arm set bothbase_urlandapi_key_env. It now reads
OPENROUTER_API_KEYand callshttps://openrouter.ai/api/v1, and an arm's own values still
win.evh inspect --roundreads the round asevh roundsdoes: on the goal's measure, against
the round's leader. It defaulted to the first arm, so an arm with no score became the
baseline and the metric fell back tocost_usd. Arms with no score are named and left out.- The bundled adapter stores
overlap_f1as computed. It rounded the value to 6 decimals,
which made ties that are not there (the leaderboard warned about its own example). This can
move a result only where two arms tied at the sixth decimal. - Hints and messages:
- with no reference,
experiment-runnamesreference-create --from-runfirst; evh --helpdescribesreference-createas "from a run or with a config";env-checkand thelitellmprovider no longer tell a pinned project to copy a
.env.exampleit does not have;run-promoteno longer printsresumedandself_family_*among the scores (the baseline
file still keeps them);- inspector notes are no longer all labelled "Re-scoring";
- the bundled adapter and
dataset-create --helpdescribe what the code does now, not its
history.
- with no reference,
- A judge scorer must state its position and noise checks. ADR-0001 makes them part of the
pass, not optional, but a scorer that leftprotocol.position_check_fractionor
noise_check_fractionout ran with them off. Both are now required. The noise check must be
above 0, and so must the position check whenever a call sees more than one output (a pointwise
scorer has no order to check).judge-scoreand V1 refuse a scorer that breaks this. - A round is read against the arm that wrote its silver reference.
evh roundsand the goal
page now compare the leader with the previous round's best, the baseline and, new, the
frontier arm whose outputs became a silver reference of the dataset. Cost per item is shown
for each of those arms. That arm is the yardstick, so it is never suggested for retirement. - The inspector reads only arms and references the catalog lists. It asked the
adapter'sarms_ofabout any path a request named; an adapter that maps a path to itself
(the podcast repo's did) letcompareread outside the data tree (runs=../../.git). A
request may name an arm, or a path that selects arms (a run directory), but only arms the
adapter'sarms()reach, and a referencereferences()lists. - A catalog nav entry may carry a CSS class as a fourth element (
hide-smhides it on a
phone), and the inspector page names the command that serves it (serve). evh rescoreandevh silver-calibratein a project that pins the package. A run
made with noadapter:uses the bundled adapter and records it asscripts/adapter.py, a
path that exists only in a checkout, so both failed with "adapter not found" in every pinned
project on the default adapter (0.2.0 and 0.3.0 alike). They now score with the package's
own copy, the one that made the run, through one shared lookup. Found by following the GUIDE
in a brand-new project.- The "next, run this" hints name a command you have. Every one said
make …, a target
only a checkout of this repository has; throughevhthey now sayevh …(the first was
GUIDE step 1's "Next: make dataset-materialize"). Two were wrong in a checkout too: an empty
baselines list suggestedmake promote(the target isrun-promote), and an empty runs list
suggestedmake demo, which a project without the bundled sample data cannot run. evh catalog-serveon a port another program holds says so and suggests--port,
instead of a Python traceback.rescoreonto another reference no longer claims the references changed. Moving runs
from silver to gold (--reference-id) compared the gold files' hash with the silver files'
and, as they always differ, wrotereferences_changed_since_measurement: trueinto every
re-scored run and printed "the reference files have CHANGED". The move is recorded as
before (reference_id_at_measurement); the flag and the warning are kept for the same
reference's files changing. No committed run in this repository carries the false flag.- Commands that assumed a checkout, each found by running every
evhcommand in a
project that pins the package:rank-stabilitycrashed (set.intersection()) instead of saying no arm had the metric;
on a lower-is-better metric it named the worst arm the leader; with too few items for two
halves it now says so.--exclude-dataset(pair-test,family-test,holdout,bootstrap-test) read
data/sources/<id>/*.txtinstead of the dataset file: wrong for any item that is not a
.txt, any dataset from a manifest, any project keeping sources elsewhere, and "nothing
excluded" was then reported as true.- The leaderboard's metric kinds,
run-compare's, and the classification report's label
parser looked for a run's adapter in the checkout only; they now find the bundled one in
the package, asrescoredoes. bootstrap-teston a gold set of free text (summaries) treated every summary as a class
and would have reported a macro-F1; it now refuses, and its "too few items" refusal names
the threshold and the opponents.catalog-serveprinted its address without flushing, so under a log file or a process
supervisor the line did not appear;--build-diroutside the project was a traceback,
now a refusal;self-test --helpran the whole suite instead of printing help.
- The catalog counted a dataset's items from the harness's own path, not the adapter's,
so a project with its own layout got "the most items any arm scored". It now asks the
adapter (dataset_items, optional). - The bundled adapter's
anthropicprovider failed every call with the anthropic SDK 1.x,
which dropped thetemperature=keyword: a TypeError before the request was sent. The
temperature now travels in the request body, so the request is the one every SDK version
sends. A self-test checks the call against the installed SDK's own signature. Found by mypy. - A manifest URL could read a local file.
dataset-create --manifestfetched any scheme
urlopenaccepts, so afile:///…row was copied into the item cache and pinned, although
a dataset's schema allows only http(s). Every fetch now refuses other schemes before reading
anything. Found by bandit. - The judge panel judged placeholder text. Every finalist, real runs included, was scored
on "Synthetic item i for ". It now reads each finalist's stored outputs, finds runs
where every command does (EVAL_RUNS_DIR, the project), ranks by their primary metric, and
refuses a lower-is-better one rather than promote the worst. rescorecould record the bundled adapter's metric kinds in a project's run: two
registries named loaded adapters differently, and the lookup fell back to the bundled one.
One loader (_adapter.py), keyed by the resolved file.import evaluation_harness.judgingfailed in an installed package (No module named 'stats'), and importingcatalog_adapterbeside it loaded_commontwice.holdout's significance block had no direction, so on a lower-is-better metric it ranked
the worst arm first.- Checks that could not fail, or did not run:
self-test's cost-cap control turned any
failure into a skip;check_terminologywas failing and nothing ran it (it is inmake test
now);make -C harness docswas a no-op (eight targets were not.PHONY).
Added
-
evaluation_harness.stats. Standard library only, and the one place the paired test
lives:exact_pandsign_flip_p;holm, with a declared family larger than the p-values passed;exact_ci, an interval for the mean difference that agrees with the test: none at 5
items or fewer, where no shift can be rejected;compare, every arm against a baseline with verdicts in words;compare_by, the same test within each group of items, Holm-corrected over every group
and arm together;ranks,pearsonandspearman(average ranks for ties; a constant input returns
constant, NaN unless you pass another), which the judging, rank-stability and
report-check scripts share instead of keeping their own copies.
It has its own test suite,
scripts/test_stats.py, run bymake test. -
The exact count is ~4x faster (0.03 s at 30 items, was 0.11 s): the sign-pattern sums are
built by doubling a list instead of enumerating each pattern. The same sums, so the same p.
The exact interval bisects to ~1e-9 of its span, not ~1e-14: 1.4 s at 30 items, was 11 s.
It is shown to 3 decimals. -
evh catalogandevh catalog-serve(make catalog,make catalog-serve).-
The catalog: static pages over a project's runs, by dataset, built into
.build/catalog/. Each dataset page lists every arm with its headline number, and links
to the inspector for one arm, or for all the arms on one metric scored against one
reference. -
The run inspector: a server on 127.0.0.1 that tests any arms on one dataset against
a baseline withstats.py, so it gives the same p aspair-teston the same pair. It
shows the verdicts, charts of the per-item scores and differences, and, for one item,
each arm's output beside the reference. -
The same rules as everywhere else:
- Arms on different
dataset_ids are refused. - On a metric scored against a reference, an arm scored against another reference than
the baseline's (silver against gold, say) is named and not tested against it; cost,
latency, tokens and length compare across references. - The static pages carry counts and means only, never an item's text.
- The server answers only to a 127.0.0.1 Host. It serves only the catalog and the
data tree, and no dot-folder in either.
- Arms on different
-
An adapter is the only project code needed.
catalog_adapter.Adapterlists the
questions both tools ask. The built-inHarnessAdapterreads the harness's own layout
(runs*/,runs-judged/,references/): an arm is one config on one dataset, its
repeats averaged per item. A project with another layout names its own adapter in
config.yaml(catalog: {adapter: path/to/file.py:Class}) or passes--adapter. -
A public mode (
evh catalog-serve --public HOST), for a deployment behind a reverse
proxy that serves the static pages:- it answers only the named host;
- each client gets
--raterequests a minute; - at most
--max-armsarms in one comparison; - two computations at once, every answer cached;
- errors never show the server's internals;
--warmcomputes, at start, every comparison the built catalog links to.
evh catalog --link LABEL=URLadds the deployment's other sites to the nav. -
CSP-clean: no inline script or style. The theme script is a file, and the legend
colours are set from JS.
Tests:
tests/test_catalog.py(54 assertions), run bymake test. -
-
Goals: what an evaluation is for (
data/goals/<goal_id>.yaml,schemas/goal.schema.json).-
What a goal says: who uses the output, the decision it informs, the measure that
decides (and which direction is better), the output formats it accepts, its datasets
and their roles, and optionallymin_items, the fewest items a winner may rest on. -
Configs name their goal (
goal:) and may declare theformatthey ask for.experiment-runrefuses an unknown goal before it runs.- The run records both, outside the fingerprint: they change what a run answers, not
what it measured. run-promotecarries both onto the baseline.
-
validateV14 fails:- a goal that breaks its schema, or whose
goal_idis not its file name; - a config, run or baseline naming a goal that does not exist, or a format the goal
does not accept.
A dataset smaller than a goal's
min_itemsgets a note. - a goal that breaks its schema, or whose
-
Optional until a project opts in. Set
required_from: YYYY-MM-DDin
data/goals/policy.yaml, and every run created from then on must name a goal. A
project upgrading changes nothing until it sets the date. -
In the catalog: a goals page, and a page per goal. On each of the goal's datasets
it ranks the arms that record the goal's measure. An arm undermin_itemsor in a format
the goal does not accept is flagged and never the leader. Each arm links to the
inspector, tested against the leader. -
This repository's six experiments have goals (
data/goals/), and so does the
tutorial (tutorial/goals/tickets_summary.yaml). No date is set here, so nothing
already committed is held to one.
Tests:
tests/test_goals.py(14 assertions), run bymake test. -
-
Item fields (
data/fields/<fieldset_id>.yaml,schemas/fieldset.schema.json, ADR-0003).
The harness defines no field: a project declares its own in a field set, and any number of
its datasets name it.- Types: text, number, integer, boolean, date, duration (seconds;
h:mm:ssaccepted),
url, and choice with its values; a field can berequiredandpublic. dataset-create --fieldset ID --fields FILEattaches values from a CSV or JSONL, one
row peritem_id, and refuses an undeclared column, a mistyped value, a value outside a
choice, an unknown item and an empty required field.--update-fieldscorrects values on
an existing dataset.- Not the identity. A run's fingerprint hashes item ids and bytes only, so a corrected
field invalidates no run; the dataset recordsfields_sha256beside them. validateV15 fails a field set that breaks its schema, fields with no field set or an
unknown one, an undeclared or mistyped value, a missing required value, and a
fields_sha256that does not match (a hand edit).- In the catalog: each field with its coverage, and a summary of its values only when it
ispublic. In the inspector: an item's fields beside its outputs; a filter on any
field; a breakdown by a choice or yes/no field, tested per group withstats.compare_by.
A public deployment answers with public fields only. - The adapter protocol gains an optional
item_fields(dataset).
- Types: text, number, integer, boolean, date, duration (seconds;
-
Items fetched from a pinned URL.
dataset-create --manifest FILEtakes one row per item:
item_id,source_urlandsource_sha256(empty: fetched and pinned now), and field
columns.dataset-materializedownloads each into.cache/items/and refuses bytes that
hash to anything else, as it refuses a changed file. A transient failure is retried like a
provider call; a host that needs a header gets it fromEVAL_FETCH_HEADERS(that host only,
never on a redirect elsewhere, never written into a dataset). With--update-fields, an
empty cell clears a value.Tests:
tests/test_fields.py(47 assertions), run bymake test; the catalog's in
test_catalog.py. -
evh init [DIR] [--tutorial]starts a project:data/sources,data/configs,
data/goals,data/fieldsand a.gitignorefor what the harness derives.--tutorial
copies the GUIDE's kit (tickets, their fields, gold, arms, a goal, a judge scorer and its
prompt, the judge panel's config) intotutorial/; the kit ships inside the package. It never overwrites a file. -
make guide-check(part ofmake check) runsdocs/GUIDE.mdin a fresh project, as a
reader would, and fails when a quoted output no longer matches what the command prints.
Lines that the unseededlead2_noisyarm moves are marked in the GUIDE and not compared. -
The tests that run
evhinstall the real wheel (tools/_wheel.py), built from
pyproject.toml, instead of copying folders. A file the package data leaves out now fails
them, where a copy would have included it. -
make browser-checkdrives the catalog and the inspector in headless Chromium: a
tutorial project with fields, the main paths and the field controls, the phone-width layout,
and no console error. A development extra (requirements-browser.txt,make browser-install), never a dependency of the package; CI runs it as its own job. The root
make browser-checkalso drives the public site, served with production's headers. -
tests/test_installed_commands.py(inmake test) runs everyevhcommand in a
project that pins the package; a command without a case fails it. -
tests/check_commands.py(inmake test) checks everymaketarget,evhcommand
and flag shown in the current docs still exists: 274 of them across 27 docs today. -
The deploy's health check also asks the inspector the question readers send most, a
group of arms on one metric (infra/deploy/probe_link.sh), and rolls back without verdict
rows. -
Code-quality gates,
make qualityat the repository root and a CI job of the same name:
ruff (lint and format), mypy, bandit, pip-audit, vulture, interrogate, codespell, shellcheck
and actionlint, with radon's complexity report. Tools pinned inrequirements-dev.txt
(make quality-install), never a dependency of the package. What they found, and how ruff
compares with the black, isort and flake8 that podcast_scraper runs:
research/internal/QUALITY_TOOLING_EXPERIMENT.md. Every public function, class and method
has a docstring (interrogate at 100%, the bar podcast_scraper sets). Logic kept in two or
more copies (Spearman, Holm, the sign-flip test, module loading, the test scripts'check(),
cost sums, file hashing) now lives in one place each, and every report claim re-derives
unchanged; a test script that runs no check now fails. -
This repository's worked examples run on patched libraries (Linux): transformers 5.20.2,
sentence-transformers 6.2.0, pillow 13.0.0, aiohttp 3.14.5, nltk 3.10.4, certifi 2026.10.19,
clearing 58 published advisories in their locks; few-nerd stays on transformers 4.55.4,
because span-marker has no transformers 5 release.make audit-locksaudits every lock per
platform. Every local arm was re-run against the committed record: every published finding
holds, and all but four arms reproduce item for item (why, per arm, in the record).
Not part of the package.
Install
pip install "evaluation-harness @ git+https://github.com/chipi/eval-harness@v0.4.0#subdirectory=harness"