Skip to content

Add portable eval-package export and import for evaluators and datasets - #12

Merged
duncankmckinnon merged 40 commits into
mainfrom
portable-eval-packages
Aug 8, 2026
Merged

Add portable eval-package export and import for evaluators and datasets#12
duncankmckinnon merged 40 commits into
mainfrom
portable-eval-packages

Conversation

@duncankmckinnon

Copy link
Copy Markdown
Owner

Summary

valcore could previously export an evaluator only as a standalone Python
script and could not export datasets at all. This PR gives both evaluators and
datasets a JSON config form and a Python code form, driven from a single
canonical translation layer, and adds import for the JSON form. The existing
script export stays byte-identical and no new dependencies are added.

Changes

  • Add capabilities.py, a shared capability registry and scalar-type map;
    factory.py now reads it instead of holding its own copy.
  • Add spec.py, the canonical translation between EvaluatorVersion and
    pydantic-ai AgentSpec (including output_fields <-> output_schema).
  • Add config_io.py to assemble, serialize, detect, and round-trip
    eval-package JSON (bundled and split forms).
  • Extend export.py with JSON export, dataset/judge/tool source renderers, and
    split-package output, alongside the retained byte-identical script renderer.
  • Extend cli/main.py with JSON and dataset-code export, an import command,
    and a no-clobber guard on named output.
  • Add export endpoints in api/routes/evaluators.py and
    api/routes/datasets.py, plus eval-package upload; models.py gains
    supporting fields.
  • Web: add exportFiles client methods and types, a two-level format picker in
    ExportModal, eval-package detection/preview in DatasetUpload, a Dataset
    header Export action, and the accompanying CSS style-contract entries.
  • Document portable eval packages in README.md.

Test plan

  • Confirm valcore export <evaluator> with no new flags emits the same
    Python script byte-for-byte as before.
  • Export an evaluator and a dataset as JSON (bundled default) and with
    --split, verifying two files are produced in the split case.
  • Import a JSON eval package and confirm the evaluator version is
    reconstructed; confirm importing a .py file raises ContractError and
    that a non-ValcoreJudge evaluator entry also raises ContractError.
  • Run the Python suite: uv run pytest -q, and lint with
    uv run ruff check . and uv run ruff format ..
  • Run the web tests: cd web && npm ci && npx vitest run, covering the
    export modal, dataset upload preview, and client methods.
  • Type-check the web build: cd web && npx tsc --noEmit.
  • In the UI, verify the export picker uses radios plus a Select, and that
    switching JSON -> Code restores the script block.

🤖 Generated by workbench pr_writer

duncankmckinnon and others added 30 commits August 7, 2026 17:35
Add the five export-modal class names the new export UI needs
(export-format, export-layout, export-file, export-file-name,
export-file-actions) to CONTRACT_CLASSES in styles.test.ts, under a
new "Export modal." group. These are static literals, so they belong
in CONTRACT_CLASSES, not INTERPOLATED_CLASSES.

The contract-table assertion now fails because styles.css does not yet
define these classes; the CSS lands in a later wave. The orphan, token,
and favicon assertions continue to pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover the new export endpoints per the authoritative contract, ahead of
implementation:
- evaluators.exportFiles: code -> export.py (no split param); json ->
  export.json with split=false (bundled) / split=true (split)
- datasets.exportFiles: code -> export.py; json -> export.json with
  version_id included when supplied and omitted entirely when absent
- both unwrap to the inner `files` map and surface non-OK as rejections

These fail today because the methods do not exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Define the five export-modal classes the styles contract already promises:
.export-format, .export-layout, .export-file, .export-file-name, and
.export-file-actions, reusing existing tokens. The components that consume
them arrive in a later wave; the contract test passes now that these are
defined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ExportFilesResponse, ExportFormat, and ExportLayout types and
exportFiles methods to the evaluators and datasets clients, unwrapping
the {files} envelope. JSON exports thread split (and version_id for
datasets); the code form sends no query params.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add edge cases asserting the "code" form ignores layout/versionId even
when supplied, so neither `split` nor `version_id` can leak into the URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover the capability registry refactor before implementation exists:
- CapabilityEntry is a frozen dataclass of (module, class_name)
- CAPABILITY_REGISTRY holds exactly the five harness capabilities, each
  entry's class_name equals its key, and every entry imports successfully
- CodeMode resolves to the deep runtime module path (the reconciliation)
- VALID_CAPABILITIES equals the registry key set; models re-exports it
- CapabilitySpec validation is driven by the registry
- consolidation guard: a registry addition is visible to export rendering
  and factory with no other edit, keeping the triplication from returning
- SCALAR_TYPES[ft].__name__ yields str/int/float/bool and excludes enum

These fail today (valcore.capabilities and models.SCALAR_TYPES do not exist).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse three parallel capability lists (models.VALID_CAPABILITIES,
factory._CAPABILITY_MODULES, export._CAPABILITY_IMPORTS) and two FieldType
maps (factory._SCALAR_TYPES, export._BASE_TYPES) into one home each, with no
behavior change.

- New src/valcore/capabilities.py holds CAPABILITY_REGISTRY (the five harness
  capabilities) and VALID_CAPABILITIES, importing nothing from valcore so
  models can depend on it without a cycle. CodeMode is reconciled to the deep
  runtime module path (pydantic_ai_harness.code_mode) that factory imports.
- CapabilityEntry carries both the canonical runtime module and an optional
  render_module. A single module cannot be both the deep canonical path (which
  test_capabilities pins and factory imports at run time) and the shorter
  re-export path that render_script has always emitted; render_module keeps
  render_script byte-identical while the registry stays the single source.
- models.py re-exports VALID_CAPABILITIES and gains SCALAR_TYPES beside
  FieldType; factory reads both; export derives type names from
  SCALAR_TYPES[ft].__name__ and reads the registry for capability imports.

Existing test_export/test_factory/test_models pass unmodified; render_script
output verified byte-identical against HEAD for all five capabilities.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… import order

Add a regression test asserting all five capabilities render their original
import line (test_export only pinned CodeMode), guarding the render_module
fallback that keeps render_script byte-identical. Also apply ruff's import
ordering fix to the delivered test file so `ruff check .` passes on a cold cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parse.test.ts pins the new ParsedFile.kind discriminator and package
disambiguation: CSV/JSONL keep their current parsing (kind "csv"/"jsonl"),
a pydantic_evals dataset and a bundled valcore package both parse to
kind "package" with columns/rows derived from cases[].inputs, and a
malformed or unrecognized .json body falls through to the JSONL parser
without throwing.

DatasetUpload.test.tsx pins the UI: a detected package shows a detection
line, previews its rows, hides the label-column control, and posts without
a label_column field; a CSV keeps its preview table and label-column
control. Queries by role, label, and text only.

Tests fail until parse.ts/DatasetUpload.tsx implement the feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite ExportModal tests for the widened subject prop and Code/JSON
format picker: evaluator Code fetches the script, JSON fetches the file
package (bundled/split), dataset Code goes through datasets.exportFiles,
JSON bodies pretty-print with a raw fallback, per-file Copy/Download, the
error banner, and the stale-response guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Disambiguate .json uploads into eval-package / pydantic_evals dataset
vs JSONL by shape, add kind to ParsedFile, and hide the label-column
control (and omit label_column) for detected packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover the reverse output_schema map for every FieldType, field-order
round trips, unsupported-construct rejection, capability translation
both ways, row/case mapping, scalar-input wrapping, column inference
with the 50-case limit, label-schema resolution across all branches,
and version_to_spec reloadability. Tests fail because src/valcore/spec.py
does not exist yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Widen ExportModal to a discriminated subject prop serving both evaluators
and datasets. Format radios (Code/JSON, Code default) with a JSON-only
Bundled/Split layout Select. Evaluator Code keeps the legacy single-block
script with footer Copy; every other combination renders per-file blocks
with Copy and Download. JSON bodies are pretty-printed with a raw fallback,
fetches refetch on format/layout change, and the cancelled-flag guard
discards stale responses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…block

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add src/valcore/spec.py, the single place either foreign model
(pydantic_ai AgentSpec, pydantic_evals Dataset) is constructed or read.
Covers the lossless output_schema reverse map, capability translation
both ways, valcore-block metadata, and row<->case mapping with column
inference and label-schema resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend DatasetDetail's suite for the not-yet-built header Export button:
the action renders alongside Edit/Delete, clicking it opens the real
ExportModal (fetching this dataset's Code export for d1 in the default
bundled layout with no version id), and closing unmounts the modal the
way EvaluatorDetail gates its own. Fails until the button is wired up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an Export action to the dataset detail header, gated on local
`exporting` state the way EvaluatorDetail gates its own modal. It opens
ExportModal with a dataset subject (no version id — the page exports the
dataset alone), defaulting to the pydantic_evals.Dataset code module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add failing tests for render_tool_sources, render_dataset_module, and
render_judge_module in export.py:

- render_tool_sources returns the import + verbatim source blocks for named
  tools, and empty blocks for an empty list.
- render_dataset_module builds a pydantic_evals.Dataset (no valcore import),
  omits expected_output for unlabelled rows, and repr()s values for round-trip.
- render_judge_module emits a ValcoreJudge whose evaluate reads the package
  with stdlib json, builds the agent via Agent.from_spec with an explicit
  output_type (guarded: output is a real model, not a StructuredDict), scores
  to the categorical label under TestModel, imports neither valcore nor yaml,
  and omits/includes tools= and custom_capability_types= per the version.
- Pin render_script output determinism as a regression guard; existing
  render_script tests remain unmodified.

Tests fail at import (functions not implemented yet) by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add render_tool_sources, render_dataset_module, and render_judge_module
to export.py, reusing the existing _Imports/_render_tools and
_render_output_model helpers so the code artifacts never drift.
render_script output is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…trip

Add tests/test_config_io.py pinning the eval-package JSON contract before
config_io exists: bundled/split serialization, the four-way from_text format
detection, native loadability of split halves, bundled-vs-bare-Dataset guard,
round-trip reconstruction of version and dataset fields, and every documented
rejection including ExceptionGroup unwrapping to a flat ContractError.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add src/valcore/config_io.py with EvalPackage and ValcoreMeta. EvalPackage
carries the optional agent (AgentSpec), dataset (pydantic_evals.Dataset), and
valcore halves, and:

- from_version / from_dataset build each half, routing every foreign-model
  mapping through spec.py
- to_text emits a uniform name-to-content mapping, bundled (one file) or split
  (two), injecting the ValcoreJudge evaluator reference pointing at whichever
  file the agent lives in, or [] when there is no agent
- from_text resolves the four formats (bundled envelope, bare dataset, bare
  agent + optional valcore block, else ContractError) and rejects unknown
  kind/version, non-object roots, unparseable JSON, and foreign evaluator names
- merge recombines a split pair, rejecting two of the same half
- to_version_fields / to_dataset_fields decode back to valcore field values

JSON only; foreign models are dumped with model_dump(mode="json",
by_alias=True) so output is indistinguishable from library-produced files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ValcoreJudge stub was built via type(), which skips the @DataClass
decoration pydantic-evals requires for custom evaluators, so the
with-evaluator load test errored. Decorate it directly and add coverage
for merge conflicts on the dataset/valcore halves, a bare-string foreign
evaluator, and the missing-section guards on to_version_fields /
to_dataset_fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
duncankmckinnon and others added 10 commits August 7, 2026 20:57
Add failing TDD tests for the CLI 'formats' work: --format code|json,
--dataset, and --split on export, plus a new import command. Guards the
locked byte-identical code export and asserts a full export->import round
trip, invalid-agent rejection, and .py-path refusal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mplementation

Extend the evaluator and dataset API suites with failing tests for the four new
export endpoints and package-aware upload:

- evaluators: GET .../export.json (bundled + split) yields a files map with the
  agent JSON and valcore_judge.py; GET .../export.py yields the standalone
  script; the legacy .../export keeps its {"source": ...} shape (regression).
- datasets: GET .../export.json is dataset-only without version_id and gains the
  agent section plus companion module with it; split yields two JSON files;
  GET .../export.py yields the dataset module.
- upload: an exported .json package round-trips into a matching dataset; a JSONL
  body with a .json name still parses as JSONL; a label_column form field is
  rejected; an explicit label_schema wins over the package's; a malformed .json
  body is a client error, not a 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend `valcore export` with `--format json`, `--dataset`, and `--split`,
and add a new `valcore import` command. Code export for a lone evaluator is
unchanged and byte-identical to render_script. JSON export assembles an
EvalPackage (merging evaluator and dataset when both are named), writes the
companion valcore_judge.py beside a config file, and notes its omission on
stderr when writing to stdout. Import validates the reconstructed version
before any persistence so a bad agent creates nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… export

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add code and config export siblings beside the existing evaluator export,
add dataset export in both forms, and teach the upload endpoint to
disambiguate an eval-package .json body from a JSONL stream.

Evaluators gain GET .../export.py (standalone script) and .../export.json
(agent JSON + valcore_judge.py, bundled or --split). The legacy
.../export keeps its {"source": ...} shape untouched. Datasets gain
GET /{id}/export.py (pydantic_evals.Dataset module) and /{id}/export.json,
which merges in a version when version_id is given and only then rides a
companion module.

Upload resolves a .json body by whole-body json.loads: a recognized
EvalPackage imports its dataset half (rejecting label_column, honoring an
explicit label_schema over the package's); anything else falls through to
the JSONL parser unchanged. Route modules go through config_io and export
and never touch pydantic_evals directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…yte-identity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add full type hints to _export_code / _export_json (Dataset, DatasetRow,
  Evaluator, EvaluatorVersion), matching every other signature in main.py.
- Thread the Evaluator through to _export_json so a stdout stem slugifies the
  evaluator name, per spec, rather than the version name.
- Drop the redundant update_evaluator call in import_; create_version already
  sets the new version as active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a section covering the evaluator/dataset code-vs-JSON matrix, the
eval-package format, the export/import commands, how a consumer runs a
package via custom_evaluator_types=[ValcoreJudge], and the three honest
limitations of the bare foreign formats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@duncankmckinnon
duncankmckinnon merged commit 5e45a84 into main Aug 8, 2026
6 checks passed
@duncankmckinnon
duncankmckinnon deleted the portable-eval-packages branch August 8, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant