Add per-skill eval scaffolding: contract tests, trigger evals, output cases - #17
Conversation
… cases Sets up evaluation for the four skills in three layers, cheapest and most deterministic first. Test inputs live in skills/<skill>/evals/ next to the skill they test; the shared harness and playbook live in evals/. - Layer 1 (contract tests, no model): checks that the OWID endpoints and response shapes each SKILL.md documents still match what the API returns. Runs on every PR and nightly. This is the failure mode most likely to bite — these skills are thin documentation over live public endpoints, so they rot when the API changes, not when the prose gets worse. - Layer 2 (trigger evals): loads all four skills at once and records which one fired, so a sibling stealing another's traffic shows up as a misroute rather than a pass. - Layer 3 (output evals): case definitions only, no runner yet. Commit inputs, never outputs: test cases and fixtures are source, everything a run produces goes to the gitignored evals/results/. No SKILL.md references any eval file, so none of this reaches a user's context budget. Layer 1 currently reports 103 passing checks and 6 failures. All six are real documentation drift, listed in the PR description. The checks are deliberately left failing rather than rewritten to assert current behaviour, so the first run reports the drift instead of freezing it in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback on #17: the eval layers were only reachable by remembering two script paths and their flags. A Makefile at the root makes the entry points discoverable without moving where anything lives — per-skill definitions stay in skills/<skill>/evals/, the shared harness stays in evals/. Four targets, deliberately few: make list the targets make validate plugin manifest, skill frontmatter and registration make test contract tests (layer 1) make triggers trigger evals (layer 2) SKILL=<name> narrows test and triggers to one skill. Layer 3 gets no target on purpose: it has no runner yet, and a catch-all "run all evals" would quietly spend tokens on layer 2. make validate also covers the two manual checks AGENTS.md used to ask for: `claude plugin validate` passes even when a skill's frontmatter name does not match its directory and when the skill is missing from marketplace.json, so both are now asserted directly. It degrades to a warning when the claude CLI is absent, since the skills support other agents too. CI now calls `make test` rather than the script directly, so the documented command is the tested one. Recipes avoid .RECIPEPREFIX, which needs GNU make 3.82+ — macOS ships 3.81. Docs: the root README gains a short Development block pointing at AGENTS.md and evals/README.md; both of those now reference the make targets instead of raw script paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The search-charts failure list drifted between runs without any code changing,
which exposed a harness bug rather than a documentation one.
`q=energy+mix` returned mostly explorerView hits one hour and only multiDimView
hits the next. The explorerType assertion was written as
[.results[] | select(.type == "explorerView") | has("explorerType")] | all
and jq's `all` is true of an empty array, so with no explorerView hits in the
sample it passed vacuously — silently retiring one of the findings this PR exists
to demonstrate. Only the "at least one explorerView hit" guard failed, which read
as a flaky test rather than as a real drift going undetected.
Same class of bug in two more places:
- The tab-table check is wrapped in `[[ -s "$FILE" ]]`, which falls through with
no output at all if an earlier request failed. Renaming a variable made it stop
running entirely and nothing reported it.
- contract_check.py gates four indicator checks on the semantic search
succeeding. A transient 502 from search.owid.io dropped all four from the
summary rather than reporting them.
So: sample a query that returns all three record types, assert each type's
documented shape only when that type is present, and `skip` with a reason
otherwise. `skip` now propagates from contract_check.py too, and the indicator
search retries, since that service 502s often enough to make the nightly run
flaky instead of informative.
This surfaces two findings the fragile version was hiding: multiDimView omits the
documented chartConfigId, and the tab table is missing Dumbbell as well as
StackedDiscreteBar.
Contract tests: 103 passed, 7 failed — all seven genuine drift, and now
reproducible rather than dependent on what the search index served that hour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c50563ddb5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try: | ||
| results[i].append(done.result()) | ||
| except Exception as exc: # a crashed run is a failed run, not a crashed suite | ||
| results[i].append({"fired": [], "transcript": f"RUNNER ERROR: {exc}"}) |
There was a problem hiding this comment.
Treat failed Claude runs as evaluation failures
When a claude invocation exits nonzero, times out, or raises before emitting a Skill tool call, this handler records the run as fired: [] without preserving an error state. For a should_trigger: false case without expected_skill, classify() then marks that run correct, so authentication, configuration, or CLI failures can silently inflate negative-case accuracy instead of invalidating the evaluation.
Useful? React with 👍 / 👎.
| "misroutes": sum(r["outcome"] == "misroute" for r in positives), | ||
| "misses": sum(r["outcome"] == "miss" for r in positives), |
There was a problem hiding this comment.
Count failures from sibling-routing cases in the summary
Queries with should_trigger: false and expected_skill can be classified as miss or misroute, but these counters only examine positives, excluding every such sibling-routing case. As a result, the report can show reduced accuracy and rows labeled as failures while still claiming zero misses and zero misroutes, obscuring the main failure mode this runner is intended to diagnose.
Useful? React with 👍 / 👎.
| if os.environ.get("SKIP_SLOW"): | ||
| emit("NOTE", "skipped semantic indicator search (SKIP_SLOW=1)") |
There was a problem hiding this comment.
Record skipped indicator checks as skips
With the documented SKIP_SLOW=1 make test invocation, this branch emits only a note, so the semantic-search check and its four dependent checks disappear from the totals and the suite can report zero skipped checks. This makes a deliberately reduced contract-test run look like a complete pass; emit explicit SKIP records for the omitted checks as the failure branch below already does.
Useful? React with 👍 / 👎.
Replaces a hand-rolled frontmatter check with `skills-ref`, the reference validator the Agent Skills spec recommends, and runs it in CI. Three checks now, none of which subsumes another: - spec conformance, per skill, via `skills-ref`. Agent-agnostic, which matters because these skills are also read by Codex, Gemini CLI and Cursor, whereas `claude plugin validate` only inspects the marketplace manifest. - the marketplace manifest, via `claude plugin validate`. - marketplace registration, which neither of the above knows about. An unregistered skill is installable by neither route. The hand-rolled check only compared the frontmatter `name` to the directory name. skills-ref covers that plus the rest of the spec: it catches a missing `description`, invalid characters, and the length and hyphen rules, none of which were checked before. Two gotchas worth recording. The published package's executable is `agentskills`, not `skills-ref` as the spec page shows, so `uvx --from skills-ref agentskills` is the working invocation. And it is pinned to 0.1.x: skills-ref is pre-1.0, where a minor bump may change behaviour, but floating within 0.1.x still picks up spec fixes, which is the point of using it. CI gains a `validate` job — fast, and green regardless of what the OWID API is doing, so it separates "someone broke the repo" from "the API moved". The workflow is renamed Checks accordingly, since it no longer runs only the contract tests. The claude CLI is absent on the runner, so that one check skips there with a message rather than failing. Verified against three breakage classes: frontmatter name mismatch, a removed required field, and an unregistered skill. All three fail the target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A skill directory is the unit of distribution, and the cross-agent installer the
README recommends copies it recursively with no ignore mechanism: vercel-labs/skills
excludes only metadata.json, .git, __pycache__ and __pypackages__. So everything
under skills/<skill>/ landed in users' projects via
npx skills add owid/skills # into the current project
That was 15 eval files, including fixture CSVs that are realistic by design. Once
they sit in someone's .agents/skills/, a project-wide `find . -name '*.csv'` or a
grep returns them next to that person's real data. Simulated before and after:
before: find . -name '*.csv' -> 3 hits, 2 of them our fixtures
after : -> 1 hit, the user's own file
before: grep -ril "per capita" -> 7 hits, 5 of them eval files
after : -> 2 hits, both genuine SKILL.md prose
installed footprint -> 108K to 32K
Skill activation was never the risk — progressive disclosure means only name and
description load at startup, and nothing referenced the eval files. The risk was
an agent's own filesystem exploration of a project that happened to have these
skills installed.
So skills/<skill>/ is now exactly SKILL.md, and test inputs live in a sibling
evals/<skill>/. This removes the possibility rather than mitigating it: the
installer never sees them. Directory names still mirror the skills, so the
locality cost is one hop.
Also makes the no-reference rule a check instead of a convention. A SKILL.md that
mentions an eval file is the only remaining way this content could reach an
agent's context, so `make validate` now fails on it — verified by adding such a
reference and watching it fail.
Contract tests unchanged at 103 passed, 7 failed. Verified that a contract.sh
still works when run directly, without the runner's environment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
evals/ mixed harness files with per-skill directories, so there was no way to tell
at a glance which entries corresponded to a skill. Per-skill inputs now live in
evals/skills/<skill-name>/, mirroring the top-level skills/ directory:
evals/README.md, lib/, run-*.sh, results/ the harness
evals/skills/<skill-name>/ that skill's inputs
This also makes the runners' globbing structurally correct rather than
incidentally correct. `evals/*/contract.sh` only skipped lib/ and results/
because neither happens to contain a contract.sh; `evals/skills/*/contract.sh`
cannot match the harness at all.
Contract tests unchanged at 103 passed, 7 failed. Verified that a contract.sh
still resolves its own paths when run directly, without the runner's environment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The contract tests were hard to read, and the cause was not code quality —
shellcheck is clean — but a vocabulary too small for what they assert. assert.sh
offered eight assertions, only one CSV-aware and none collection-aware, so every
check the vocabulary could not express fell back to raw shell or raw jq:
11 x bash -c with nested quote escaping
13 x hand-rolled [ ... ] | all / any | not
12 x inline jq filters over 60 characters
All three are now zero, zero, and four. What replaced them:
- all_match / none_match take the selector and the predicate as separate
arguments rather than one fused filter. Quoting stays sane, and the helper can
insist the selector matched something before judging the predicate. That makes
the vacuous-pass bug fixed earlier impossible by construction rather than by
discipline: `[] | all` is true in jq, so a fused filter silently passes when
the sample holds none of the thing being described. They skip loudly instead.
- CSV columns are addressed by name, resolved case-insensitively, with a
quote-aware splitter. Compare:
before: ok "country filter is respected" \
bash -c "[ \"\$(tail -n +2 '$CSV' | cut -d, -f2 | sort -u | tr '\n' ' ')\" = 'GBR USA ' ]"
after: csv_column_set "the country filter is respected" "$CSV" code "GBR USA"
Naming rather than indexing also fixes two latent bugs. Case-insensitive
matching means one line works for both documented header forms, where
useColumnShortNames=true lowercases the first three. And the quote-aware
splitter gets `"Bonaire, Sint Eustatius and Saba"` right, where cut -d, silently
returns the wrong field.
- The 150-character capture() filters in joining-data are now one named, commented
jq filter; jq allows comments, so it explains itself.
- The 15-line sed/comm tab-table block became skill_md_table_covers, since
"does a markdown table in SKILL.md cover every value the API returns" is
generic doc-drift checking.
owid-catalog is left alone: it delegates to Python and already read cleanly.
Adds `make lint` — shellcheck for the shell, ruff for the Python, both via uvx so
nothing new to install. Both are clean. shellcheck needed --external-sources to
follow the dynamic source of assert.sh; ruff wanted Callable from collections.abc
and flagged one stale noqa.
The seven contract-test findings are unchanged. Passing checks go 103 -> 104: the
CSV rewrite added one precondition that the entity, code and year columns resolve
at all, so a header change fails once and clearly instead of cascading.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ccesses Three issues raised by the Codex reviewer on #17. All three were real; each is reproducible and each is now covered. 1. A failed `claude` invocation was indistinguishable from "no skill fired". run_once returned fired: [] whether the model declined to route or the CLI never ran, so classify() scored every `should_trigger: false` case without an `expected_skill` as correct. Simulated with a `claude` stub that exits 1: before: accuracy 20%, two negative cases reported as passing after : every query reports "! error (claude exited 1: not authenticated)", a warning that the numbers are invalid, and exit status 1 A run may now only vote if it observed a routing decision or exited cleanly. Failed runs are reported with their cause, the trigger rate is computed over usable runs only, and a query with no usable run is an `error` outcome. 2. The summary counted misses and misroutes over `should_trigger: true` rows only, so a sibling-routing case that missed or misrouted lowered accuracy while the breakdown still read "0 miss, 0 misroute" — hiding precisely the failure mode this runner exists to catch. The same stub run showed 8 failing rows summarised as 5 misses. Outcomes are now counted across all rows; recall stays scoped to the positives, where it is defined. 3. `SKIP_SLOW=1` emitted a bare note, so the indicator gate and its four dependent checks vanished from the totals and a deliberately reduced run reported zero skipped checks. This broke the invariant this repo documents — a check that did not run must never look like a check that passed — in the one place it was easiest to miss. The gate and its dependents are now named once and reported by all three paths, so the arithmetic balances: SKIP_SLOW=1 -> 20 passed, 0 failed, 5 skipped full run -> 24 passed, 1 failed, 0 skipped (25 checks either way) Contract tests unchanged at 104 passed, 7 failed. Lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-tunable Two problems surfaced by the first real `make triggers` run: it exited 1 on a perfectly informative result, and it cost about 40% of a five-hour session window. Exit status was `accuracy < 1.0`, which makes the command permanently red — 100% routing accuracy is not a realistic bar, and a command that always fails is a command people stop reading. Trigger accuracy is a measurement, not a gate, so the runner now exits non-zero only when runs actually failed (the numbers are untrustworthy) or when you opt into a floor with --min-accuracy. Contract tests remain the gate; this is instrumentation. Cost is queries x runs x skills full `claude -p` sessions — 120 for the default invocation, each carrying a whole system prompt, and a query that fires nothing lets the model answer it in full before the process exits. Knobs added, largest lever first: - --effort, defaulting to low. Routing is decided before the model does any work, so thinking tokens are pure waste here. Raise it for a fidelity run. - RUNS= and MODEL= and EFFORT= pass through from make, so the cheap iteration loop is `make triggers SKILL=owid-catalog RUNS=1` rather than a hand-written command. - --max-budget-usd, a hard per-run cap (honoured with --print, which -p gives us). A cut-off run reports as an error, not as a negative, so it cannot quietly become a passing result. The README now states the cost arithmetic and the caveat that a cheaper MODEL measures that model's routing, not the routing your users get — fine for iterating on wording, not for a recorded result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sets up evaluation for the four skills. The structure is the thing to review; I kept it uncommitted to any particular runner or grading service, so a layer can be dropped or a tool swapped without unpicking the rest.
Approach
Three layers, cheapest and most deterministic first. They answer different questions, and only the third needs a model grading output.
SKILL.mdstill match reality?evals/skills/<skill>/contract.shevals/skills/<skill>/triggers.jsonevals/skills/<skill>/evals.jsonLayer 1 comes first because it targets how these skills will actually break: they are thin documentation over live public HTTP, so they rot when the API changes, not when the prose gets worse. Layer 2 is next because all four skills describe overlapping subject matter — misrouting between them is a likelier defect than a bad answer.
The layer 3 file format matches the
evals.jsonconventionanthropics/skills'skill-creatorexpects, so its tooling can drive our cases later without a migration. Placement deliberately differs fromgetsentry/skillsandopenai/skills, which put evals inside the skill directory — see the distribution note below.Layout
Everything directly under
evals/is harness; everything underevals/skills/is per-skill input, named exactly as the skill it tests. The runners globevals/skills/*/, so they cannot mistakelib/orresults/for a skill.Two rules, both now enforced by
make validaterather than trusted:evals/results/.SKILL.mdreferences any eval file. Otherwise every user who triggers a skill pays context for test prose.Evals are outside
skills/on purposeWorth reviewing this bit specifically, because it reverses my first instinct.
A skill directory is the unit of distribution and is copied recursively into users' projects. The
skillsCLI the README recommends for Codex/Gemini/Cursor excludes onlymetadata.json,.git,__pycache__and__pypackages__— no ignore file, no opt-out. So anything underskills/<skill>/ships to everyone who runsnpx skills add owid/skills.Skill activation was never the risk: progressive disclosure loads only
name+descriptionat startup, and nothing references the eval files. The risk is an agent's own filesystem exploration of a project that has these skills installed. Fixture CSVs are realistic by design, so once they land in.agents/skills/they are indistinguishable from the user's own data. Simulated both layouts:Keeping evals in a sibling directory removes the possibility rather than mitigating it — the installer never sees them. Directory names still mirror the skills, so the locality cost is one hop.
Running it
Five targets. Layer 3 gets none: it has no runner yet, and a catch-all "run all evals" would quietly spend tokens on layer 2.
make validateruns four checks, none of which subsumes another:skills-ref(the validator the spec recommends)claude plugin validatemarketplace.jsonexistsSKILL.mdreferences evalsVerified against four breakage classes: frontmatter name mismatch, a deleted required field, an unregistered skill, and a SKILL.md that references an eval file. Note
skills-ref's published executable isagentskills, notskills-refas the spec page shows; it is pinned>=0.1.1,<0.2since it is pre-1.0.CI has two jobs.
validateis fast and should always be green;contract-testsruns on PRs and nightly at 06:00 UTC. That separates "someone broke the repo" from "the API moved".Layer 2 loads all four skills together via
--plugin-dirand records which skill fired, giving four outcomes:correct,miss,misroute,false_positive.triggers.jsonkeeps the{query, should_trigger}shapeskill-creatoruses, plus an optionalexpected_skillfor "a sibling should win this one":{ "query": "grab the csv behind https://ourworldindata.org/grapher/life-expectancy for the USA", "should_trigger": false, "expected_skill": "fetch-chart-data", "note": "url already known — discovery is not needed" }What the contract tests found
104 checks pass, 7 fail. All seven are real documentation drift, not broken tests. They are left failing on purpose so the first run reports the drift instead of freezing it in. #18 fixes all seven and takes the suite to 120/0.
search-chartsobjectIDdocumented as required on every hit — returned on 0 of 200 sampled.search-chartsexplorerTypedocumented as required onSearchExplorerViewHit— never returned.search-chartschartConfigIddocumented as required onSearchMultiDimViewHit— never returned.search-chartsStackedDiscreteBarandDumbbell, both of which the API returns. An agent can't build a?tab=URL for either.fetch-chart-datadescriptionKeytypedstring[]but is a single markdown bulleted string.fetch-chart-dataEntity,Code,Year, but the recommendeduseColumnShortNames=truereturns lowercaseentity,code,year. The recommendation contradicts the documented structure.owid-catalogsearch(..., sort_by="relevance")raisesTypeError— no such parameter in owid-catalog 1.1.0. The documented example is broken code.Two more are encoded as passing checks with a
note, because they're traps rather than contradictions:search-chartssays "If you don't get any results, try slightly different terms" — butnbHits == 0essentially never happens, so that guidance never fires and an agent can present unrelated charts as matches.csvType=filteredalso applies the chart's own default entity selection.population.csv?csvType=filtered&time=2020returns 7 rows — continents and World, no countries.joining-datareasonably assumes country rows are present.And one in
joining-data's prose a contract test can't assert: it tells agents to read$.columns.[0].timespan, butcolumnsis an object keyed by column name, so that path is not valid jq. Forpopulation-with-un-projectionsit matters — the first column is estimates (1950–2023) and the projection to 2100 is a second column, so reading one column's timespan gives the wrong answer for exactly the recent/future-year case the skill recommends that chart for.A harness bug worth knowing about
The failure list initially drifted between runs with no code changing, which turned out to be a bug in the tests rather than flakiness in the API.
q=energy+mixreturned mostlyexplorerViewhits one hour and onlymultiDimViewhits the next. TheexplorerTypeassertion was[... | select(.type == "explorerView") | has("explorerType")] | all, and jq'sallistruefor an empty array — so with no explorerView hits in the sample it passed vacuously, silently retiring one of the findings this PR exists to demonstrate.Same class of bug twice more: the tab-table check sits behind a
[[ -s "$FILE" ]]guard that falls through with no output at all (renaming a variable made it stop running entirely, and nothing reported that); andcontract_check.pygated four indicator checks on the semantic search succeeding, so a transient 502 fromsearch.owid.iodropped all four from the summary.Fixed by sampling a query that returns all three record types, asserting each type's shape only when that type is present, and
skip-ing loudly with a reason otherwise. Fixing it surfaced two findings the fragile version was hiding: thechartConfigIddrift, andDumbbellmissing from the tab table alongsideStackedDiscreteBar.The invariant is written down in
evals/README.md: a check that did not run must never look like a check that passed. It applies to layer 2 as well — assert shapes, not populations.Verification
make lintis clean: shellcheck (with--external-sources) and ruff, both viauvx.contract.shstill works run directly, without the runner's environment.claude -ptranscript — 2 hits in 80 lines, first wassearch-charts(the expected skill), no spurious matches from the available-skills listing. Fixed two bugs found doing this:claude -pblocking on stdin, and a fallback regex loose enough to match the skills listing itself.Not done
evals/skills/joining-data/triggers.jsonis flagged in itsnoteas genuinely ambiguous and wants a human call.SKILL.mdcontent was changed here — that's Fix the seven documentation drifts the contract tests found #18.claude -pinvocations), so it is deliberately not wired into CI.Open questions
jsonschemarewrite would let the TypeScript types in eachSKILL.mdbe checked structurally instead of via hand-transcribed jq assertions, and would express "expected to fail, tell me if it stops failing" asxfail(strict=True)— which is exactly what the seven intentional failures want, and would let CI be green. Worth doing now, or after the suite outgrows four skills?🤖 Generated with Claude Code