AI evaluation harness
A model-backed feature has no failing test to point at: the output is different every time and often
plausibly wrong. So the question "did this change make it better?" is only answerable with a measurement
— and a measurement that cannot be reproduced or compared is a number somebody wrote down.
The rule: a number without a dataset version and a run identity is not a measurement.
1. The dataset and the report are versioned artifacts, not files
Both carry a schema version, so a consumer can reject what it does not understand instead of
misreading it.
- Absent version means the original version, for backward compatibility. An explicit unsupported one
fails at load, loudly.
- The value objects validate the version in their constructor, not only the loader. Consumers construct
them directly, and a check that lives in the parser protects nobody who did not go through the parser.
- Reject mixed sources at the boundary. If a dataset can come from a file or be built in code, taking
metadata from one and samples from the other produces a report about something that never existed.
Replacing a file-backed set with another file-backed set is fine; switching kind is not.
- Report identity: the dataset version, the system version, the model and its parameters, the prompt
revision, the timestamp. Two reports are comparable only if those match, and the harness is what should
say so rather than the person reading them.
2. What the run must isolate
- Validate every sample before the first invocation. Otherwise an invalid sample halfway through aborts
the run after side effects have already happened.
- Isolate the failure of one row and one metric. An exception scoring one sample marks that cell as an
error and the run continues; a harness that dies on row four hundred has measured nothing and cost
everything.
- Timeouts and retries are configuration, declared per run and recorded in the report — they change the
result, so they are part of it.
- The invocation payload is minimal and serialisable. Passing the whole sample to a queued runner drags
expected outputs and free-form metadata that may not serialise; pass an input-only object. And a
serialisation validator that walks structures needs a cycle guard — probe with an encoding attempt before
the recursive walk exhausts the stack.
- Compute the dispatch shape once, outside the loop. Reflection per sample is the easiest performance
mistake to introduce while supporting several callable shapes.
3. Metrics, and the shapes that make them lie
- Score per sample, aggregate once. Computing a mean, two percentiles and a pass rate with separate
passes sorts the same list four times; aggregate in one helper and reuse the sorted values.
- Round the boundaries of anything that becomes an artifact. Histogram bucket edges are part of the
report contract, and binary floating-point noise leaks into dashboards and makes every diff dirty.
- Decide where the top value lands. A perfect score must fall in the last bucket, and buckets with no
samples still appear — an absent bucket and an empty one look different to every consumer.
- Assertions over precomputed outputs are a first-class mode: scoring a stored set of answers without
re-invoking anything is how you iterate on the metric itself.
- Evaluate components as well as the whole. An end-to-end score tells you something regressed; a
component or step score tells you where.
4. Cohorts
Tag samples and report per cohort — by feature, language, difficulty, source. A single aggregate hides the
one segment that broke, and the segment that broke is usually the smallest one.
- A sample with several tags counts in every matching cohort.
- Missing is not a tag value. Modelling untagged samples as a literal tag collides with a real dataset
that uses that same string. Represent the untagged cohort with a null name and an explicit flag.
- Filters and splits are part of the dataset, and so is the ability to promote failing cases from a
run back into it. That loop — failure becomes a permanent case — is what makes the set improve.
5. What must never reach the report
- Never copy free-form sample metadata into the artifact. It carries provider payloads, prompts, keys
and personal data. Export a normalised, named subset until there is a redaction hook — see
padosoft-logging-discipline.
- Escape user-controlled text in rendered reports. Tag names, metric names and error messages end up in
table cells: pipes, backticks and newlines break the structure and make the output unparseable. Normalise
multi-line error text to a single line.
- A stored transcript is a data sink. If the harness keeps model inputs and outputs, that store inherits
every rule about personal data and provenance — see
padosoft-rag-ingestion-security.
6. Red-team coverage is a dataset, not a mood
Give adversarial cases the same treatment as functional ones: named categories, samples, expected refusals,
and a score that moves. The categories worth having from the start:
| Category |
What it probes |
| Prompt injection, direct and indirect |
instructions arriving inside the content |
| Jailbreaks |
refusal that holds under pressure and role-play |
| Data exfiltration |
secrets, personal data, other tenants' content in the answer |
| Excessive agency |
a tool called when the request did not warrant it |
| Server-side request forgery, command and query injection |
when the model's output reaches a fetch, a shell or a query |
| Competitor and off-topic endorsement |
brand-safety failures that read as helpfulness |
A refusal is a correct answer, and the metric has to say so — otherwise the harness rewards the model
that answers everything.
7. The gate
An evaluation nobody fails on is a report. Pick the thresholds that block — overall, and per cohort where a
segment matters more — and make the gate fail closed on a missing or stale run rather than passing
because the number was absent. A model or prompt change without a comparison run is an unmeasured change;
see padosoft-evidence-boundaries.
Gotchas
- A higher average with a worse worst case is usually a regression. Watch a tail percentile next to the
mean, and prefer the pass rate over either for gating.
- Evaluating on the set you tuned on is optimistic. Keep a held-out split, recorded by digest, and gate
on that one.
- A model-as-judge metric is itself a model-backed feature, with its own drift, its own cost and its
own need for evaluation. Pin its model and version it like any other component.
- Non-determinism is a parameter, not a nuisance. Record the sampling settings; a run at one
temperature is not comparable to a run at another.
- A dataset that never changes stops measuring the product and starts measuring the dataset.
- The harness is code, and it is the code nobody tests. A scoring bug moves every number at once and
looks exactly like a model change.
Checklist
Final report
Run: <id> · dataset <name>@<version> · system <version> · model <id>/<params>
Samples: <n> (cohorts: <list>) · held-out split: <digest>
Results: pass rate <…> · mean <…> · p90 <…> · worst cohort <…>
Errors isolated: rows <n> · metrics <n>
Red team: categories <n/n> · refusal-scored: yes | no
Compared against: <previous run id> — verdict: better | worse | not comparable (<why>)
Gate: <threshold> → pass | fail | no run (fails closed)
1---2name: padosoft-ai-evaluation3description: Use this skill when measuring whether a model-backed feature works — building or changing an evaluation harness, a golden dataset, a metric, a scoring report, a regression gate on prompt or model changes. Also when the user asks how to know a prompt change made things better, how to stop a model upgrade silently regressing, what to put in a test set, how to score free-form output, or wants red-team coverage. It covers datasets and reports as versioned artifacts, the isolation an evaluation run needs, cohorts, and what must never leak into a report. Do not use it to write application tests (padosoft-test-integrity), to choose a model, or to design prompts.4license: MIT5---67# AI evaluation harness89A model-backed feature has no failing test to point at: the output is different every time and often10plausibly wrong. So the question "did this change make it better?" is only answerable with a **measurement**11— and a measurement that cannot be reproduced or compared is a number somebody wrote down.1213**The rule: a number without a dataset version and a run identity is not a measurement.**1415---1617## 1. The dataset and the report are versioned artifacts, not files1819Both carry a **schema version**, so a consumer can reject what it does not understand instead of20misreading it.2122- **Absent version means the original version**, for backward compatibility. An explicit unsupported one23 **fails at load**, loudly.24- **The value objects validate the version in their constructor**, not only the loader. Consumers construct25 them directly, and a check that lives in the parser protects nobody who did not go through the parser.26- **Reject mixed sources at the boundary.** If a dataset can come from a file or be built in code, taking27 metadata from one and samples from the other produces a report about something that never existed.28 Replacing a file-backed set with another file-backed set is fine; switching kind is not.29- **Report identity**: the dataset version, the system version, the model and its parameters, the prompt30 revision, the timestamp. Two reports are comparable only if those match, and the harness is what should31 say so rather than the person reading them.3233## 2. What the run must isolate3435- **Validate every sample before the first invocation.** Otherwise an invalid sample halfway through aborts36 the run after side effects have already happened.37- **Isolate the failure of one row and one metric.** An exception scoring one sample marks that cell as an38 error and the run continues; a harness that dies on row four hundred has measured nothing and cost39 everything.40- **Timeouts and retries are configuration**, declared per run and recorded in the report — they change the41 result, so they are part of it.42- **The invocation payload is minimal and serialisable.** Passing the whole sample to a queued runner drags43 expected outputs and free-form metadata that may not serialise; pass an input-only object. And a44 serialisation validator that walks structures needs a cycle guard — probe with an encoding attempt before45 the recursive walk exhausts the stack.46- **Compute the dispatch shape once**, outside the loop. Reflection per sample is the easiest performance47 mistake to introduce while supporting several callable shapes.4849## 3. Metrics, and the shapes that make them lie5051- **Score per sample, aggregate once.** Computing a mean, two percentiles and a pass rate with separate52 passes sorts the same list four times; aggregate in one helper and reuse the sorted values.53- **Round the boundaries of anything that becomes an artifact.** Histogram bucket edges are part of the54 report contract, and binary floating-point noise leaks into dashboards and makes every diff dirty.55- **Decide where the top value lands.** A perfect score must fall in the last bucket, and buckets with no56 samples still appear — an absent bucket and an empty one look different to every consumer.57- **Assertions over precomputed outputs** are a first-class mode: scoring a stored set of answers without58 re-invoking anything is how you iterate on the metric itself.59- **Evaluate components as well as the whole.** An end-to-end score tells you something regressed; a60 component or step score tells you where.6162## 4. Cohorts6364Tag samples and report per cohort — by feature, language, difficulty, source. A single aggregate hides the65one segment that broke, and the segment that broke is usually the smallest one.6667- **A sample with several tags counts in every matching cohort.**68- **Missing is not a tag value.** Modelling untagged samples as a literal tag collides with a real dataset69 that uses that same string. Represent the untagged cohort with a null name and an explicit flag.70- **Filters and splits are part of the dataset**, and so is the ability to promote failing cases from a71 run back into it. That loop — failure becomes a permanent case — is what makes the set improve.7273## 5. What must never reach the report7475- **Never copy free-form sample metadata into the artifact.** It carries provider payloads, prompts, keys76 and personal data. Export a normalised, named subset until there is a redaction hook — see77 **`padosoft-logging-discipline`**.78- **Escape user-controlled text in rendered reports.** Tag names, metric names and error messages end up in79 table cells: pipes, backticks and newlines break the structure and make the output unparseable. Normalise80 multi-line error text to a single line.81- **A stored transcript is a data sink.** If the harness keeps model inputs and outputs, that store inherits82 every rule about personal data and provenance — see **`padosoft-rag-ingestion-security`**.8384## 6. Red-team coverage is a dataset, not a mood8586Give adversarial cases the same treatment as functional ones: named categories, samples, expected refusals,87and a score that moves. The categories worth having from the start:8889| Category | What it probes |90|---|---|91| Prompt injection, direct and indirect | instructions arriving inside the content |92| Jailbreaks | refusal that holds under pressure and role-play |93| Data exfiltration | secrets, personal data, other tenants' content in the answer |94| Excessive agency | a tool called when the request did not warrant it |95| Server-side request forgery, command and query injection | when the model's output reaches a fetch, a shell or a query |96| Competitor and off-topic endorsement | brand-safety failures that read as helpfulness |9798**A refusal is a correct answer**, and the metric has to say so — otherwise the harness rewards the model99that answers everything.100101## 7. The gate102103An evaluation nobody fails on is a report. Pick the thresholds that block — overall, and per cohort where a104segment matters more — and make the gate **fail closed** on a missing or stale run rather than passing105because the number was absent. A model or prompt change without a comparison run is an unmeasured change;106see **`padosoft-evidence-boundaries`**.107108---109110## Gotchas111112- **A higher average with a worse worst case is usually a regression.** Watch a tail percentile next to the113 mean, and prefer the pass rate over either for gating.114- **Evaluating on the set you tuned on is optimistic.** Keep a held-out split, recorded by digest, and gate115 on that one.116- **A model-as-judge metric is itself a model-backed feature**, with its own drift, its own cost and its117 own need for evaluation. Pin its model and version it like any other component.118- **Non-determinism is a parameter, not a nuisance.** Record the sampling settings; a run at one119 temperature is not comparable to a run at another.120- **A dataset that never changes stops measuring the product** and starts measuring the dataset.121- **The harness is code, and it is the code nobody tests.** A scoring bug moves every number at once and122 looks exactly like a model change.123124## Checklist125126- [ ] Dataset and report carry a schema version; unsupported fails at load; absent defaults to the original127- [ ] Version validated in the value objects, not only in the loader128- [ ] Mixed sample sources rejected at the boundary129- [ ] Report identity: dataset, system, model, parameters, prompt revision, timestamp130- [ ] Every sample validated before the first invocation131- [ ] Row-level and metric-level failures isolated; timeouts and retries recorded132- [ ] Invocation payload minimal and serialisable; cycle guard on the validator133- [ ] Aggregation done once per metric; artifact boundary values rounded134- [ ] Cohorts reported; multi-tag samples counted in each; untagged modelled explicitly135- [ ] Failing cases promotable back into the dataset136- [ ] No free-form metadata in the report; user-controlled text escaped137- [ ] Red-team categories present, with refusal scored as success138- [ ] A gate with thresholds that fails closed on a missing run139140## Final report141142```143Run: <id> · dataset <name>@<version> · system <version> · model <id>/<params>144Samples: <n> (cohorts: <list>) · held-out split: <digest>145Results: pass rate <…> · mean <…> · p90 <…> · worst cohort <…>146Errors isolated: rows <n> · metrics <n>147Red team: categories <n/n> · refusal-scored: yes | no148Compared against: <previous run id> — verdict: better | worse | not comparable (<why>)149Gate: <threshold> → pass | fail | no run (fails closed)150```