diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebf11d70..687dbeaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,9 @@ jobs: - name: MCP tool reference drift gate run: uv run python scripts/export_mcp_tools.py --check + - name: Export target catalog drift gate + run: uv run python scripts/export_target_catalog.py --check + # `-n auto` resolves to the runner's core count. The suite has no expensive # test to remove — ~63 ms mean, no fat tail — so parallelism is the only # thing that makes this step faster, and it is ~90% of the run's critical diff --git a/CHANGELOG.md b/CHANGELOG.md index e9819a25..9a5bcb01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ nothing was being distributed. This is the first version that is. ### Added +- **Export targets: a release is exported for the model it will train.** Part of the + export-targets epic (#784). Every installed format declares the targets it writes for, and the + catalog is served on every surface: `GET /export-targets`, `visionset target list` and the + `list_export_targets` tool, with `FormatOut.targets` naming each format's own. `POST + /releases/{id}/export` and `GET /releases/{id}/export-compatibility` take `target` beside + `format`, exactly one of the two; `visionset export --target` and the `target` parameter of + `export_release` and `check_export` do the same. A target narrows its format to the geometries + its trainer has a task for - the drop is reported, consented through `allow_lossy`, and honoured + in the output - and the compatibility report, the export result and the job payload all record + `target` beside `format`. The target table in `docs/content/releases.md` is generated from the + catalog by `scripts/export_target_catalog.py`, behind a drift gate. + - **A schema version records which kind of work published it** (#368). New nullable `provenance` on `AnnotationSchema`: `curated` for a version somebody sat down and designed, `annotation` for one that fell out of adding a class part-way through labeling. It gates diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4b22d51..91370eb2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -243,6 +243,7 @@ a deliberate manual run, because each costs minutes or needs its own install. | Generated API client | `pnpm generate:client` (commit the diff) — writes **two** artifacts under `frontend/ui-core/src/generated/`: `api.ts` (the types) and `checks.ts` (the runtime response checks `unwrap` takes). CI diffs the whole directory. | `generated` | | Wire fixtures (annotator payloads, capability rosters) | `uv run python scripts/export_wire_fixtures.py` (commit the diff) — writes `tests/fixtures/wire_annotations.json` and `tests/fixtures/wire_capabilities.json`, the kernel's answers as bytes for the `frontend` job that installs no Python | part of `python` | | MCP tool reference | `uv run python scripts/export_mcp_tools.py` (commit the diff) — `docs/content/mcp-tools.md` is generated from the server's own tool listing, because a tool description *is* the interface an agent reads | `generated` | +| Export target catalog | `uv run python scripts/export_target_catalog.py` (commit the diff) — the target table in `docs/content/releases.md` is generated from what the installed formats declare, the same catalog `GET /export-targets` serves | `generated` | **`scripts/check.sh` runs pytest under `pytest-xdist` with `-n auto`.** The suite is roughly 3200 tests averaging 63 ms, with only eight over a second — there is no expensive diff --git a/docs/content/api.md b/docs/content/api.md index e56bc0de..ed1fa29f 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -105,9 +105,10 @@ GET /releases/{release_id} GET /releases/{release_id}/manifest bytes GET /releases/{release_id}/verify GET /releases/{release_id}/assignment -GET /releases/{release_id}/export-compatibility ?format= -POST /releases/{release_id}/export ?format=&allow_lossy=, launch +GET /releases/{release_id}/export-compatibility ?target=|format=, exactly one +POST /releases/{release_id}/export ?target=|format=&allow_lossy=, launch GET /formats +GET /export-targets the models a release can be exported for GET /inference/connections POST /inference/connections @@ -834,9 +835,10 @@ each is a decision somebody will otherwise try to "fix": - **Unknown keys pass.** `additionalProperties: false` constrains what the API *accepts*, not what it may one day *send*. A client that refused an added field would turn every backward-compatible release into a broken page. -- **An unknown member of an *open* vocabulary passes.** Seven vocabularies carry +- **An unknown member of an *open* vocabulary passes.** Eight vocabularies carry `x-visionset-open` in the spec — the four `allowed_actions` sets, `capabilities`, - `SuggestionOut.parameters`, and the reasons a class is left out of a pre-label prompt — and + `SuggestionOut.parameters`, the reasons a class is left out of a pre-label prompt, and the + tasks an export target accepts — and the generated check for one accepts a member this client never compiled against, exactly as it accepts an added field. Every other enum still refuses, and refuses the whole response with it: a value the client must *switch* on has no diff --git a/docs/content/cli.md b/docs/content/cli.md index 1a5c2223..3cfdecc2 100644 --- a/docs/content/cli.md +++ b/docs/content/cli.md @@ -35,9 +35,10 @@ visionset job pre-label JOB_ID CONNECTION [--minimum-confidence FLOAT] [--replac visionset release publish --tag T --project P [--split TRAIN,VAL,TEST] [--seed N] visionset release list --project P visionset release verify TAG --project P -visionset export --project P --release TAG --format F --out DIR [--allow-lossy] -visionset export --project P --release TAG --format F --check # writes nothing; exit 1 = it loses something +visionset export --project P --release TAG --target T|--format F --out DIR [--allow-lossy] +visionset export --project P --release TAG --target T|--format F --check # writes nothing; exit 1 = it loses something visionset format list # no --workspace: it opens nothing +visionset target list # the models a release can be exported for visionset backfill-thumbnails --project P visionset token create --name NAME @@ -201,9 +202,10 @@ the callback would have to *precede* the subcommand - `visionset --workspace X t ci` would work and `visionset token create --name ci --workspace X` would fail with "No such option". Nobody types the first one. -`--json` is per command for the identical reason, and so is every other option here. Three -commands do without `--workspace`, each because it needs none: `visionset format list` reads -installed distributions, which is a fact about the process, and `visionset inference size` asks +`--json` is per command for the identical reason, and so is every other option here. Four +commands do without `--workspace`, each because it needs none: `visionset format list` and +`visionset target list` read installed distributions, which is a fact about the process, and +`visionset inference size` asks the publishing hub about a model that no row has to name yet; `visionset init` takes a positional `PATH`, because it names where to *make* a workspace rather than which one to use — and for that reason it @@ -419,9 +421,14 @@ lifecycle must be drivable from a script, not because this is how labelling happ `release publish --tag T --project P [--split TRAIN,VAL,TEST] [--seed N]` → `ReleaseService.publish`. `release list --project P`, and `release verify TAG --project P`, whose **exit code is the answer**. -`export --project P --release TAG --format F --out DIR [--allow-lossy]` resolves the format through -the plugin registry and hands the instance to `ReleaseService.export` - the kernel is forbidden from -finding a plugin itself. `format list` says which are installed. +`export --project P --release TAG --target T|--format F --out DIR [--allow-lossy]` resolves the +target or the format through the plugin registry and hands the instance to `ReleaseService.export` - +the kernel is forbidden from finding a plugin itself. `--target` names the model the release will +train and resolves to the format that writes for it; `--format` names a format and addresses no +trainer. Exactly one of the two: both or neither is a usage error at exit 2. `format list` says +which formats are installed and `target list` which models can be trained on their output, each +with the format it resolves to; both take `--json`, whose shape is the wire's. `--format yolo`, the +former name of `ultralytics`, still works for one release and prints a deprecation line on stderr. A release tag is **case-sensitive** where a project name is not: a tag is an identifier, not a label somebody reads. `--allow-lossy` is a third gate word beside `--yes` and `--allow-destructive`, never diff --git a/docs/content/mcp-tools.md b/docs/content/mcp-tools.md index 57783601..6adbd709 100644 --- a/docs/content/mcp-tools.md +++ b/docs/content/mcp-tools.md @@ -11,7 +11,7 @@ error envelope, and the three gate words. ## Always offered -53 tools, in the order an agent meets them: make a project, give it a schema, put images in it, work through them, promote, publish, export. +54 tools, in the order an agent meets them: make a project, give it a schema, put images in it, work through them, promote, publish, export. | Tool | Takes | What it does | | --- | --- | --- | @@ -59,8 +59,9 @@ error envelope, and the three gate words. | `list_releases` | `project` | List a project's releases, newest last, with everything each one publishes. | | `verify_release` | `project`, `tag` | Re-read and re-hash everything a release names, and report what is wrong. | | `list_formats` | — | List the export formats installed in this VisionSet, and whether each is lossy. | -| `check_export` | `project`, `tag`, `format` | Say what a format would drop from a release, without writing anything. | -| `export_release` | `project`, `tag`, `format`, `dest`, `allow_lossy`? | Write a release to a local directory in one of the installed formats. | +| `list_export_targets` | — | List the models a release can be exported for, each with the format that writes for it. | +| `check_export` | `project`, `tag`, `target`?, `format`? | Say what a target or a format would drop from a release, without writing anything. | +| `export_release` | `project`, `tag`, `dest`, `target`?, `format`?, `allow_lossy`? | Write a release to a local directory, for a target or in one of the installed formats. | | `list_inference_connections` | — | List this workspace's model connections, oldest first. | | `model_download_size` | `model_id`, `model_revision` | How big fetching that model's weights would be. Nothing is downloaded. | | `create_inference_connection` | `name`, `connection_type`, `model_id`, `model_revision`, `device`?, `precision`?, `endpoint_url`?, `provider_id`?, `credential_env`? | Configure a connection. Nothing is downloaded and nothing is contacted. | diff --git a/docs/content/mcp.md b/docs/content/mcp.md index 943667cb..34e2a3ab 100644 --- a/docs/content/mcp.md +++ b/docs/content/mcp.md @@ -190,9 +190,10 @@ call until the end. #439 has since added a job gate, but it changes none of this | `publish_release` | Freeze it under a tag, immutably. | | `list_releases` | Everything published, with counts and hashes. | | `verify_release` | Re-hash every blob a release names. | -| `list_formats` | Installed exporters, which are lossy, and what each can write. | -| `check_export` | What a format would drop from a release, before writing anything. | -| `export_release` | Write a release to a local directory. `allow_lossy` where needed. | +| `list_formats` | Installed exporters, which are lossy, what each can write, and the targets it writes for. | +| `list_export_targets` | The models a release can be exported for, each with the format it resolves to and its hints. | +| `check_export` | What a target or a format would drop from a release, before writing anything. Exactly one of `target` and `format`. | +| `export_release` | Write a release to a local directory, for a target or in a format. `allow_lossy` where needed. | ### Inference connections @@ -358,15 +359,16 @@ out of the object to pick the variant, and omitting it fails. Always send ## What is not here, and why Fifty candidate tools were recorded across the four REST tasks; thirty of them shipped and -twenty did not. Twenty-six have been added since, every one of them because a surface grew a +twenty did not. Twenty-seven have been added since, every one of them because a surface grew a capability an agent had no way to reach. The larger groups say what that looks like: the four batch-composition tools above; the seven inference-connection tools, closing the Models page's SDK-first parity; the four schema-draft tools above, because composing a schema across several calls needs somewhere to hold a class before it is finished; the three deletions, which are advertised only on request; the pre-labeling trio, `pre_label_job` beside the two fan-outs, -closing the last capability declared with no consumer; and `check_export`, the plan-before-apply -half of an export on the `preview_schema_change` precedent. That is fifty-three offered by -default and fifty-six in all. The parity rule means +closing the last capability declared with no consumer; `check_export`, the plan-before-apply +half of an export on the `preview_schema_change` precedent; and `list_export_targets`, because +`export_release` takes a target name and an agent has to be able to read the catalog it comes +from. That is fifty-four offered by default and fifty-seven in all. The parity rule means *evaluated*, not *implemented* — tool-selection accuracy degrades with count, so a tool ships only when an agent has a reason to reach for it that no neighbour covers. diff --git a/docs/content/releases.md b/docs/content/releases.md index 5d3c1c67..0ed20b2c 100644 --- a/docs/content/releases.md +++ b/docs/content/releases.md @@ -308,6 +308,69 @@ the plugin runs (a plugin that clears its own subdirectory would otherwise take when an earlier run left one behind. That is what keeps "an exporter that writes nothing reports zero" true, and keeps exporting twice into one directory agreeing with itself. +### Export targets + +The user-facing unit of export is a **target**: the model the release will train. A target +resolves to exactly one format - the *dialect* that writes its descriptor grammar - and a model +with two trainer homes gets two named targets, never a runtime switch. Every installed format +declares the targets it writes for on the `Exporter` port, and a format that is not a trainer's +declares one target named after itself, family `other`, so every export is addressed the same +way. The kernel derives the catalog from those declarations; `GET /export-targets`, +`visionset target list` and the `list_export_targets` tool all render the same derivation, and +nothing keeps a list by hand. The registry refuses a target declared by two installed formats +(`ExportTargetConflict`) and a target promising a geometry its format never writes +(`InvalidExportTarget`), at the scan rather than at the first export. + +The catalog this build ships, generated from the declarations by +`scripts/export_target_catalog.py` and held current by a drift gate: + + +| Target | Label | Family | Format | Tasks | Geometries | Recommended size | Strategy | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `bdd100k-lane` | bdd100k-lane | `other` | `bdd100k-lane` | — | polyline | — | — | +| `classification` | classification | `other` | `classification` | — | classification_tag | — | — | +| `coco` | coco | `other` | `coco` | — | bbox, polygon | — | — | +| `culane` | culane | `other` | `culane` | — | polyline | — | — | +| `curvelanes` | curvelanes | `other` | `curvelanes` | — | polyline | — | — | +| `dummy` | dummy | `other` | `dummy` | — | bbox, classification_tag, cuboid_3d, keypoints, mask, polygon, polyline, polyline_3d | — | — | +| `openlane-2d` | openlane-2d | `other` | `openlane-2d` | — | polyline | — | — | +| `tusimple` | tusimple | `other` | `tusimple` | — | polyline | — | — | +| `voc` | voc | `other` | `voc` | — | bbox | — | — | +| `yolo11` | YOLO11 | `ultralytics-yolo` | `ultralytics` | classify, detect, obb, pose, segment | bbox, classification_tag, polygon | 640×640 | letterbox | +| `yolo12` | YOLO12 | `ultralytics-yolo` | `ultralytics` | classify, detect, obb, pose, segment | bbox, classification_tag, polygon | 640×640 | letterbox | +| `yolo26` | YOLO26 | `ultralytics-yolo` | `ultralytics` | classify, depth, detect, obb, pose, segment, semantic | bbox, classification_tag, polygon | 640×640 | letterbox | +| `yolov10` | YOLOv10 | `ultralytics-yolo` | `ultralytics` | detect | bbox | 640×640 | letterbox | +| `yolov3` | YOLOv3 | `ultralytics-yolo` | `ultralytics` | detect | bbox | 640×640 | letterbox | +| `yolov5` | YOLOv5 | `ultralytics-yolo` | `ultralytics` | detect | bbox | 640×640 | letterbox | +| `yolov6` | YOLOv6 | `ultralytics-yolo` | `ultralytics` | detect | bbox | 640×640 | letterbox | +| `yolov7` | YOLOv7 | `community-yolo` | `yolov5-yaml` | detect | bbox | 640×640 | letterbox | +| `yolov8` | YOLOv8 | `ultralytics-yolo` | `ultralytics` | classify, detect, obb, pose, segment | bbox, classification_tag, polygon | 640×640 | letterbox | +| `yolov9` | YOLOv9 | `ultralytics-yolo` | `ultralytics` | detect, segment | bbox, polygon | 640×640 | letterbox | + + +`tasks` is the trainer's whole vocabulary, pose and depth included; `geometries` is what an export +addressed to the target carries, which is never wider than the format writes and narrower where +the trainer has no task for a shape. A geometry VisionSet cannot produce - pose, obb, semantic, +depth - is absence rather than a drop, and is never a row of the compatibility report. + +**A target narrows its format.** `check_export`, `require_export_consent` and `export` each take +an optional `target`, and a geometry the format writes whole but the target's trainer has no task +for is reported `dropped` with a reason naming the target - consented through the same `allow_lossy` +gate as every other loss. The export then honours the drop the report promises: the plugin is +handed the manifest with every annotation the target does not carry removed, because the port has +no word for a target and a promise the report makes must not depend on every plugin reading a +declaration it cannot see. The class vocabulary stays whole, since a class index is the frozen +schema's. The report and the `ExportResult` both record `target` - `null` when the export was +addressed to a format alone - so a reader of `visionset-export-report.json` can tell which question +it answers. + +**The per-export task is derived, never chosen.** `segment` when the target accepts it and the +manifest carries any polygon; otherwise `classify` when the target accepts it and the manifest +carries classification tags and no box or polygon; otherwise `detect`. Because the manifest a +target-addressed export hands to the plugin already holds only what the target carries, the +derivation and the declaration cannot disagree: a `yolov10` export of a release holding polygons is +a `detect` layout of its boxes, and the report says the polygons were dropped. + ### The YOLO dialects Two formats write a YOLO dataset, and they differ only in the grammar of `data.yaml`. Each is a @@ -315,7 +378,7 @@ Two formats write a YOLO dataset, and they differ only in the grammar of `data.y will train - the *target* - resolves to exactly one dialect. `ultralytics` is what every trainer from YOLOv3 to YOLO26 in the Ultralytics line reads; `yolov5-yaml` is the older grammar YOLOv7 reads. `yolo`, the former name of `ultralytics`, is accepted as an alias for one release and then -removed. +removed; `visionset export --format yolo` says so on stderr and continues. The layout both share: @@ -591,7 +654,9 @@ than say what it meant to do. visionset release publish --tag v1.0 --project road-signs --split 0.7,0.15,0.15 --seed 42 visionset release list --project road-signs visionset release verify v1.0 --project road-signs +visionset export --project road-signs --release v1.0 --target yolo11 --out ./out visionset export --project road-signs --release v1.0 --format dummy --out ./out +visionset target list ``` `--split` is **one** option rather than three, because a split is one concept, `0.7,0.15,0.15` is @@ -611,11 +676,18 @@ branches on the result without parsing output: visionset release verify v1.0 --project road-signs && ./train.sh ``` -For `export`, the CLI resolves the format name through the plugin registry and hands the *instance* -to `ReleaseService.export`, because the kernel is forbidden from importing the registry. It resolves -it with `pick`, never a dict lookup: a `KeyError` is outside the `VisionSetError` tree and a typo -would answer with a traceback instead of the list of installed formats. `visionset format list` -prints that list without opening a workspace at all. +For `export`, the CLI resolves the name through the plugin registry and hands the *instance* to +`ReleaseService.export`, because the kernel is forbidden from importing the registry. It resolves a +format with `pick` and a target with `resolve_target`, never a dict lookup: a `KeyError` is outside +the `VisionSetError` tree and a typo would answer with a traceback instead of the list of installed +names. `visionset format list` and `visionset target list` print those lists without opening a +workspace at all. + +**`--target` and `--format` are one choice.** A target is the model the release will train and +resolves to the format that writes for it; a format addresses no trainer. Giving both, or neither, +is a usage error at exit 2, because the mistake is on the command line and nothing has been opened +yet. `--format yolo`, the former name of `ultralytics`, still works for one release and prints a +deprecation line on stderr naming the current name. `--allow-lossy` is the third gate word, never folded into `--yes` or `--allow-destructive`. And `dummy` writes nothing, so a `file_count` of 0 in its report is an export that ran, not one that @@ -667,11 +739,18 @@ GET /releases/{id} → 200 ReleaseOut GET /releases/{id}/manifest → 200 application/json, raw GET /releases/{id}/verify → 200 ReleaseVerificationOut GET /releases/{id}/assignment → 200 SplitAssignmentOut -GET /releases/{id}/export-compatibility?format= → 200 ExportCompatibilityOut -POST /releases/{id}/export?format=&allow_lossy= → 200 application/zip -GET /formats → 200 FormatPage +GET /releases/{id}/export-compatibility?target=|format= → 200 ExportCompatibilityOut +POST /releases/{id}/export?target=|format=&allow_lossy= → 202 BackgroundJobOut +GET /formats → 200 FormatPage +GET /export-targets → 200 ExportTargetPage ``` +**`target` and `format` are query aliases, and exactly one is given.** Both or neither is a 422 +`VALIDATION_ERROR` whose one error has `loc: ["query"]` and the message +`give exactly one of target and format`, so a client branches on the code and the location like +every other malformed request. An unknown target is 404 `EXPORT_TARGET_NOT_FOUND` naming the +installed ones; a target two installed formats both declare is 500 `EXPORT_TARGET_CONFLICT`. + **The manifest download is raw bytes off the blob store**, not `ReleaseService.manifest()` re-serialized, and that is the point of the route rather than an optimization. A manifest is hash-pinned evidence: what arrives must hash to `manifest_hash`, and a round trip through this @@ -727,7 +806,11 @@ than this product is. Re-exporting is free, so deleting the lot is safe. **Which formats exist is a property of the deployment**, so `GET /formats` answers it rather than this document. `lossy` is on the row so a client knows before it POSTs whether the export will need `allow_lossy=true`, instead of discovering it by getting a 409; `geometries` and -`modalities` beside it are what the format declares it can write. +`modalities` beside it are what the format declares it can write, and `targets` names the models +it writes for. `GET /export-targets` is the same installation seen from the trainer's side, one +row per target with its `format`, `tasks`, `geometries` and `hints` - flattened so a client renders +one control from one read. The job an export launches carries `target` and `format` in its payload +and its result, and the archive is still laid down under `/exports///`. **`export-compatibility` is the pre-flight, and it is optional.** Same release, same format name, and the same document the export refuses with and writes into its own output - a client showing a @@ -750,6 +833,9 @@ carries the identical body under `detail.compatibility`. | `WorkspaceCorrupt` | The manifest blob is gone, or is not a readable manifest, or the trunk holds an asset that is not stored. All are guarantees failing rather than entities missing. | | `ExportSourceUnreadable` | The release names bytes an export cannot use - the blob is gone, or is not an image the format can write. **409, not 500**: the request is fine and the stored state is not, so the message names the asset and reaches the caller. The remedy is `verify` and then restoring the blob. A previous generation of this tool swallowed this and shipped a training set one image short. | | `ExportFormatNotFound` | Nothing is installed under that format name. Raised by the registry in `visionset.formats`, not by this service - the kernel never sees a name. | +| `ExportTargetNotFound` | No installed format declares a target under that name. Raised by `resolve_target` on the port, over the exporters the surface passed in; the message names the installed targets. | +| `ExportTargetConflict` | Two installed formats declare one target name. A defect of the installation rather than of the request - 500 over HTTP - and the remedy is removing one of the distributions, or exporting by format name. | +| `InvalidExportTarget` | An installed format declares a target promising a geometry the format never writes. Refused at the registry scan, so a defective plugin fails every listing rather than one export. | | `LossyExportNotConsented` | The chosen format cannot carry everything the release holds, and the caller has not passed `allow_lossy`. Raised when the format declares itself lossy **or** when this release's own report is not clean, and it carries that report. Retryable with the flag, which is why a client must branch on the code and never on the 409. | diff --git a/frontend/ui-core/src/generated/api.ts b/frontend/ui-core/src/generated/api.ts index 0604c9b9..8dfe5de9 100644 --- a/frontend/ui-core/src/generated/api.ts +++ b/frontend/ui-core/src/generated/api.ts @@ -843,6 +843,38 @@ export interface paths { patch?: never; trace?: never; }; + "/export-targets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Export Targets + * @description Every model this server can export a release for, by name. + * + * The catalog is derived from the installed formats: each declares the targets + * it writes for, and every installed format declares at least one, so nothing + * exportable is missing from this list. `name` is what + * `POST /releases/{release_id}/export?target=` takes, and `format` is the + * installed format that export resolves to. + * + * `geometries` is what an export addressed to the target carries — never wider + * than its format writes, and narrower where the trainer has no task for a + * shape. `tasks` is the trainer's own vocabulary and may name tasks nothing + * here can feed. `hints` is what the trainer expects of its images, for a + * client that offers to prepare them. + */ + get: operations["list_export_targets"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/formats": { parameters: { query?: never; @@ -854,7 +886,9 @@ export interface paths { * List Formats * @description Every export format installed on this server, by name. * - * `name` is what `POST /releases/{release_id}/export?format=` takes. + * `name` is what `POST /releases/{release_id}/export?format=` takes. `targets` + * names the models this format writes for; `GET /export-targets` carries each + * one in full. * * `lossy` says the format cannot carry everything the kernel can represent — * some geometry, attribute kind, or per-annotation provenance is dropped. It is @@ -2683,14 +2717,23 @@ export interface paths { * `GET /background-jobs/{id}` — the `Location` header names it — until `state` * is `succeeded`, then `GET /background-jobs/{id}/artifact` for the archive. * - * **Everything a caller can be told now is still told now.** Which formats - * exist is a property of this deployment — `GET /formats` lists what is - * installed — and an unknown name is 404 `EXPORT_FORMAT_NOT_FOUND` on this + * **Exactly one of `target` and `format`.** A target is the model the + * release will train — `GET /export-targets` lists them — and resolves to + * the format that writes for it; a format addresses no trainer. Both or + * neither is a 422 `VALIDATION_ERROR`. An export addressed to a target + * carries only the geometries its trainer has a task for, and the report it + * writes names the target. + * + * **Everything a caller can be told now is still told now.** Which targets + * and formats exist is a property of this deployment, and an unknown name is + * 404 `EXPORT_TARGET_NOT_FOUND` or 404 `EXPORT_FORMAT_NOT_FOUND` on this * request. A format that cannot carry everything the release holds is 409 * `LOSSY_EXPORT_NOT_CONSENTED` on this request too, and retrying is the * identical call plus `allow_lossy=true`. An unknown release is 404 - * `RELEASE_NOT_FOUND`. None of the three creates a job, so a caller holding a - * job id holds one that will run. + * `RELEASE_NOT_FOUND`. None of these creates a job, so a caller holding a + * job id holds one that will run. A target two installed formats both + * declare is 500 `EXPORT_TARGET_CONFLICT`, and a release whose manifest blob + * is gone is 500 `WORKSPACE_CORRUPT`. * * A POST because it does work and writes files, though it changes nothing a * later read can see: the release is immutable, and re-exporting overwrites the @@ -2712,13 +2755,23 @@ export interface paths { }; /** * Check Export - * @description Say what the named format would drop from this release, without writing anything. + * @description Say what the named target or format would drop from this release, without writing anything. * * The pre-flight for `POST /releases/{release_id}/export`: same release, same - * format name, same document the export refuses with and writes into its own + * address, same document the export refuses with and writes into its own * output. A client showing a consent dialog asks this first; one that would * rather find out by being refused does not have to. * + * Exactly one of `target` and `format`. A target narrows its format to the + * geometries its trainer has a task for, so a report for `target=yolov10` + * can say `dropped` where one for `format=ultralytics` says `supported`; + * `target` on the report says which question it answers. An unknown target is + * 404 `EXPORT_TARGET_NOT_FOUND`, an unknown format 404 `EXPORT_FORMAT_NOT_FOUND`, + * an unknown release 404 `RELEASE_NOT_FOUND`. A target two installed formats + * both declare is 500 `EXPORT_TARGET_CONFLICT`, and a release whose manifest + * blob is gone is 500 `WORKSPACE_CORRUPT`; neither is something the request + * can fix. + * * `compatible` is the answer. It is not the same question as the format's * `lossy` flag, which `GET /formats` publishes: that is the format's blanket * statement about everything a capability list cannot see, while this is about @@ -4005,6 +4058,9 @@ export interface components { /** * ExportCompatibilityOut * @description What one format would drop from one release, worked out before writing. + * + * `target` names the trainer the release was judged for, and is null when it + * was judged against the format alone. */ ExportCompatibilityOut: { /** Classes */ @@ -4028,6 +4084,48 @@ export interface components { * Format: uuid */ release_id: string; + /** Target */ + target?: string | null; + }; + /** + * ExportTargetOut + * @description One model a person can train on, and the installed format that writes for it. + * + * `name` is what `POST /releases/{release_id}/export?target=` takes; `format` + * is the format it resolves to, one of `GET /formats`. `tasks` is the trainer's + * own vocabulary and may name tasks no geometry here can feed; `geometries` is + * what an export addressed to this target carries. + */ + ExportTargetOut: { + /** Family */ + family: string; + /** Format */ + format: string; + /** + * Geometries + * @default [] + */ + geometries: string[]; + hints: components["schemas"]["PreprocessingHintsOut"]; + /** Label */ + label: string; + /** Name */ + name: string; + /** + * Tasks + * @default [] + */ + tasks: components["schemas"]["Task"][]; + }; + /** + * ExportTargetPage + * @description A page of export targets. + */ + ExportTargetPage: { + /** Items */ + items: components["schemas"]["ExportTargetOut"][]; + /** Total */ + total: number; }; /** * FormatOut @@ -4053,6 +4151,11 @@ export interface components { modalities: string[]; /** Name */ name: string; + /** + * Targets + * @default [] + */ + targets: string[]; }; /** * FormatPage @@ -4604,6 +4707,28 @@ export interface components { * @enum {string} */ Precision: "fp16" | "fp32"; + /** + * PreprocessingHintsOut + * @description What a target's trainer expects of its input images. Hints, never requirements. + * + * `recommended_size` is `[width, height]`. `trainer_resizes` says the trainer + * resizes on its own, so resizing beforehand is an optimization rather than a + * need; `augmentation_common` says augmentation is the ordinary practice when + * training this target. + */ + PreprocessingHintsOut: { + /** Augmentation Common */ + augmentation_common: boolean; + /** Recommended Size */ + recommended_size?: [ + number, + number + ] | null; + /** Recommended Strategy */ + recommended_strategy?: string | null; + /** Trainer Resizes */ + trainer_resizes: boolean; + }; /** * ProgressCounts * @description How many assets sit in each annotation state. @@ -5290,6 +5415,16 @@ export interface components { /** Regions */ regions: components["schemas"]["SuggestedRegion"][]; }; + /** + * Task + * @description A trainer-side task an export target accepts. + * + * Open on the wire because it travels only as a target's task list, which a + * client renders member by member: a trainer gaining a task must not cost an + * older client the whole catalog. + * @enum {string} + */ + Task: "detect" | "segment" | "classify" | "pose" | "obb" | "semantic" | "depth" | (string & {}); /** * VideoProvenanceOut * @description What a clip turned out to be, and the cut it is decomposed by. @@ -7271,6 +7406,62 @@ export interface operations { }; }; }; + list_export_targets: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExportTargetPage"]; + }; + }; + /** @description Missing or invalid bearer token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description The request payload is not processable */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description Unhandled server error, with an incident id */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description The workspace is busy; retry after the header says */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + }; + }; list_formats: { parameters: { query?: never; @@ -11444,11 +11635,13 @@ export interface operations { }; export_release: { parameters: { - query: { - /** @description Which installed format to write. `GET /formats` lists them. */ - format: string; + query?: { /** @description Required when the format cannot carry everything the release holds. */ allow_lossy?: boolean; + /** @description An export target's name. `GET /export-targets` lists them. */ + target?: string | null; + /** @description An installed format's name. `GET /formats` lists them. */ + format?: string | null; }; header?: never; path: { @@ -11525,9 +11718,11 @@ export interface operations { }; check_export: { parameters: { - query: { - /** @description Which installed format to write. `GET /formats` lists them. */ - format: string; + query?: { + /** @description An export target's name. `GET /export-targets` lists them. */ + target?: string | null; + /** @description An installed format's name. `GET /formats` lists them. */ + format?: string | null; }; header?: never; path: { @@ -11955,4 +12150,5 @@ export interface KnownMembers { ModelCapability: "point_suggest" | "text_detect"; PreLabelExclusionReason: "no_producible_geometry" | "required_attribute"; SuggestParameter: "tolerance"; + Task: "detect" | "segment" | "classify" | "pose" | "obb" | "semantic" | "depth"; } diff --git a/frontend/ui-core/src/generated/checks.ts b/frontend/ui-core/src/generated/checks.ts index 4be6a319..d172067b 100644 --- a/frontend/ui-core/src/generated/checks.ts +++ b/frontend/ui-core/src/generated/checks.ts @@ -184,10 +184,22 @@ export const checkClassCompatibilityOut: Check /*#__PURE__*/ object({ "annotations": [true, isInteger], "assets": [true, isInteger], "geometry": [true, checkGeometryType], "label_class": [true, isString], "reason": [false, either([isString, isNull] as const)], "status": [true, checkClassExportStatus] } as const); export const checkExportCompatibilityOut: Check = - /*#__PURE__*/ object({ "classes": [true, arrayOf(checkClassCompatibilityOut)], "compatible": [true, isBoolean], "degraded_annotations": [true, isInteger], "degraded_assets": [true, isInteger], "excluded_annotations": [true, isInteger], "excluded_assets": [true, isInteger], "format": [true, isString], "format_is_lossy": [true, isBoolean], "release_id": [true, isString] } as const); + /*#__PURE__*/ object({ "classes": [true, arrayOf(checkClassCompatibilityOut)], "compatible": [true, isBoolean], "degraded_annotations": [true, isInteger], "degraded_assets": [true, isInteger], "excluded_annotations": [true, isInteger], "excluded_assets": [true, isInteger], "format": [true, isString], "format_is_lossy": [true, isBoolean], "release_id": [true, isString], "target": [false, either([isString, isNull] as const)] } as const); + +export const checkPreprocessingHintsOut: Check = + /*#__PURE__*/ object({ "augmentation_common": [true, isBoolean], "recommended_size": [false, either([tuple([isInteger, isInteger] as const), isNull] as const)], "recommended_strategy": [false, either([isString, isNull] as const)], "trainer_resizes": [true, isBoolean] } as const); + +export const checkTask: Check = + /*#__PURE__*/ openOneOf(["detect", "segment", "classify", "pose", "obb", "semantic", "depth"] as const); + +export const checkExportTargetOut: Check = + /*#__PURE__*/ object({ "family": [true, isString], "format": [true, isString], "geometries": [true, arrayOf(isString)], "hints": [true, checkPreprocessingHintsOut], "label": [true, isString], "name": [true, isString], "tasks": [true, arrayOf(checkTask)] } as const); + +export const checkExportTargetPage: Check = + /*#__PURE__*/ object({ "items": [true, arrayOf(checkExportTargetOut)], "total": [true, isInteger] } as const); export const checkFormatOut: Check = - /*#__PURE__*/ object({ "degraded_geometries": [true, arrayOf(isString)], "geometries": [true, arrayOf(isString)], "lossy": [true, isBoolean], "modalities": [true, arrayOf(isString)], "name": [true, isString] } as const); + /*#__PURE__*/ object({ "degraded_geometries": [true, arrayOf(isString)], "geometries": [true, arrayOf(isString)], "lossy": [true, isBoolean], "modalities": [true, arrayOf(isString)], "name": [true, isString], "targets": [true, arrayOf(isString)] } as const); export const checkFormatPage: Check = /*#__PURE__*/ object({ "items": [true, arrayOf(checkFormatOut)], "total": [true, isInteger] } as const); @@ -433,6 +445,7 @@ export const checkListBlockingAssets = checkBlockingAssetPage; export const checkListDatasetAssetAnnotations = checkAnnotationPage; export const checkListDatasetAssets = checkDatasetAssetPage; export const checkListDatasetChanges = checkDatasetChangePage; +export const checkListExportTargets = checkExportTargetPage; export const checkListFormats = checkFormatPage; export const checkListInferenceConnections = checkConnectionPage; export const checkListIngestJobs = checkIngestJobPage; diff --git a/frontend/ui-core/src/screens/dataset.test.tsx b/frontend/ui-core/src/screens/dataset.test.tsx index 2916fde4..fa26f631 100644 --- a/frontend/ui-core/src/screens/dataset.test.tsx +++ b/frontend/ui-core/src/screens/dataset.test.tsx @@ -20,10 +20,10 @@ import { writeToken } from "../data/session"; import { DatasetScreen } from "./DatasetScreen"; const API = "http://visionset.test"; -// The three list fields `FormatOut` declares with a default. A default means the +// The four list fields `FormatOut` declares with a default. A default means the // server serializes them every time, which is why the contract types them as always // present rather than optional. -const FORMAT_REST = { geometries: [], modalities: [], degraded_geometries: [] } as const; +const FORMAT_REST = { geometries: [], modalities: [], degraded_geometries: [], targets: [] } as const; const PROJECT = "11111111-1111-4111-8111-111111111111"; const DATASET = "22222222-2222-4222-8222-222222222222"; diff --git a/openapi.json b/openapi.json index 9ecc0bc5..ac90aaeb 100644 --- a/openapi.json +++ b/openapi.json @@ -2777,7 +2777,7 @@ "type": "object" }, "ExportCompatibilityOut": { - "description": "What one format would drop from one release, worked out before writing.", + "description": "What one format would drop from one release, worked out before writing.\n\n`target` names the trainer the release was judged for, and is null when it\nwas judged against the format alone.", "properties": { "classes": { "items": { @@ -2818,6 +2818,17 @@ "format": "uuid", "title": "Release Id", "type": "string" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target" } }, "required": [ @@ -2834,6 +2845,77 @@ "title": "ExportCompatibilityOut", "type": "object" }, + "ExportTargetOut": { + "description": "One model a person can train on, and the installed format that writes for it.\n\n`name` is what `POST /releases/{release_id}/export?target=` takes; `format`\nis the format it resolves to, one of `GET /formats`. `tasks` is the trainer's\nown vocabulary and may name tasks no geometry here can feed; `geometries` is\nwhat an export addressed to this target carries.", + "properties": { + "family": { + "title": "Family", + "type": "string" + }, + "format": { + "title": "Format", + "type": "string" + }, + "geometries": { + "default": [], + "items": { + "type": "string" + }, + "title": "Geometries", + "type": "array" + }, + "hints": { + "$ref": "#/components/schemas/PreprocessingHintsOut" + }, + "label": { + "title": "Label", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "tasks": { + "default": [], + "items": { + "$ref": "#/components/schemas/Task" + }, + "title": "Tasks", + "type": "array" + } + }, + "required": [ + "name", + "label", + "family", + "format", + "hints" + ], + "title": "ExportTargetOut", + "type": "object" + }, + "ExportTargetPage": { + "description": "A page of export targets.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ExportTargetOut" + }, + "title": "Items", + "type": "array" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "title": "ExportTargetPage", + "type": "object" + }, "FormatOut": { "description": "An installed export format, and what it can express.", "properties": { @@ -2868,6 +2950,14 @@ "name": { "title": "Name", "type": "string" + }, + "targets": { + "default": [], + "items": { + "type": "string" + }, + "title": "Targets", + "type": "array" } }, "required": [ @@ -3881,6 +3971,57 @@ "title": "Precision", "type": "string" }, + "PreprocessingHintsOut": { + "description": "What a target's trainer expects of its input images. Hints, never requirements.\n\n`recommended_size` is `[width, height]`. `trainer_resizes` says the trainer\nresizes on its own, so resizing beforehand is an optimization rather than a\nneed; `augmentation_common` says augmentation is the ordinary practice when\ntraining this target.", + "properties": { + "augmentation_common": { + "title": "Augmentation Common", + "type": "boolean" + }, + "recommended_size": { + "anyOf": [ + { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "integer" + } + ], + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Recommended Size" + }, + "recommended_strategy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Recommended Strategy" + }, + "trainer_resizes": { + "title": "Trainer Resizes", + "type": "boolean" + } + }, + "required": [ + "trainer_resizes", + "augmentation_common" + ], + "title": "PreprocessingHintsOut", + "type": "object" + }, "ProgressCounts": { "description": "How many assets sit in each annotation state.", "properties": { @@ -5231,6 +5372,21 @@ "title": "SuggestionOut", "type": "object" }, + "Task": { + "description": "A trainer-side task an export target accepts.\n\nOpen on the wire because it travels only as a target's task list, which a\nclient renders member by member: a trainer gaining a task must not cost an\nolder client the whole catalog.", + "enum": [ + "detect", + "segment", + "classify", + "pose", + "obb", + "semantic", + "depth" + ], + "title": "Task", + "type": "string", + "x-visionset-open": true + }, "VideoProvenanceOut": { "description": "What a clip turned out to be, and the cut it is decomposed by.\n\n`ranges` is the canonical form of the selection the source was registered\nwith \u2014 clamped to the clip, sorted, overlaps merged \u2014 and empty means the\nwhole clip. Like `extraction_fps`, it is part of the source's identity.", "properties": { @@ -8066,9 +8222,76 @@ ] } }, + "/export-targets": { + "get": { + "description": "Every model this server can export a release for, by name.\n\nThe catalog is derived from the installed formats: each declares the targets\nit writes for, and every installed format declares at least one, so nothing\nexportable is missing from this list. `name` is what\n`POST /releases/{release_id}/export?target=` takes, and `format` is the\ninstalled format that export resolves to.\n\n`geometries` is what an export addressed to the target carries \u2014 never wider\nthan its format writes, and narrower where the trainer has no task for a\nshape. `tasks` is the trainer's own vocabulary and may name tasks nothing\nhere can feed. `hints` is what the trainer expects of its images, for a\nclient that offers to prepare them.", + "operationId": "list_export_targets", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportTargetPage" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Export Targets", + "tags": [ + "formats" + ] + } + }, "/formats": { "get": { - "description": "Every export format installed on this server, by name.\n\n`name` is what `POST /releases/{release_id}/export?format=` takes.\n\n`lossy` says the format cannot carry everything the kernel can represent \u2014\nsome geometry, attribute kind, or per-annotation provenance is dropped. It is\na property of the format rather than of any one release, so it is answered\nhere and not per export, and exporting in one requires `allow_lossy=true`.\n\nNever empty in practice: a built-in no-op format ships with VisionSet so the\nplugin path is exercised even before a real exporter is installed.", + "description": "Every export format installed on this server, by name.\n\n`name` is what `POST /releases/{release_id}/export?format=` takes. `targets`\nnames the models this format writes for; `GET /export-targets` carries each\none in full.\n\n`lossy` says the format cannot carry everything the kernel can represent \u2014\nsome geometry, attribute kind, or per-annotation provenance is dropped. It is\na property of the format rather than of any one release, so it is answered\nhere and not per export, and exporting in one requires `allow_lossy=true`.\n\nNever empty in practice: a built-in no-op format ships with VisionSet so the\nplugin path is exercised even before a real exporter is installed.", "operationId": "list_formats", "responses": { "200": { @@ -13757,7 +13980,7 @@ }, "/releases/{release_id}/export": { "post": { - "description": "Queue the release for writing, and answer at once with the job to poll.\n\n**202, not 200, and this is a breaking change to this one endpoint.** It used\nto block until the exporter finished and answer with the archive. A real\nexporter walks every asset in a release and copies its bytes, which is\nminutes of work behind a request that has no way to report progress and every\nproxy's timeout in front of it. So this now follows the launch-and-poll\ncontract the ingest routes have always used: poll\n`GET /background-jobs/{id}` \u2014 the `Location` header names it \u2014 until `state`\nis `succeeded`, then `GET /background-jobs/{id}/artifact` for the archive.\n\n**Everything a caller can be told now is still told now.** Which formats\nexist is a property of this deployment \u2014 `GET /formats` lists what is\ninstalled \u2014 and an unknown name is 404 `EXPORT_FORMAT_NOT_FOUND` on this\nrequest. A format that cannot carry everything the release holds is 409\n`LOSSY_EXPORT_NOT_CONSENTED` on this request too, and retrying is the\nidentical call plus `allow_lossy=true`. An unknown release is 404\n`RELEASE_NOT_FOUND`. None of the three creates a job, so a caller holding a\njob id holds one that will run.\n\nA POST because it does work and writes files, though it changes nothing a\nlater read can see: the release is immutable, and re-exporting overwrites the\nprevious archive.", + "description": "Queue the release for writing, and answer at once with the job to poll.\n\n**202, not 200, and this is a breaking change to this one endpoint.** It used\nto block until the exporter finished and answer with the archive. A real\nexporter walks every asset in a release and copies its bytes, which is\nminutes of work behind a request that has no way to report progress and every\nproxy's timeout in front of it. So this now follows the launch-and-poll\ncontract the ingest routes have always used: poll\n`GET /background-jobs/{id}` \u2014 the `Location` header names it \u2014 until `state`\nis `succeeded`, then `GET /background-jobs/{id}/artifact` for the archive.\n\n**Exactly one of `target` and `format`.** A target is the model the\nrelease will train \u2014 `GET /export-targets` lists them \u2014 and resolves to\nthe format that writes for it; a format addresses no trainer. Both or\nneither is a 422 `VALIDATION_ERROR`. An export addressed to a target\ncarries only the geometries its trainer has a task for, and the report it\nwrites names the target.\n\n**Everything a caller can be told now is still told now.** Which targets\nand formats exist is a property of this deployment, and an unknown name is\n404 `EXPORT_TARGET_NOT_FOUND` or 404 `EXPORT_FORMAT_NOT_FOUND` on this\nrequest. A format that cannot carry everything the release holds is 409\n`LOSSY_EXPORT_NOT_CONSENTED` on this request too, and retrying is the\nidentical call plus `allow_lossy=true`. An unknown release is 404\n`RELEASE_NOT_FOUND`. None of these creates a job, so a caller holding a\njob id holds one that will run. A target two installed formats both\ndeclare is 500 `EXPORT_TARGET_CONFLICT`, and a release whose manifest blob\nis gone is 500 `WORKSPACE_CORRUPT`.\n\nA POST because it does work and writes files, though it changes nothing a\nlater read can see: the release is immutable, and re-exporting overwrites the\nprevious archive.", "operationId": "export_release", "parameters": [ { @@ -13770,17 +13993,6 @@ "type": "string" } }, - { - "description": "Which installed format to write. `GET /formats` lists them.", - "in": "query", - "name": "format", - "required": true, - "schema": { - "description": "Which installed format to write. `GET /formats` lists them.", - "title": "Format", - "type": "string" - } - }, { "description": "Required when the format cannot carry everything the release holds.", "in": "query", @@ -13792,6 +14004,42 @@ "title": "Allow Lossy", "type": "boolean" } + }, + { + "description": "An export target's name. `GET /export-targets` lists them.", + "in": "query", + "name": "target", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "An export target's name. `GET /export-targets` lists them.", + "title": "Target" + } + }, + { + "description": "An installed format's name. `GET /formats` lists them.", + "in": "query", + "name": "format", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "An installed format's name. `GET /formats` lists them.", + "title": "Format" + } } ], "responses": { @@ -13879,7 +14127,7 @@ }, "/releases/{release_id}/export-compatibility": { "get": { - "description": "Say what the named format would drop from this release, without writing anything.\n\nThe pre-flight for `POST /releases/{release_id}/export`: same release, same\nformat name, same document the export refuses with and writes into its own\noutput. A client showing a consent dialog asks this first; one that would\nrather find out by being refused does not have to.\n\n`compatible` is the answer. It is not the same question as the format's\n`lossy` flag, which `GET /formats` publishes: that is the format's blanket\nstatement about everything a capability list cannot see, while this is about\nthe labels *this* release actually holds. Export asks for `allow_lossy=true`\nwhen either says so.\n\nA GET because it writes nothing and answers the same thing every time \u2014 a\nrelease is immutable, so this response is as stable as the release is.", + "description": "Say what the named target or format would drop from this release, without writing anything.\n\nThe pre-flight for `POST /releases/{release_id}/export`: same release, same\naddress, same document the export refuses with and writes into its own\noutput. A client showing a consent dialog asks this first; one that would\nrather find out by being refused does not have to.\n\nExactly one of `target` and `format`. A target narrows its format to the\ngeometries its trainer has a task for, so a report for `target=yolov10`\ncan say `dropped` where one for `format=ultralytics` says `supported`;\n`target` on the report says which question it answers. An unknown target is\n404 `EXPORT_TARGET_NOT_FOUND`, an unknown format 404 `EXPORT_FORMAT_NOT_FOUND`,\nan unknown release 404 `RELEASE_NOT_FOUND`. A target two installed formats\nboth declare is 500 `EXPORT_TARGET_CONFLICT`, and a release whose manifest\nblob is gone is 500 `WORKSPACE_CORRUPT`; neither is something the request\ncan fix.\n\n`compatible` is the answer. It is not the same question as the format's\n`lossy` flag, which `GET /formats` publishes: that is the format's blanket\nstatement about everything a capability list cannot see, while this is about\nthe labels *this* release actually holds. Export asks for `allow_lossy=true`\nwhen either says so.\n\nA GET because it writes nothing and answers the same thing every time \u2014 a\nrelease is immutable, so this response is as stable as the release is.", "operationId": "check_export", "parameters": [ { @@ -13893,14 +14141,39 @@ } }, { - "description": "Which installed format to write. `GET /formats` lists them.", + "description": "An export target's name. `GET /export-targets` lists them.", + "in": "query", + "name": "target", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "An export target's name. `GET /export-targets` lists them.", + "title": "Target" + } + }, + { + "description": "An installed format's name. `GET /formats` lists them.", "in": "query", "name": "format", - "required": true, + "required": false, "schema": { - "description": "Which installed format to write. `GET /formats` lists them.", - "title": "Format", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "An installed format's name. `GET /formats` lists them.", + "title": "Format" } } ], diff --git a/scripts/check.sh b/scripts/check.sh index 2781c8c7..0c00c475 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -160,6 +160,7 @@ run_generated() { step "openapi drift" openapi_is_current step "generated client drift" pnpm generate:client:check step "mcp tool reference drift" uv run python scripts/export_mcp_tools.py --check + step "export target catalog drift" uv run python scripts/export_target_catalog.py --check step "version sync" pnpm version:check # `tests/fixtures/wire_annotations.json` is deliberately absent: its gate is # `tests/server/test_wire_fixtures.py`, so the `python` group already runs it. diff --git a/scripts/export_target_catalog.py b/scripts/export_target_catalog.py new file mode 100644 index 00000000..0b445e47 --- /dev/null +++ b/scripts/export_target_catalog.py @@ -0,0 +1,103 @@ +"""Write the export-target table in `docs/content/releases.md` from the installed catalog. + +**Generated rather than curated**, for the reason `export_mcp_tools.py` gives: +the catalog is derived from what the installed formats declare, every surface +renders that derivation, and a hand-written table would be a second copy free +to drift from the one `GET /export-targets` serves. The table lives inside a +hand-written document, so only the region between the two markers is owned +here; the prose around it stays the author's. + +Usage: + + uv run python scripts/export_target_catalog.py # rewrite the region + uv run python scripts/export_target_catalog.py --check # fail if stale +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from visionset import wire +from visionset.formats.registry import exporters + +REPO_ROOT = Path(__file__).resolve().parent.parent + +DOCUMENT_PATH = REPO_ROOT / "docs" / "content" / "releases.md" + +BEGIN = "" +END = "" + +COLUMNS = ( + "Target", + "Label", + "Family", + "Format", + "Tasks", + "Geometries", + "Recommended size", + "Strategy", +) + + +def render() -> str: + """The region between the markers, markers included.""" + lines = [ + BEGIN, + "| " + " | ".join(COLUMNS) + " |", + "| " + " | ".join("---" for _ in COLUMNS) + " |", + ] + for row in wire.export_targets(exporters()): + hints = row["hints"] + size = hints["recommended_size"] + lines.append( + "| " + + " | ".join( + ( + f"`{row['name']}`", + str(row["label"]), + f"`{row['family']}`", + f"`{row['format']}`", + ", ".join(row["tasks"]) or "—", + ", ".join(row["geometries"]), + "—" if size is None else f"{size[0]}×{size[1]}", + "—" + if hints["recommended_strategy"] is None + else str(hints["recommended_strategy"]), + ) + ) + + " |" + ) + lines.append(END) + return "\n".join(lines) + + +def _split(document: str) -> tuple[str, str, str]: + """The text before the region, the region, and the text after it.""" + start = document.index(BEGIN) + stop = document.index(END, start) + len(END) + return document[:start], document[start:stop], document[stop:] + + +def main(argv: list[str]) -> int: + document = DOCUMENT_PATH.read_text(encoding="utf-8") + before, current, after = _split(document) + rendered = render() + relative = DOCUMENT_PATH.relative_to(REPO_ROOT) + if "--check" in argv: + if current != rendered: + print( + f"the export-target table in {relative} is stale — run " + f"`uv run python scripts/export_target_catalog.py` and commit the result.", + file=sys.stderr, + ) + return 1 + print(f"the export-target table in {relative} matches the installed catalog.") + return 0 + DOCUMENT_PATH.write_text(before + rendered + after, encoding="utf-8") + print(f"wrote the export-target table in {DOCUMENT_PATH}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/visionset/cli/export.py b/src/visionset/cli/export.py index 7112efed..8ce93a8f 100644 --- a/src/visionset/cli/export.py +++ b/src/visionset/cli/export.py @@ -1,13 +1,19 @@ # usage: from visionset.cli.export import export -"""``visionset export`` — a release, an installed format, a directory. +"""``visionset export`` — a release, a target or an installed format, a directory. The kernel takes a plugin instance; it does not find one. ``ReleaseService.export`` is handed an ``Exporter``, because import-linter forbids ``visionset.kernel`` importing ``visionset.formats`` — a plugin registry is discovery at runtime, and the kernel is the part that must not do any. So resolving a *name* to a plugin is the surface's job, and this module does it the one supported way: -``formats.registry.exporter(name)``, never a dict lookup, because a ``KeyError`` -is outside the ``VisionSetError`` tree and would answer a typo with a traceback. +``registry.pick`` for a format and ``resolve_target`` for a target, never a dict +lookup, because a ``KeyError`` is outside the ``VisionSetError`` tree and would +answer a typo with a traceback. + +**``--target`` and ``--format`` are one choice, not two flags.** A target is the +model the release will train and resolves to the format that writes for it; a +format addresses no trainer. Giving both, or neither, is a usage error at exit +2, because the mistake is on the command line and nothing has been opened yet. **``--allow-lossy`` is a third gate word, never folded into ``--yes``.** ``--yes`` guards destroying data and ``--allow-destructive`` guards narrowing a contract; @@ -50,8 +56,9 @@ from visionset.cli._output import JsonOption, document, note, table from visionset.cli._resolve import ProjectOption, resolve_release from visionset.cli._workspace import WorkspaceOption, opened_workspace -from visionset.formats.registry import exporter -from visionset.kernel.domain import ExportCompatibility +from visionset.formats import registry +from visionset.kernel.domain import ExportCompatibility, ExportTarget +from visionset.kernel.ports import Exporter, resolve_target from visionset.kernel.services import EXPORT_REPORT_FILENAME, ReleaseService @@ -62,8 +69,21 @@ def export( # of the module. Typer takes the flag's spelling from the option, not the # parameter, so ``--format`` is unaffected. format_name: Annotated[ - str, typer.Option("--format", "-f", help="An installed exporter's name.") - ], + str | None, + typer.Option( + "--format", + "-f", + help="An installed format's name. `visionset format list` says which.", + ), + ] = None, + target: Annotated[ + str | None, + typer.Option( + "--target", + "-t", + help="The model to train, resolved to its format. `visionset target list` says which.", + ), + ] = None, out: Annotated[ Path | None, typer.Option( @@ -90,9 +110,11 @@ def export( json_out: JsonOption = False, workspace: WorkspaceOption = None, ) -> None: - """Write a release out in an installed format. + """Write a release out for a target, or in an installed format. - `visionset format list` says which formats are installed. A name that is not + Exactly one of `--target` and `--format`. `visionset target list` says which + models can be trained on what this installation writes, and + `visionset format list` which formats are installed; a name that is not among them is refused with the list, at exit 1. With `--check` nothing is written: it prints the per-class compatibility @@ -105,15 +127,19 @@ def export( # is in the command line rather than in the workspace. if not check and out is None: raise typer.BadParameter("Required unless --check is given.", param_hint="--out") + if (target is None) == (format_name is None): + raise typer.BadParameter( + "Give exactly one of --target and --format.", param_hint="--target / --format" + ) with opened_workspace(workspace) as service: - # Inside the block on purpose: ``ExportFormatNotFound`` is a - # ``VisionSetError`` naming every installed format, and ``opened_workspace`` - # is what turns it into one sentence and exit 1. - plugin = exporter(format_name) + # Inside the block on purpose: ``ExportFormatNotFound`` and + # ``ExportTargetNotFound`` are ``VisionSetError``s naming every installed + # name, and ``opened_workspace`` is what turns one into a sentence and exit 1. + plugin, addressed = _resolve(target, format_name) found = resolve_release(service, project, release) if check: - report = ReleaseService(service).check_export(found.id, plugin) + report = ReleaseService(service).check_export(found.id, plugin, target=addressed) _report(report, json_out=json_out) # **The same predicate `ReleaseService.export` gates on**, and not # `report.compatible` alone: a format that declares itself lossy asks @@ -129,7 +155,9 @@ def export( # `out` is not None here — the guard above is what makes that true, and # mypy cannot see through it across the `with`. assert out is not None - result = ReleaseService(service).export(found.id, plugin, out, allow_lossy=allow_lossy) + result = ReleaseService(service).export( + found.id, plugin, out, allow_lossy=allow_lossy, target=addressed + ) if json_out: document(wire.export_result(result)) return @@ -160,6 +188,27 @@ def export( typer.echo(str(result.directory)) +def _resolve(target: str | None, format_name: str | None) -> tuple[Exporter, ExportTarget | None]: + """The plugin the command line named, and the target when it named one. + + A format reached through a former name still works, and says so on stderr: + the alias is honoured for one release, and a script that types it should + learn that here rather than from the release that removes it. + """ + # Through the module, so a test can substitute the scan the way the job handler lets it. + installed = registry.exporters() + if target is not None: + return resolve_target(installed, target) + assert format_name is not None + plugin, alias = registry.pick(installed, format_name) + if alias is not None: + note( + f"--format {alias} is deprecated and will be removed in the next release; " + f"use --format {plugin.format_name}." + ) + return plugin, None + + def _report(report: ExportCompatibility, *, json_out: bool) -> None: """The per-class answer, as `--json` or as columns. diff --git a/src/visionset/cli/main.py b/src/visionset/cli/main.py index ea30f9b7..3eedb767 100644 --- a/src/visionset/cli/main.py +++ b/src/visionset/cli/main.py @@ -19,6 +19,7 @@ from visionset.cli.releases import release_app from visionset.cli.schemas import schema_app from visionset.cli.server import server +from visionset.cli.targets import target_app from visionset.cli.tokens import token_app app = typer.Typer( @@ -50,6 +51,7 @@ app.add_typer(release_app, name="release") app.command("export")(export) app.add_typer(format_app, name="format") +app.add_typer(target_app, name="target") app.command("backfill-thumbnails")(backfill_thumbnails) app.add_typer(token_app, name="token") app.add_typer(inference_app, name="inference") diff --git a/src/visionset/cli/targets.py b/src/visionset/cli/targets.py new file mode 100644 index 00000000..2f6fa1cb --- /dev/null +++ b/src/visionset/cli/targets.py @@ -0,0 +1,51 @@ +# usage: from visionset.cli.targets import target_app +"""``visionset target`` — which models this installation can export a release for. + +The trainer's view of ``visionset format``: every installed format declares the +targets it writes for, and this lists them flattened, each with the format it +resolves to. Like ``format list`` it opens no workspace, because what is +installed is a fact about the process rather than about any dataset — and it +exists for the same reason: the valid values of ``export --target`` depend on +what somebody installed. +""" + +from __future__ import annotations + +from typing import Final + +import typer + +from visionset import wire +from visionset.cli._output import JsonOption, document, note, table +from visionset.formats import registry + +target_app = typer.Typer( + help="Inspect the models a release can be exported for.", no_args_is_help=True +) + +_COLUMNS: Final = ("NAME", "LABEL", "FAMILY", "FORMAT", "TASKS", "GEOMETRIES") + + +@target_app.command("list") +def target_list(json_out: JsonOption = False) -> None: + """List the export targets, by name, with the format each resolves to.""" + rows = wire.export_targets(registry.exporters()) + if json_out: + document(wire.page(rows)) + return + table( + _COLUMNS, + [ + ( + str(row["name"]), + str(row["label"]), + str(row["family"]), + str(row["format"]), + ",".join(row["tasks"]), + ",".join(row["geometries"]), + ) + for row in rows + ], + ) + if not rows: + note("No export targets are installed.") diff --git a/src/visionset/jobs/export.py b/src/visionset/jobs/export.py index 3e023932..37b3c427 100644 --- a/src/visionset/jobs/export.py +++ b/src/visionset/jobs/export.py @@ -36,7 +36,7 @@ from visionset.formats import registry from visionset.jobs.context import workspace_for from visionset.jobs.registry import HandlerRef, register -from visionset.kernel.ports import ProgressReporter +from visionset.kernel.ports import ProgressReporter, resolve_target from visionset.kernel.services import ReleaseService JOB_TYPE = "export.release" @@ -50,16 +50,22 @@ EXPORTS_DIRNAME: Final = "exports" -def payload_for(release_id: UUID, format_name: str, *, allow_lossy: bool) -> dict[str, JsonValue]: +def payload_for( + release_id: UUID, format_name: str, *, target: str | None, allow_lossy: bool +) -> dict[str, JsonValue]: """The payload this handler expects, built where the type is known. - One place names these three keys and the same place reads them — a route + One place names these four keys and the same place reads them — a route spelling them by hand would be free to spell them differently, and the - mismatch would surface as a ``KeyError`` inside a worker. + mismatch would surface as a ``KeyError`` inside a worker. ``format`` is + the resolved format's own name even when the caller addressed a target, + so the worker resolves the same plugin the request was refused or + accepted against. """ return { "release_id": str(release_id), "format": format_name, + "target": target, "allow_lossy": allow_lossy, } @@ -99,6 +105,7 @@ def run( release_id = UUID(str(payload["release_id"])) format_name = str(payload["format"]) + target_name = None if payload.get("target") is None else str(payload["target"]) allow_lossy = bool(payload["allow_lossy"]) workspace = workspace_for(workspace_root) @@ -110,13 +117,15 @@ def run( # ``pick``, never ``exporters()[name]``: a ``KeyError`` is outside the # ``VisionSetError`` tree, and here it would fail a job with a traceback # instead of a sentence naming what is installed. - exporter, _ = registry.pick(registry.exporters(), format_name) + installed = registry.exporters() + exporter, _ = registry.pick(installed, format_name) + target = None if target_name is None else resolve_target(installed, target_name)[1] destination = workspace_root / EXPORTS_DIRNAME / str(release_id) / format_name # Cleared first, because the archive must describe *this* run. shutil.rmtree(destination, ignore_errors=True) result = ReleaseService(workspace).export( - release_id, exporter, destination, allow_lossy=allow_lossy + release_id, exporter, destination, allow_lossy=allow_lossy, target=target ) archive = archive_path(workspace_root, release_id, format_name) @@ -127,6 +136,7 @@ def run( return { "release_id": str(release_id), "format": result.format_name, + "target": result.target, "archive": str(archive.relative_to(workspace_root)), "file_count": result.file_count, "total_bytes": result.total_bytes, diff --git a/src/visionset/kernel/domain/export_target.py b/src/visionset/kernel/domain/export_target.py index 17c899c2..182f3c21 100644 --- a/src/visionset/kernel/domain/export_target.py +++ b/src/visionset/kernel/domain/export_target.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from visionset.kernel.domain.schema import GeometryType +from visionset.kernel.domain.vocabulary import OpenVocabulary TARGET_NAME_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9-]*$") """What a target may be called: a lowercase slug, as typed in a URL or a flag. @@ -30,8 +31,13 @@ """ -class Task(StrEnum): - """A trainer-side task an export target accepts.""" +class Task(OpenVocabulary): + """A trainer-side task an export target accepts. + + Open on the wire because it travels only as a target's task list, which a + client renders member by member: a trainer gaining a task must not cost an + older client the whole catalog. + """ DETECT = "detect" SEGMENT = "segment" diff --git a/src/visionset/kernel/domain/release.py b/src/visionset/kernel/domain/release.py index 99e5e1ae..71786cc3 100644 --- a/src/visionset/kernel/domain/release.py +++ b/src/visionset/kernel/domain/release.py @@ -542,6 +542,11 @@ class ExportCompatibility(BaseModel): serialization_alias="format", validation_alias=AliasChoices("format", "format_name"), ) + #: The target the release was judged for, or ``None`` when it was judged + #: against the format alone. A target can only take away, so a report + #: carrying one may say dropped where the format's own report says carried; + #: the name is what lets a reader of the file tell which question it answers. + target: str | None = None #: Nothing in this release would be dropped **or reduced** by this format's #: capabilities. Still one word for the whole verdict, and still the gate on #: consent: a degraded annotation loses information, so it asks. @@ -617,6 +622,7 @@ class ExportResult(BaseModel): release_id: UUID format_name: str + target: str | None = None #: What the format would drop, worked out before anything was written. #: #: Carried on the result as well as written into ``directory`` because a diff --git a/src/visionset/kernel/services/release_service.py b/src/visionset/kernel/services/release_service.py index 54371ec6..09673866 100644 --- a/src/visionset/kernel/services/release_service.py +++ b/src/visionset/kernel/services/release_service.py @@ -347,9 +347,16 @@ def assignment(self, release_id: UUID) -> SplitAssignment: # --- handing the snapshot to a format plugin --------------------------- - def check_export(self, release_id: UUID, exporter: Exporter) -> ExportCompatibility: + def check_export( + self, release_id: UUID, exporter: Exporter, *, target: ExportTarget | None = None + ) -> ExportCompatibility: """What this format would drop from this release, before anything is written. + ``target`` narrows the question to one trainer: a geometry the format + writes but the target has no task for is reported dropped, and the + report names the target it answers for. Without one the format alone + is judged. + Computed from the **frozen manifest**, never from live membership: an export describes a release, and a release is a snapshot. Two runs against one release therefore agree forever, which is what lets one document be @@ -376,10 +383,15 @@ def check_export(self, release_id: UUID, exporter: Exporter) -> ExportCompatibil """ release = self.get(release_id) manifest = self._read_manifest(release) - return _compatibility(release, manifest, exporter) + return _compatibility(release, manifest, exporter, target) def require_export_consent( - self, release_id: UUID, exporter: Exporter, *, allow_lossy: bool + self, + release_id: UUID, + exporter: Exporter, + *, + allow_lossy: bool, + target: ExportTarget | None = None, ) -> ExportCompatibility: """The compatibility report, or refuse because the caller has not consented. @@ -407,7 +419,7 @@ def require_export_consent( """ release = self.get(release_id) manifest = self._read_manifest(release) - compatibility = _compatibility(release, manifest, exporter) + compatibility = _compatibility(release, manifest, exporter, target) if (exporter.lossy or not compatibility.compatible) and not allow_lossy: raise LossyExportNotConsented( f"format {exporter.format_name!r} cannot carry everything release " @@ -423,9 +435,16 @@ def export( dest: Path, *, allow_lossy: bool = False, + target: ExportTarget | None = None, ) -> ExportResult: """Write this release into ``dest`` in the exporter's format. + ``target`` addresses the export to one trainer. The plugin is handed the + manifest with every annotation the target has no task for removed, so + the output holds exactly what the report says it holds: the port has no + word for a target, and a drop the report promises must not depend on + every plugin reading a declaration it cannot see. + Takes an ``Exporter`` **instance**, never a format name, and that is the one place this service differs from every other read here. Plugins are discovered through an entry-point group that lives in @@ -468,11 +487,13 @@ def export( """ release = self.get(release_id) manifest = self._read_manifest(release) - compatibility = self.require_export_consent(release_id, exporter, allow_lossy=allow_lossy) + compatibility = self.require_export_consent( + release_id, exporter, allow_lossy=allow_lossy, target=target + ) dest.mkdir(parents=True, exist_ok=True) exporter.export( release, - manifest, + manifest if target is None else _addressed_to(manifest, target), dest, content=_content_reader(manifest, self._workspace.blob_store), ) @@ -492,6 +513,7 @@ def export( compatibility=compatibility, release_id=release.id, format_name=exporter.format_name, + target=None if target is None else target.name, directory=dest, file_count=len(written), total_bytes=sum(path.stat().st_size for path in written), @@ -894,6 +916,7 @@ def _compatibility( return ExportCompatibility( release_id=release.id, format_name=exporter.format_name, + target=None if target is None else target.name, # Degraded counts against `compatible` exactly as dropped does: a polygon # arriving as a box has lost its shape, and the caller is asked before # that happens rather than told after. @@ -907,6 +930,30 @@ def _compatibility( ) +def _addressed_to(manifest: Manifest, target: ExportTarget) -> Manifest: + """The manifest with every annotation the target does not carry removed. + + The classes stay: a class index is the frozen schema's, and a target that + drops every polygon of a class still has that class in its vocabulary. + """ + return manifest.model_copy( + update={ + "assets": tuple( + asset.model_copy( + update={ + "annotations": tuple( + one + for one in asset.annotations + if GeometryType(one.geometry.type) in target.supported_geometries + ) + } + ) + for asset in manifest.assets + ) + } + ) + + def _status_of( geometry: GeometryType, exporter: Exporter, target: ExportTarget | None ) -> ClassExportStatus: diff --git a/src/visionset/mcp/formats.py b/src/visionset/mcp/formats.py index d9cc1565..35958e39 100644 --- a/src/visionset/mcp/formats.py +++ b/src/visionset/mcp/formats.py @@ -1,5 +1,5 @@ # usage: from visionset.mcp import formats -"""``list_formats`` — which exporters are installed, and which of them lose things. +"""``list_formats`` and ``list_export_targets`` — what this installation can write. Discovery is over the ``visionset.formats`` entry-point group, so a third-party distribution's exporter is indistinguishable from a built-in here. Nothing is @@ -28,3 +28,21 @@ def list_formats() -> dict[str, Any]: """ installed = exporters() return wire.page([wire.export_format(installed[name]) for name in sorted(installed)]) + + +def list_export_targets() -> dict[str, Any]: + """List the models a release can be exported for, each with the format that writes for it. + + Call this before `export_release` when you know what will be trained — the + `name` here is exactly what that tool's `target` parameter takes, and it + resolves to `format` without you naming it. `list_formats` is the same + installation seen from the format's side. + + `geometries` is what an export addressed to the target carries — never + wider than its format writes, and narrower where the trainer has no task + for a shape. `tasks` is the trainer's own vocabulary and may name tasks + nothing here can feed. `hints` is what the trainer expects of its images: + a recommended size and resize strategy, whether the trainer resizes on its + own, and whether augmentation is the ordinary practice. + """ + return wire.page(wire.export_targets(exporters())) diff --git a/src/visionset/mcp/main.py b/src/visionset/mcp/main.py index fb4d7f2d..ac494a50 100644 --- a/src/visionset/mcp/main.py +++ b/src/visionset/mcp/main.py @@ -127,6 +127,7 @@ (releases.list_releases, READS), (releases.verify_release, READS), (formats.list_formats, READS), + (formats.list_export_targets, READS), (releases.check_export, READS), (releases.export_release, WRITES), # After the cycle, not in it: connections are workspace configuration — diff --git a/src/visionset/mcp/releases.py b/src/visionset/mcp/releases.py index 2b4ff239..e2e010bb 100644 --- a/src/visionset/mcp/releases.py +++ b/src/visionset/mcp/releases.py @@ -35,8 +35,9 @@ from pydantic import Field from visionset import wire -from visionset.formats.registry import exporter -from visionset.kernel.domain import SplitRecipe +from visionset.formats import registry +from visionset.kernel.domain import ExportTarget, SplitRecipe +from visionset.kernel.ports import Exporter, resolve_target from visionset.kernel.services import ProjectService, ReleaseService from visionset.mcp._errors import refused from visionset.mcp._resolve import ProjectRef, resolve_project, resolve_release @@ -119,17 +120,65 @@ def verify_release(project: ProjectRef, tag: TagRef) -> dict[str, Any]: return wire.release_verification(report) +TargetRef = Annotated[ + str | None, + Field( + description=( + "The model the release will train, resolved to the format that writes for it. " + "See `list_export_targets`. Give this or `format`, never both." + ) + ), +] +"""Module-level for the ``inspect.signature`` reason.""" + +FormatRef = Annotated[ + str | None, + Field( + description=( + "An installed exporter's name. See `list_formats`. Give this or `target`, never both." + ) + ), +] +"""Module-level for the ``inspect.signature`` reason.""" + + +def _addressed(target: str | None, format: str | None) -> tuple[Exporter, ExportTarget | None]: + """The plugin one of the two names, and the target when it was a target. + + Through `pick` and `resolve_target` rather than by indexing the registry: a + `KeyError` is outside the VisionSetError tree, so a mistyped name has to + arrive as a refusal that names the installed ones. + + Raises: + ExportFormatNotFound: `format` names nothing installed. + ExportTargetNotFound: `target` names nothing any installed format declares. + ExportTargetConflict: two installed formats declare `target`. + """ + installed = registry.exporters() + if target is not None: + return resolve_target(installed, target) + assert format is not None + return registry.pick(installed, format)[0], None + + def check_export( project: ProjectRef, tag: TagRef, - format: Annotated[str, Field(description="An installed exporter's name. See `list_formats`.")], + target: TargetRef = None, + format: FormatRef = None, ) -> dict[str, Any]: - """Say what a format would drop from a release, without writing anything. + """Say what a target or a format would drop from a release, without writing anything. Call this before `export_release` when the answer matters. It reads the release's frozen manifest and judges every class in it against what the format declares it can write, so the numbers are exact rather than estimated. + Exactly one of `target` and `format`. A target narrows its format to the + geometries its trainer has a task for, so `target="yolov10"` can report a + polygon class `dropped` where `format="ultralytics"` reports it `supported`; + `target` on the report names which question it answers. Both or neither is + refused. + Every class gets a `status`, and there are three of them. `supported` is written as it stands. `dropped` is **not in the output at all** — `excluded_annotations` counts those labels and `excluded_assets` how many @@ -154,20 +203,24 @@ def check_export( attributes, confidence, provenance — and is true of the format forever; this is about the labels this release actually holds. """ + if (target is None) == (format is None): + return refused("give exactly one of target and format") with opened_workspace() as workspace: release = resolve_release(workspace, project, tag) - report = ReleaseService(workspace).check_export(release.id, exporter(format)) + plugin, addressed = _addressed(target, format) + report = ReleaseService(workspace).check_export(release.id, plugin, target=addressed) return wire.export_compatibility(report) def export_release( project: ProjectRef, tag: TagRef, - format: Annotated[str, Field(description="An installed exporter's name. See `list_formats`.")], dest: Annotated[ str, Field(description="An absolute directory path on this machine to write into."), ], + target: TargetRef = None, + format: FormatRef = None, allow_lossy: Annotated[ bool, Field( @@ -178,12 +231,18 @@ def export_release( ), ] = False, ) -> dict[str, Any]: - """Write a release to a local directory in one of the installed formats. + """Write a release to a local directory, for a target or in one of the installed formats. Blocks until the export finishes and returns a description of what landed — the bytes stay on disk, which is the point: whatever trains on this reads the directory, not this call's answer. + Exactly one of `target` and `format`. A target is the model the release will + train — `list_export_targets` names them — and resolves to the format that + writes for it; the export then carries only the geometries that trainer has + a task for, and the report names the target. A format addresses no trainer. + Both or neither is refused. + `dest` is created if it does not exist and is **not emptied first**, so `file_count` and `total_bytes` describe the directory afterwards, which equals what this run wrote only when the directory was fresh. Point separate @@ -208,14 +267,13 @@ def export_release( # bare OSError from inside a plugin rather than as a refusal. if destination.exists() and not destination.is_dir(): return refused(f"dest must be a directory, and {dest} is a file") + if (target is None) == (format is None): + return refused("give exactly one of target and format") with opened_workspace() as workspace: release = resolve_release(workspace, project, tag) - # `pick`, through `exporter()`, rather than indexing the registry: a - # `KeyError` is outside the VisionSetError tree, so a mistyped format name - # has to arrive as a refusal that names the installed ones. - plugin = exporter(format) + plugin, addressed = _addressed(target, format) result = ReleaseService(workspace).export( - release.id, plugin, destination, allow_lossy=allow_lossy + release.id, plugin, destination, allow_lossy=allow_lossy, target=addressed ) return wire.export_result(result) diff --git a/src/visionset/server/models.py b/src/visionset/server/models.py index c3872086..f16b7d06 100644 --- a/src/visionset/server/models.py +++ b/src/visionset/server/models.py @@ -96,6 +96,7 @@ DraftAttribute, DraftLabelClass, ExportCompatibility, + ExportTarget, Geometry, GeometryType, ImageFormat, @@ -116,6 +117,7 @@ PolylineGeometry, Precision, PreLabelRun, + PreprocessingHints, Project, ProjectPreview, ProjectStats, @@ -136,6 +138,7 @@ SplitAssignment, SplitRecipe, SuggestParameter, + Task, VideoProvenance, WeightDownload, WorkspaceSummary, @@ -2118,10 +2121,15 @@ def of(cls, compatibility: ClassCompatibility) -> Self: # ``format_name`` is the wire's word, matching the query parameter a caller just # sent. class ExportCompatibilityOut(BaseModel): - """What one format would drop from one release, worked out before writing.""" + """What one format would drop from one release, worked out before writing. + + `target` names the trainer the release was judged for, and is null when it + was judged against the format alone. + """ release_id: UUID format: str + target: str | None = None compatible: bool format_is_lossy: bool # Dropped only; degraded annotations are counted separately below. @@ -2136,6 +2144,7 @@ def of(cls, compatibility: ExportCompatibility) -> Self: return cls( release_id=compatibility.release_id, format=compatibility.format_name, + target=compatibility.target, compatible=compatibility.compatible, format_is_lossy=compatibility.format_is_lossy, excluded_annotations=compatibility.excluded_annotations, @@ -2184,6 +2193,9 @@ class FormatOut(BaseModel): # `yolov5-yaml` that answer leaves out that a polygon is written at all. degraded_geometries: list[str] = [] modalities: list[str] = [] + # The names of the targets this format writes for. `GET /export-targets` + # carries each one in full. + targets: list[str] = [] @classmethod def of(cls, exporter: Exporter) -> Self: @@ -2193,6 +2205,7 @@ def of(cls, exporter: Exporter) -> Self: geometries=sorted(one.value for one in exporter.supported_geometries), degraded_geometries=sorted(one.value for one in exporter.degraded_geometries), modalities=sorted(exporter.supported_modalities), + targets=sorted(one.name for one in exporter.targets), ) @@ -2200,6 +2213,70 @@ class FormatPage(Page[FormatOut]): """A page of export formats.""" +class PreprocessingHintsOut(BaseModel): + """What a target's trainer expects of its input images. Hints, never requirements. + + `recommended_size` is `[width, height]`. `trainer_resizes` says the trainer + resizes on its own, so resizing beforehand is an optimization rather than a + need; `augmentation_common` says augmentation is the ordinary practice when + training this target. + """ + + recommended_size: tuple[int, int] | None = None + recommended_strategy: str | None = None + trainer_resizes: bool + augmentation_common: bool + + @classmethod + def of(cls, hints: PreprocessingHints) -> Self: + return cls( + recommended_size=hints.recommended_size, + recommended_strategy=( + None if hints.recommended_strategy is None else hints.recommended_strategy.value + ), + trainer_resizes=hints.trainer_resizes, + augmentation_common=hints.augmentation_common, + ) + + +# `family` is a plain string rather than an enum: it is a scalar a client +# groups by, and the open-vocabulary marker is reserved for values that travel +# only as list members. A new family therefore arrives as a new string, never +# as a refused response. +class ExportTargetOut(BaseModel): + """One model a person can train on, and the installed format that writes for it. + + `name` is what `POST /releases/{release_id}/export?target=` takes; `format` + is the format it resolves to, one of `GET /formats`. `tasks` is the trainer's + own vocabulary and may name tasks no geometry here can feed; `geometries` is + what an export addressed to this target carries. + """ + + name: str + label: str + family: str + format: str + tasks: list[Task] = [] + geometries: list[str] = [] + hints: PreprocessingHintsOut + + @classmethod + def of(cls, target: ExportTarget, exporter: Exporter) -> Self: + return cls( + name=target.name, + label=target.label, + family=target.family.value, + format=exporter.format_name, + tasks=sorted(target.tasks), + geometries=sorted(one.value for one in target.supported_geometries), + hints=PreprocessingHintsOut.of(target.hints), + ) + + +class ExportTargetPage(Page[ExportTargetOut]): + """A page of export targets.""" + + # --- inference providers ------------------------------------------------------ diff --git a/src/visionset/server/routes/__init__.py b/src/visionset/server/routes/__init__.py index 11237618..df9cb6ec 100644 --- a/src/visionset/server/routes/__init__.py +++ b/src/visionset/server/routes/__init__.py @@ -64,6 +64,7 @@ releases.project_router, releases.router, formats.router, + formats.targets_router, # Outside the pipeline order too, and for its own reason: a connection is not # a stage of the data's life but a piece of this workspace's configuration, # which the pipeline reads rather than produces. diff --git a/src/visionset/server/routes/formats.py b/src/visionset/server/routes/formats.py index 15141e2a..21044b5e 100644 --- a/src/visionset/server/routes/formats.py +++ b/src/visionset/server/routes/formats.py @@ -1,14 +1,15 @@ # usage: from visionset.server.routes import formats -"""What this deployment can export to. +"""What this deployment can export to: the formats, and the targets they write for. -One route, and it is a listing rather than a line in the documentation, because -the answer is a property of the *installation*: any distribution registering into -the ``visionset.formats`` entry-point group adds a row here, and nothing in this -repository can enumerate what somebody else has installed. +Two routes, both listings rather than lines in the documentation, because the +answer is a property of the *installation*: any distribution registering into +the ``visionset.formats`` entry-point group adds a row to each, and nothing in +this repository can enumerate what somebody else has installed. Not nested under anything. A format is not owned by a project, a dataset or a release — the same set is available to all of them — and hanging the list off one -of those would suggest otherwise. +of those would suggest otherwise. The target catalog is the same set seen from +the trainer's side, flattened so a client renders one control from one read. Handlers are ``def``, not ``async def``, for the reason ``projects.py`` gives. """ @@ -16,16 +17,19 @@ from __future__ import annotations from visionset.server.dependencies import ExportersDep, protected_router -from visionset.server.models import FormatOut, FormatPage +from visionset.server.models import ExportTargetOut, ExportTargetPage, FormatOut, FormatPage router = protected_router(prefix="/formats", tags=["formats"]) +targets_router = protected_router(prefix="/export-targets", tags=["formats"]) @router.get("") def list_formats(exporters: ExportersDep) -> FormatPage: """Every export format installed on this server, by name. - `name` is what `POST /releases/{release_id}/export?format=` takes. + `name` is what `POST /releases/{release_id}/export?format=` takes. `targets` + names the models this format writes for; `GET /export-targets` carries each + one in full. `lossy` says the format cannot carry everything the kernel can represent — some geometry, attribute kind, or per-annotation provenance is dropped. It is @@ -40,3 +44,27 @@ def list_formats(exporters: ExportersDep) -> FormatPage: items=sorted((FormatOut.of(exporter) for exporter in installed), key=lambda out: out.name), total=len(installed), ) + + +@targets_router.get("") +def list_export_targets(exporters: ExportersDep) -> ExportTargetPage: + """Every model this server can export a release for, by name. + + The catalog is derived from the installed formats: each declares the targets + it writes for, and every installed format declares at least one, so nothing + exportable is missing from this list. `name` is what + `POST /releases/{release_id}/export?target=` takes, and `format` is the + installed format that export resolves to. + + `geometries` is what an export addressed to the target carries — never wider + than its format writes, and narrower where the trainer has no task for a + shape. `tasks` is the trainer's own vocabulary and may name tasks nothing + here can feed. `hints` is what the trainer expects of its images, for a + client that offers to prepare them. + """ + rows = [ + ExportTargetOut.of(target, exporter) + for exporter in exporters.values() + for target in exporter.targets + ] + return ExportTargetPage(items=sorted(rows, key=lambda out: out.name), total=len(rows)) diff --git a/src/visionset/server/routes/releases.py b/src/visionset/server/routes/releases.py index 0c488ce6..e05b520a 100644 --- a/src/visionset/server/routes/releases.py +++ b/src/visionset/server/routes/releases.py @@ -25,16 +25,19 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Annotated, Any, Final from uuid import UUID -from fastapi import Query, Response, status +from fastapi import Depends, Query, Response, status +from fastapi.exceptions import RequestValidationError from fastapi.responses import StreamingResponse from visionset.formats.registry import pick from visionset.jobs.export import JOB_TYPE as export_job_type from visionset.jobs.export import payload_for as export_payload_for -from visionset.kernel.domain import BackgroundJobSpec +from visionset.kernel.domain import BackgroundJobSpec, ExportTarget +from visionset.kernel.ports import Exporter, resolve_target from visionset.kernel.services import ReleaseService from visionset.server.dependencies import ( ExportersDep, @@ -67,11 +70,49 @@ } } +TargetQuery = Annotated[ + str | None, + Query(description="An export target's name. `GET /export-targets` lists them."), +] + FormatQuery = Annotated[ - str, - Query(description="Which installed format to write. `GET /formats` lists them."), + str | None, + Query(description="An installed format's name. `GET /formats` lists them."), ] + +@dataclass(frozen=True, slots=True) +class ExportAddress: + """Which trainer or which format an export is for. Exactly one of the two.""" + + target: str | None + format: str | None + + +def _address(target: TargetQuery = None, format: FormatQuery = None) -> ExportAddress: + """The pair, refused as a 422 ``VALIDATION_ERROR`` unless exactly one is given. + + A dependency rather than two loose parameters on each route, so the rule + between them is stated once and refused in the shape every other malformed + request answers with — ``loc`` is ``["query"]`` because neither name alone + is the mistake. + """ + if (target is None) == (format is None): + raise RequestValidationError( + [ + { + "type": "value_error", + "loc": ("query",), + "msg": "give exactly one of target and format", + "input": {"target": target, "format": format}, + } + ] + ) + return ExportAddress(target=target, format=format) + + +AddressQuery = Annotated[ExportAddress, Depends(_address)] + #: A gate, so it is a query parameter and the route never pre-checks it — the #: flag goes to the service and the kernel's own refusal carries the code. A #: third word beside `confirm` and `allow_destructive` because it guards a third @@ -221,15 +262,25 @@ def check_export( workspace: WorkspaceDep, exporters: ExportersDep, release_id: UUID, - format: FormatQuery, + address: AddressQuery, ) -> ExportCompatibilityOut: - """Say what the named format would drop from this release, without writing anything. + """Say what the named target or format would drop from this release, without writing anything. The pre-flight for `POST /releases/{release_id}/export`: same release, same - format name, same document the export refuses with and writes into its own + address, same document the export refuses with and writes into its own output. A client showing a consent dialog asks this first; one that would rather find out by being refused does not have to. + Exactly one of `target` and `format`. A target narrows its format to the + geometries its trainer has a task for, so a report for `target=yolov10` + can say `dropped` where one for `format=ultralytics` says `supported`; + `target` on the report says which question it answers. An unknown target is + 404 `EXPORT_TARGET_NOT_FOUND`, an unknown format 404 `EXPORT_FORMAT_NOT_FOUND`, + an unknown release 404 `RELEASE_NOT_FOUND`. A target two installed formats + both declare is 500 `EXPORT_TARGET_CONFLICT`, and a release whose manifest + blob is gone is 500 `WORKSPACE_CORRUPT`; neither is something the request + can fix. + `compatible` is the answer. It is not the same question as the format's `lossy` flag, which `GET /formats` publishes: that is the format's blanket statement about everything a capability list cannot see, while this is about @@ -239,11 +290,32 @@ def check_export( A GET because it writes nothing and answers the same thing every time — a release is immutable, so this response is as stable as the release is. """ + exporter, target = _addressed(exporters, address) return ExportCompatibilityOut.of( - ReleaseService(workspace).check_export(release_id, pick(exporters, format)[0]) + ReleaseService(workspace).check_export(release_id, exporter, target=target) ) +def _addressed( + exporters: dict[str, Exporter], address: ExportAddress +) -> tuple[Exporter, ExportTarget | None]: + """The exporter an address names, and the target when it named one. + + Through ``pick`` and ``resolve_target`` rather than by indexing: a + ``KeyError`` is outside the ``VisionSetError`` tree and would answer 500 to + a caller who mistyped a name. + + Raises: + ExportFormatNotFound: ``format`` names nothing installed. + ExportTargetNotFound: ``target`` names nothing any installed format declares. + ExportTargetConflict: two installed formats declare ``target``. + """ + if address.target is not None: + return resolve_target(exporters, address.target) + assert address.format is not None + return pick(exporters, address.format)[0], None + + @router.post( "/{release_id}/export", status_code=status.HTTP_202_ACCEPTED, @@ -255,7 +327,7 @@ def export_release( runner: RunnerDep, response: Response, release_id: UUID, - format: FormatQuery, + address: AddressQuery, allow_lossy: AllowLossyQuery = False, ) -> BackgroundJobOut: """Queue the release for writing, and answer at once with the job to poll. @@ -269,32 +341,45 @@ def export_release( `GET /background-jobs/{id}` — the `Location` header names it — until `state` is `succeeded`, then `GET /background-jobs/{id}/artifact` for the archive. - **Everything a caller can be told now is still told now.** Which formats - exist is a property of this deployment — `GET /formats` lists what is - installed — and an unknown name is 404 `EXPORT_FORMAT_NOT_FOUND` on this + **Exactly one of `target` and `format`.** A target is the model the + release will train — `GET /export-targets` lists them — and resolves to + the format that writes for it; a format addresses no trainer. Both or + neither is a 422 `VALIDATION_ERROR`. An export addressed to a target + carries only the geometries its trainer has a task for, and the report it + writes names the target. + + **Everything a caller can be told now is still told now.** Which targets + and formats exist is a property of this deployment, and an unknown name is + 404 `EXPORT_TARGET_NOT_FOUND` or 404 `EXPORT_FORMAT_NOT_FOUND` on this request. A format that cannot carry everything the release holds is 409 `LOSSY_EXPORT_NOT_CONSENTED` on this request too, and retrying is the identical call plus `allow_lossy=true`. An unknown release is 404 - `RELEASE_NOT_FOUND`. None of the three creates a job, so a caller holding a - job id holds one that will run. + `RELEASE_NOT_FOUND`. None of these creates a job, so a caller holding a + job id holds one that will run. A target two installed formats both + declare is 500 `EXPORT_TARGET_CONFLICT`, and a release whose manifest blob + is gone is 500 `WORKSPACE_CORRUPT`. A POST because it does work and writes files, though it changes nothing a later read can see: the release is immutable, and re-exporting overwrites the previous archive. """ - # ``pick`` rather than ``exporters[format]``: a ``KeyError`` is outside the - # ``VisionSetError`` tree and would answer 500 to a caller who mistyped a - # format name. One wording for the refusal, and it lives in the registry. - exporter, _ = pick(exporters, format) + exporter, target = _addressed(exporters, address) # Synchronously, before the job exists: a refusal a request can make is a # refusal the request makes. Discovering the consent gate in a # worker would put a 409 on a row somebody has to go and read. The worker # checks again; that one is the guarantee, this one is the answer. - ReleaseService(workspace).require_export_consent(release_id, exporter, allow_lossy=allow_lossy) + ReleaseService(workspace).require_export_consent( + release_id, exporter, allow_lossy=allow_lossy, target=target + ) job = workspace.job_queue.enqueue( BackgroundJobSpec( type=export_job_type, - payload=export_payload_for(release_id, format, allow_lossy=allow_lossy), + payload=export_payload_for( + release_id, + exporter.format_name, + target=None if target is None else target.name, + allow_lossy=allow_lossy, + ), idempotent=True, ) ) diff --git a/src/visionset/wire/__init__.py b/src/visionset/wire/__init__.py index 5ef42803..1683995b 100644 --- a/src/visionset/wire/__init__.py +++ b/src/visionset/wire/__init__.py @@ -83,6 +83,7 @@ DraftLabelClass, ExportCompatibility, ExportResult, + ExportTarget, Geometry, InferenceConnection, IngestFailure, @@ -92,6 +93,7 @@ PolygonGeometry, PolylineGeometry, PreLabelRun, + PreprocessingHints, Project, ProjectPreview, Release, @@ -701,9 +703,54 @@ def export_format(value: Exporter) -> dict[str, Any]: # polygon is written. "degraded_geometries": sorted(one.value for one in value.degraded_geometries), "modalities": sorted(value.supported_modalities), + "targets": sorted(one.name for one in value.targets), } +def preprocessing_hints(value: PreprocessingHints) -> dict[str, Any]: + """What a target's trainer expects of its images. Hints, never requirements.""" + return { + "recommended_size": None + if value.recommended_size is None + else list(value.recommended_size), + "recommended_strategy": ( + None if value.recommended_strategy is None else value.recommended_strategy.value + ), + "trainer_resizes": value.trainer_resizes, + "augmentation_common": value.augmentation_common, + } + + +def export_target(value: ExportTarget, exporter: Exporter) -> dict[str, Any]: + """One model a person can train on, flattened with the format that writes for it. + + ``format`` is the exporter's own name rather than a nested format row, so + the catalog answers "which format does this target resolve to" in one read + and a surface never has to join two listings. + """ + return { + "name": value.name, + "label": value.label, + "family": value.family.value, + "format": exporter.format_name, + "tasks": sorted(one.value for one in value.tasks), + "geometries": sorted(one.value for one in value.supported_geometries), + "hints": preprocessing_hints(value.hints), + } + + +def export_targets(installed: Mapping[str, Exporter]) -> list[dict[str, Any]]: + """The whole catalog, in name order, derived from what is installed.""" + return sorted( + ( + export_target(target, exporter) + for exporter in installed.values() + for target in exporter.targets + ), + key=lambda row: str(row["name"]), + ) + + def class_compatibility(value: ClassCompatibility) -> dict[str, Any]: """One class of a release, judged against one format.""" return { @@ -727,6 +774,7 @@ def export_compatibility(value: ExportCompatibility) -> dict[str, Any]: return { "release_id": str(value.release_id), "format": value.format_name, + "target": value.target, "compatible": value.compatible, "format_is_lossy": value.format_is_lossy, "excluded_annotations": value.excluded_annotations, @@ -747,6 +795,7 @@ def export_result(value: ExportResult) -> dict[str, Any]: return { "release_id": str(value.release_id), "format": value.format_name, + "target": value.target, "directory": str(value.directory), "file_count": value.file_count, "total_bytes": value.total_bytes, diff --git a/tests/cli/test_export_commands.py b/tests/cli/test_export_commands.py index 7ddf5f94..4c2942b6 100644 --- a/tests/cli/test_export_commands.py +++ b/tests/cli/test_export_commands.py @@ -25,12 +25,14 @@ published_release, run, started_batch, + usage_error, workspace, ) from typer.testing import CliRunner from visionset.cli.main import app from visionset.formats import registry +from visionset.formats._targets import self_target from visionset.kernel.domain import ( Annotation, BboxGeometry, @@ -60,6 +62,7 @@ class LossyExporter: supported_geometries = frozenset(GeometryType) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -155,6 +158,7 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": ["polyline"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["bdd100k-lane"], }, { # The one format whose content is tags: a box has a location it @@ -165,6 +169,7 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": ["classification_tag"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["classification"], }, { # Lossless: boxes and polygons are native, and everything COCO @@ -174,6 +179,7 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": ["bbox", "polygon"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["coco"], }, { "name": "culane", @@ -181,6 +187,7 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": ["polyline"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["culane"], }, { "name": "curvelanes", @@ -188,6 +195,7 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": ["polyline"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["curvelanes"], }, { "name": "dummy", @@ -204,6 +212,7 @@ def test_format_list_json_is_the_envelope() -> None: ], "degraded_geometries": [], "modalities": ["image", "point_cloud", "video"], + "targets": ["dummy"], }, { "name": "openlane-2d", @@ -211,6 +220,7 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": ["polyline"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["openlane-2d"], }, { # The one lane format that does not write the vertices it was given. @@ -219,6 +229,7 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": [], "degraded_geometries": ["polyline"], "modalities": ["image"], + "targets": ["tusimple"], }, { # Lossy because a label row is a class index and coordinates: @@ -230,6 +241,17 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": ["bbox", "classification_tag", "polygon"], "degraded_geometries": [], "modalities": ["image"], + "targets": [ + "yolo11", + "yolo12", + "yolo26", + "yolov10", + "yolov3", + "yolov5", + "yolov6", + "yolov8", + "yolov9", + ], }, { # Lossy for a different reason: a VOC `` has a fixed set @@ -240,6 +262,7 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": ["bbox"], "degraded_geometries": ["polygon"], "modalities": ["image"], + "targets": ["voc"], }, { # Detection only, so a polygon is reduced to its box. @@ -248,6 +271,7 @@ def test_format_list_json_is_the_envelope() -> None: "geometries": ["bbox"], "degraded_geometries": ["polygon"], "modalities": ["image"], + "targets": ["yolov7"], }, ] @@ -383,6 +407,7 @@ class BoxesOnlyExporter: supported_geometries = frozenset({GeometryType.CLASSIFICATION_TAG}) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -654,3 +679,176 @@ def test_the_refusal_names_the_flag_a_person_types( # …and it names the one command that answers the question the refusal raises # and cannot itself answer. assert "--check" in result.stderr + + +# --- addressing a target ----------------------------------------------------- + + +def test_an_export_can_be_addressed_to_a_target(root: Path, tmp_path: Path) -> None: + """The self-target of a format is the format, so the run is the same export by another name.""" + name = published_release(root, tmp_path) + out = tmp_path / "out" + + result = run( + root, "export", "-p", name, "--release", "v1.0", "--target", "dummy", "--out", str(out) + ) + + assert result.exit_code == 0, result.output + assert result.stdout.strip() == str(out) + written = json.loads((out / EXPORT_REPORT_FILENAME).read_text(encoding="utf-8")) + assert (written["format"], written["target"]) == ("dummy", "dummy") + + +def test_target_json_carries_the_target_beside_the_format(root: Path, tmp_path: Path) -> None: + name = published_release(root, tmp_path) + + result = run( + root, + "export", + "-p", + name, + "--release", + "v1.0", + "--target", + "dummy", + "--out", + str(tmp_path / "out"), + "--json", + ) + + assert result.exit_code == 0, result.output + document = json.loads(result.stdout) + assert (document["format"], document["target"]) == ("dummy", "dummy") + assert document["compatibility"]["target"] == "dummy" + + +@pytest.mark.parametrize( + "address", + [(), ("--target", "dummy", "--format", "dummy")], + ids=["neither", "both"], +) +def test_target_and_format_are_one_choice_at_exit_two( + root: Path, tmp_path: Path, address: tuple[str, ...] +) -> None: + name = published_release(root, tmp_path) + + result = run( + root, "export", "-p", name, "--release", "v1.0", *address, "--out", str(tmp_path / "out") + ) + + assert result.exit_code == 2, result.output + assert "Give exactly one of --target and --format." in usage_error(result) + + +def test_an_unknown_target_exits_one_naming_what_is_installed(root: Path, tmp_path: Path) -> None: + name = published_release(root, tmp_path) + + result = run( + root, + "export", + "-p", + name, + "--release", + "v1.0", + "--target", + "yolo99", + "--out", + str(tmp_path / "out"), + ) + + assert result.exit_code == 1, result.output + assert "yolo11" in result.stderr + + +def test_the_former_format_name_still_works_and_says_it_is_going( + root: Path, tmp_path: Path +) -> None: + name = published_release(root, tmp_path) + + result = run( + root, + "export", + "-p", + name, + "--release", + "v1.0", + "--format", + "yolo", + "--allow-lossy", + "--out", + str(tmp_path / "out"), + ) + + assert result.exit_code == 0, result.output + assert "--format yolo is deprecated" in result.stderr + assert "--format ultralytics" in result.stderr + assert result.stdout.strip() == str(tmp_path / "out") + assert (tmp_path / "out" / "data.yaml").is_file() + + +def test_the_current_format_name_prints_no_deprecation(root: Path, tmp_path: Path) -> None: + name = published_release(root, tmp_path) + + result = run( + root, + "export", + "-p", + name, + "--release", + "v1.0", + "--format", + "ultralytics", + "--allow-lossy", + "--out", + str(tmp_path / "out"), + ) + + assert result.exit_code == 0, result.output + assert "deprecated" not in result.stderr + + +# --- target list --------------------------------------------------------------- + + +def test_target_list_names_every_target_with_the_format_it_resolves_to() -> None: + result = CliRunner().invoke(app, ["target", "list"]) + + assert result.exit_code == 0, result.output + lines = result.stdout.splitlines() + assert lines[0].split() == ["NAME", "LABEL", "FAMILY", "FORMAT", "TASKS", "GEOMETRIES"] + rows = {line.split()[0]: line.split() for line in lines[1:]} + assert rows["yolo11"][1:4] == ["YOLO11", "ultralytics-yolo", "ultralytics"] + assert rows["yolov7"][3] == "yolov5-yaml" + assert rows["coco"][1:4] == ["coco", "other", "coco"] + + +def test_target_list_needs_no_workspace_at_all( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke(app, ["target", "list"]) + assert result.exit_code == 0, result.output + + +def test_target_list_json_is_the_catalog_the_other_surfaces_publish() -> None: + result = CliRunner().invoke(app, ["target", "list", "--json"]) + + assert result.exit_code == 0, result.output + document = json.loads(result.stdout) + assert document["total"] == len(document["items"]) + rows = {row["name"]: row for row in document["items"]} + assert rows["yolo11"] == { + "name": "yolo11", + "label": "YOLO11", + "family": "ultralytics-yolo", + "format": "ultralytics", + "tasks": ["classify", "detect", "obb", "pose", "segment"], + "geometries": ["bbox", "classification_tag", "polygon"], + "hints": { + "recommended_size": [640, 640], + "recommended_strategy": "letterbox", + "trainer_resizes": True, + "augmentation_common": True, + }, + } + assert [row["name"] for row in document["items"]] == sorted(rows) diff --git a/tests/cli/test_json_contract.py b/tests/cli/test_json_contract.py index f51b148c..4b881ae4 100644 --- a/tests/cli/test_json_contract.py +++ b/tests/cli/test_json_contract.py @@ -61,6 +61,7 @@ from visionset import wire from visionset.formats._dummy import DummyExporter +from visionset.formats.ultralytics import UltralyticsExporter from visionset.inference import PreLabelExcludedClass, PreLabelExclusionReason, PreLabelPlan from visionset.kernel.domain import ( AnnotationSummary, @@ -181,6 +182,23 @@ models.ReleaseVerificationOut, ), ("export_format", wire.export_format(DummyExporter()), models.FormatOut), + ( + "export_target", + wire.export_target(next(iter(DummyExporter().targets)), DummyExporter()), + models.ExportTargetOut, + ), + # Both halves of the hints: the self-target's, where nothing is recommended, + # and a trainer's, where a size and a strategy are. + ( + "preprocessing_hints_empty", + wire.preprocessing_hints(next(iter(DummyExporter().targets)).hints), + models.PreprocessingHintsOut, + ), + ( + "preprocessing_hints_populated", + wire.preprocessing_hints(next(iter(UltralyticsExporter().targets)).hints), + models.PreprocessingHintsOut, + ), # The compatibility report is published by all three surfaces, so it is gated like every # other shared shape — and the on-disk copy is checked against the wire # projection in `tests/kernel/test_release_service.py`, which closes the loop. diff --git a/tests/formats/test_target_catalog_doc.py b/tests/formats/test_target_catalog_doc.py new file mode 100644 index 00000000..068906ee --- /dev/null +++ b/tests/formats/test_target_catalog_doc.py @@ -0,0 +1,62 @@ +"""The export-target table in `docs/content/releases.md` is generated, and this keeps it so. + +The `tests/mcp/test_tool_reference.py` argument: the CI step that regenerates +and diffs is the gate, and duplicating it as a test is deliberate, because the +mistake is made during `uv run pytest` and that is where it should surface. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +from visionset.formats.registry import exporters + +SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "export_target_catalog.py" + + +@pytest.fixture(scope="module") +def script() -> ModuleType: + spec = importlib.util.spec_from_file_location("export_target_catalog", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_the_committed_table_matches_the_installed_catalog(script: ModuleType) -> None: + document = script.DOCUMENT_PATH.read_text(encoding="utf-8") + _, current, _ = script._split(document) + assert current == script.render() + + +def test_every_installed_target_has_a_row(script: ModuleType) -> None: + rendered = script.render() + for exporter in exporters().values(): + for target in exporter.targets: + assert f"| `{target.name}` |" in rendered, target.name + + +def test_the_check_mode_reports_a_stale_table( + script: ModuleType, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + stale = tmp_path / "releases.md" + stale.write_text(f"before\n{script.BEGIN}\n| old |\n{script.END}\nafter\n", encoding="utf-8") + monkeypatch.setattr(script, "DOCUMENT_PATH", stale) + monkeypatch.setattr(script, "REPO_ROOT", tmp_path) + + assert script.main(["--check"]) == 1 + assert "stale" in capsys.readouterr().err + + assert script.main([]) == 0 + assert script.main(["--check"]) == 0 + written = stale.read_text(encoding="utf-8") + assert written.startswith("before\n") and written.endswith("\nafter\n") diff --git a/tests/jobs/test_process_pool.py b/tests/jobs/test_process_pool.py index 03433c99..d93aa8bb 100644 --- a/tests/jobs/test_process_pool.py +++ b/tests/jobs/test_process_pool.py @@ -63,7 +63,7 @@ def test_a_job_crosses_into_a_spawned_worker_and_reports_back( job = workspace.job_queue.enqueue( BackgroundJobSpec( type=JOB_TYPE, - payload=payload_for(uuid4(), "dummy", allow_lossy=False), + payload=payload_for(uuid4(), "dummy", target=None, allow_lossy=False), idempotent=True, ) ) diff --git a/tests/kernel/test_release_service.py b/tests/kernel/test_release_service.py index 0f604d05..f4aac2d0 100644 --- a/tests/kernel/test_release_service.py +++ b/tests/kernel/test_release_service.py @@ -1382,7 +1382,11 @@ def test_a_target_carrying_everything_the_format_writes_changes_nothing(tmp_path release, manifest = _mixed_manifest(fixture) everything = _narrow_target(frozenset({GeometryType.BBOX, GeometryType.POLYGON})) - assert _compatibility(release, manifest, _BoxesAndPolygons(), everything) == _compatibility( + narrowed = _compatibility(release, manifest, _BoxesAndPolygons(), everything) + + # The verdict is the format's; only the name of the question changes. + assert narrowed.target == "narrow" + assert narrowed.model_copy(update={"target": None}) == _compatibility( release, manifest, _BoxesAndPolygons() ) fixture.close() @@ -1410,3 +1414,84 @@ def test_a_declared_geometry_no_annotation_can_carry_is_never_a_row(tmp_path: Pa ("lane", GeometryType.POLYGON), } fixture.close() + + +class _Narrowable(_BoxesAndPolygons): + """Keeps the manifest it was handed, so a test can read what the plugin saw.""" + + format_name = "narrowable" + + def __init__(self) -> None: + self.seen: Manifest | None = None + + def export( + self, + release: Release, + manifest: Manifest, + dest: Path, + *, + content: ContentReader, + ) -> None: + self.seen = manifest + + +def test_an_export_addressed_to_a_target_hands_the_plugin_only_what_it_carries( + tmp_path: Path, +) -> None: + """The port has no word for a target, so the drop the report promises is made here.""" + fixture = Fixture(tmp_path) + release = fixture.releases.publish(_mixed(fixture), "v1") + plugin = _Narrowable() + + result = fixture.releases.export( + release.id, + plugin, + tmp_path / "out", + allow_lossy=True, + target=_narrow_target(frozenset({GeometryType.BBOX})), + ) + + assert plugin.seen is not None + assert all( + isinstance(one.geometry, BboxGeometry) + for asset in plugin.seen.assets + for one in asset.annotations + ) + # The vocabulary is untouched: a class index is the frozen schema's. + assert [one.name for one in plugin.seen.classes] == ["sign", "lane"] + assert result.target == "narrow" + assert result.compatibility.target == "narrow" + (lane,) = result.compatibility.excluded + assert lane.label_class == "lane" + fixture.close() + + +def test_an_export_by_format_alone_hands_the_plugin_the_whole_manifest(tmp_path: Path) -> None: + fixture = Fixture(tmp_path) + release = fixture.releases.publish(_mixed(fixture), "v1") + plugin = _Narrowable() + + result = fixture.releases.export(release.id, plugin, tmp_path / "out") + + assert plugin.seen == fixture.releases.manifest(release.id) + assert result.target is None + assert result.compatibility.target is None + fixture.close() + + +def test_the_report_on_disk_names_the_target_it_answers_for(tmp_path: Path) -> None: + fixture = Fixture(tmp_path) + release = fixture.releases.publish(_mixed(fixture), "v1") + dest = tmp_path / "out" + + fixture.releases.export( + release.id, + _BoxesAndPolygons(), + dest, + allow_lossy=True, + target=_narrow_target(frozenset({GeometryType.BBOX})), + ) + + written = json.loads((dest / EXPORT_REPORT_FILENAME).read_text(encoding="utf-8")) + assert (written["format"], written["target"]) == ("boxes-and-polygons", "narrow") + fixture.close() diff --git a/tests/mcp/test_registration.py b/tests/mcp/test_registration.py index 28ea581a..f14bb645 100644 --- a/tests/mcp/test_registration.py +++ b/tests/mcp/test_registration.py @@ -66,6 +66,7 @@ "verify_release", "check_export", "export_release", + "list_export_targets", "list_formats", "list_inference_connections", "model_download_size", diff --git a/tests/mcp/test_release_tools.py b/tests/mcp/test_release_tools.py index e2ec29d8..b4e14b23 100644 --- a/tests/mcp/test_release_tools.py +++ b/tests/mcp/test_release_tools.py @@ -206,6 +206,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": ["polyline"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["bdd100k-lane"], }, { # The one format whose content is tags: a box has a location it @@ -216,6 +217,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": ["classification_tag"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["classification"], }, { # Lossless: boxes and polygons are native, and everything @@ -225,6 +227,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": ["bbox", "polygon"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["coco"], }, { "name": "culane", @@ -232,6 +235,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": ["polyline"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["culane"], }, { "name": "curvelanes", @@ -239,6 +243,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": ["polyline"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["curvelanes"], }, { "name": "dummy", @@ -248,6 +253,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": sorted(one.value for one in GeometryType), "degraded_geometries": [], "modalities": ["image", "point_cloud", "video"], + "targets": ["dummy"], }, { "name": "openlane-2d", @@ -255,6 +261,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": ["polyline"], "degraded_geometries": [], "modalities": ["image"], + "targets": ["openlane-2d"], }, { # The one lane format that does not write the vertices it @@ -266,6 +273,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": [], "degraded_geometries": ["polyline"], "modalities": ["image"], + "targets": ["tusimple"], }, { # Lossy because a label row is a class index and coordinates, @@ -275,6 +283,17 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": ["bbox", "classification_tag", "polygon"], "degraded_geometries": [], "modalities": ["image"], + "targets": [ + "yolo11", + "yolo12", + "yolo26", + "yolov10", + "yolov3", + "yolov5", + "yolov6", + "yolov8", + "yolov9", + ], }, { "name": "voc", @@ -282,6 +301,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": ["bbox"], "degraded_geometries": ["polygon"], "modalities": ["image"], + "targets": ["voc"], }, { # Detection only, so a polygon is reduced to its box. @@ -290,6 +310,7 @@ def test_the_installed_exporters_declare_what_they_can_carry() -> None: "geometries": ["bbox"], "degraded_geometries": ["polygon"], "modalities": ["image"], + "targets": ["yolov7"], }, ], "total": 11, @@ -313,6 +334,90 @@ def test_export_writes_into_the_directory_it_was_given( assert result["file_count"] == 0 +def test_an_export_can_be_addressed_to_a_target( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The self-target of a format is the format, so this is the same export by another name.""" + named = promoted(monkeypatch, tmp_path, count=1) + payload(call("publish_release", project=named, tag="v1.0")) + dest = tmp_path / "exports" / "dummy" + + result = payload( + call("export_release", project=named, tag="v1.0", target="dummy", dest=str(dest)) + ) + + assert (result["format"], result["target"]) == ("dummy", "dummy") + assert result["compatibility"]["target"] == "dummy" + written = json.loads((dest / EXPORT_REPORT_FILENAME).read_text(encoding="utf-8")) + assert written["target"] == "dummy" + + +@pytest.mark.parametrize("tool", ["check_export", "export_release"]) +@pytest.mark.parametrize( + "address", [{}, {"target": "dummy", "format": "dummy"}], ids=["neither", "both"] +) +def test_target_and_format_are_one_choice( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, tool: str, address: dict[str, str] +) -> None: + named = promoted(monkeypatch, tmp_path, count=1) + payload(call("publish_release", project=named, tag="v1.0")) + extra = {"dest": str(tmp_path / "out")} if tool == "export_release" else {} + + refusal = error(call(tool, project=named, tag="v1.0", **address, **extra)) + + assert refusal["message"] == "give exactly one of target and format" + assert not (tmp_path / "out").exists() + + +def test_an_unknown_target_names_the_ones_that_are_installed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + named = promoted(monkeypatch, tmp_path, count=1) + payload(call("publish_release", project=named, tag="v1.0")) + + refusal = error( + call("export_release", project=named, tag="v1.0", target="yolo99", dest=str(tmp_path)) + ) + + assert "yolo11" in refusal["message"] + + +def test_check_export_by_target_names_the_target_on_the_report( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + named = promoted(monkeypatch, tmp_path, count=1) + payload(call("publish_release", project=named, tag="v1.0")) + + report = payload(call("check_export", project=named, tag="v1.0", target="dummy")) + + assert (report["format"], report["target"]) == ("dummy", "dummy") + + +def test_the_catalog_is_the_one_the_other_surfaces_publish() -> None: + document = payload(call("list_export_targets")) + + assert document["total"] == len(document["items"]) + rows = {row["name"]: row for row in document["items"]} + assert rows["yolo11"] == { + "name": "yolo11", + "label": "YOLO11", + "family": "ultralytics-yolo", + "format": "ultralytics", + "tasks": ["classify", "detect", "obb", "pose", "segment"], + "geometries": ["bbox", "classification_tag", "polygon"], + "hints": { + "recommended_size": [640, 640], + "recommended_strategy": "letterbox", + "trainer_resizes": True, + "augmentation_common": True, + }, + } + assert rows["yolov7"]["format"] == "yolov5-yaml" + # Every installed format is reachable through the catalog. + formats = {row["name"] for row in payload(call("list_formats"))["items"]} + assert {row["format"] for row in rows.values()} == formats + + def test_an_unknown_format_names_the_ones_that_are_installed( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/server/_exports.py b/tests/server/_exports.py index d980013d..de55169b 100644 --- a/tests/server/_exports.py +++ b/tests/server/_exports.py @@ -23,7 +23,17 @@ from fastapi import FastAPI from visionset.formats import registry -from visionset.kernel.domain import GeometryType, Manifest, Release +from visionset.formats._targets import self_target +from visionset.kernel.domain import ( + ExportTarget, + GeometryType, + Manifest, + PreprocessingHints, + Release, + ResizeStrategy, + TargetFamily, + Task, +) from visionset.kernel.ports import ContentReader, Exporter from visionset.server.dependencies import get_exporters @@ -49,6 +59,7 @@ class WritingExporter: supported_geometries = frozenset(GeometryType) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -77,6 +88,7 @@ class LossyExporter: supported_geometries = frozenset(GeometryType) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -139,6 +151,7 @@ class BoxesOnlyExporter: supported_geometries = frozenset({GeometryType.BBOX}) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -160,6 +173,7 @@ class PolygonsOnlyExporter: supported_geometries = frozenset({GeometryType.POLYGON}) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) + targets = self_target(format_name, supported_geometries) def export( self, @@ -170,3 +184,48 @@ def export( content: ContentReader, ) -> None: (dest / "polygons.txt").write_text(str(len(manifest.assets))) + + +class TargetedExporter: + """Writes boxes and polygons, and declares one trainer that takes only polygons. + + The pair a target-addressed export exists for: the *format* carries the + release's boxes whole, so a drop reported under `target=polygon-trainer` is + the target's doing and nothing else's. The count it writes is what the + plugin was handed, which is how a test sees the narrowing over HTTP. + """ + + format_name = "targeted" + lossy = False + + supported_geometries = frozenset({GeometryType.BBOX, GeometryType.POLYGON}) + degraded_geometries: frozenset[GeometryType] = frozenset() + supported_modalities = frozenset({"image"}) + targets = frozenset( + { + ExportTarget( + name="polygon-trainer", + label="Polygon trainer", + family=TargetFamily.COMMUNITY_YOLO, + tasks=frozenset({Task.SEGMENT}), + supported_geometries=frozenset({GeometryType.POLYGON}), + hints=PreprocessingHints( + recommended_size=(640, 640), + recommended_strategy=ResizeStrategy.STRETCH, + trainer_resizes=True, + augmentation_common=True, + ), + ) + } + ) + + def export( + self, + release: Release, + manifest: Manifest, + dest: Path, + *, + content: ContentReader, + ) -> None: + handed = sum(len(asset.annotations) for asset in manifest.assets) + (dest / "annotations.txt").write_text(str(handed)) diff --git a/tests/server/test_formats.py b/tests/server/test_formats.py index 0fb8d356..701ad49c 100644 --- a/tests/server/test_formats.py +++ b/tests/server/test_formats.py @@ -76,6 +76,9 @@ def test_the_listing_uses_the_envelope_like_every_other_collection( # left out that a polygon is written at all. "degraded_geometries": [], "modalities": ["image"], + # A format that is its own target: `GET /export-targets` + # carries the row in full. + "targets": ["writing"], } ], "total": 1, @@ -85,3 +88,60 @@ def test_the_listing_uses_the_envelope_like_every_other_collection( def test_the_listing_is_protected(client: TestClient) -> None: with TestClient(client.app) as anonymous: assert anonymous.get("/formats").status_code == 401 + + +# --- the target catalog -------------------------------------------------------- + + +def test_the_catalog_flattens_every_target_with_the_format_that_writes_for_it( + client: TestClient, +) -> None: + with_exporters(client.app, WritingExporter(), LossyExporter()) + + body = client.get("/export-targets").json() + + assert [row["name"] for row in body["items"]] == ["lossy", "writing"] + assert body["total"] == 2 + lossy, writing = body["items"] + assert lossy == { + "name": "lossy", + "label": "lossy", + "family": "other", + "format": "lossy", + "tasks": [], + "geometries": sorted(one.value for one in GeometryType), + "hints": { + "recommended_size": None, + "recommended_strategy": None, + "trainer_resizes": True, + "augmentation_common": False, + }, + } + assert writing["format"] == "writing" + + +def test_the_shipped_catalog_names_every_yolo_target_and_the_dialect_each_resolves_to( + client: TestClient, +) -> None: + """No override: the real entry-point scan, so the catalog is the one a deployment serves.""" + rows = {row["name"]: row for row in client.get("/export-targets").json()["items"]} + + assert rows["yolo11"]["format"] == "ultralytics" + assert rows["yolo11"]["family"] == "ultralytics-yolo" + assert rows["yolo11"]["tasks"] == ["classify", "detect", "obb", "pose", "segment"] + assert rows["yolo11"]["geometries"] == ["bbox", "classification_tag", "polygon"] + assert rows["yolo11"]["hints"] == { + "recommended_size": [640, 640], + "recommended_strategy": "letterbox", + "trainer_resizes": True, + "augmentation_common": True, + } + assert rows["yolov7"]["format"] == "yolov5-yaml" + # Every installed format is reachable through the catalog. + formats = {row["name"] for row in client.get("/formats").json()["items"]} + assert {row["format"] for row in rows.values()} == formats + + +def test_the_catalog_is_protected(client: TestClient) -> None: + with TestClient(client.app) as anonymous: + assert anonymous.get("/export-targets").status_code == 401 diff --git a/tests/server/test_openapi_contract.py b/tests/server/test_openapi_contract.py index ffd0cbf1..92129230 100644 --- a/tests/server/test_openapi_contract.py +++ b/tests/server/test_openapi_contract.py @@ -385,11 +385,11 @@ def test_a_vocabulary_is_open_exactly_when_its_shape_allows_it() -> None: assert wrong == [], "\n".join(wrong) -def test_the_open_set_is_the_seven_the_client_was_generated_for() -> None: +def test_the_open_set_is_the_eight_the_client_was_generated_for() -> None: """The roster, so growing the set is a decision somebody makes on purpose. The gate above derives membership from shape and would stay green if the - contract grew a seventh. This one makes that arrive as a decision here, in the + contract grew a ninth. This one makes that arrive as a decision here, in the same review as the ``openapi.json`` diff and the widened union it produces in the generated client. """ @@ -407,4 +407,5 @@ def test_the_open_set_is_the_seven_the_client_was_generated_for() -> None: "ModelCapability", "PreLabelExclusionReason", "SuggestParameter", + "Task", } diff --git a/tests/server/test_releases.py b/tests/server/test_releases.py index ed359cde..c4b00b82 100644 --- a/tests/server/test_releases.py +++ b/tests/server/test_releases.py @@ -15,6 +15,7 @@ import hashlib import io +import json import zipfile from collections.abc import Iterator from pathlib import Path @@ -28,6 +29,7 @@ BoxesOnlyExporter, LossyExporter, PolygonsOnlyExporter, + TargetedExporter, WritingExporter, reset_exporters, with_exporters, @@ -433,6 +435,92 @@ def test_exporting_streams_back_an_archive_of_what_the_plugin_wrote( } +def test_an_export_can_be_addressed_to_a_target_instead_of_a_format( + client: TestClient, release: str +) -> None: + """The self-target of a format is the format, so this is the same export by another name.""" + with_exporters(client.app, WritingExporter()) + + response = exported(client, release, target="writing") + + assert _names_in(response.content) == { + "manifest.json", + "images/listing.txt", + EXPORT_REPORT_FILENAME, + } + + +def test_both_target_and_format_is_422_and_so_is_neither(client: TestClient, release: str) -> None: + with_exporters(client.app, WritingExporter()) + + both = client.post( + f"/releases/{release}/export", params={"format": "writing", "target": "writing"} + ) + neither = client.post(f"/releases/{release}/export") + + for response in (both, neither): + assert response.status_code == 422, response.text + body = response.json() + assert body["code"] == "VALIDATION_ERROR" + (error,) = body["detail"]["errors"] + assert (error["type"], error["loc"]) == ("value_error", ["query"]) + assert error["msg"] == "give exactly one of target and format" + assert both.json()["detail"]["errors"][0]["input"] == {"target": "writing", "format": "writing"} + assert neither.json()["detail"]["errors"][0]["input"] == {"target": None, "format": None} + + +def test_an_unknown_target_is_404_and_names_what_is_installed( + client: TestClient, release: str +) -> None: + with_exporters(client.app, WritingExporter()) + + response = client.post(f"/releases/{release}/export", params={"target": "yolo99"}) + + assert response.status_code == 404 + assert response.json()["code"] == "EXPORT_TARGET_NOT_FOUND" + assert "writing" in response.json()["message"] + + +def test_a_target_narrows_its_format_and_the_plugin_is_handed_only_what_it_carries( + client: TestClient, release: str +) -> None: + """The release holds boxes; the trainer takes polygons; the format could write both.""" + with_exporters(client.app, TargetedExporter()) + + refused = client.post(f"/releases/{release}/export", params={"target": "polygon-trainer"}) + assert refused.status_code == 409 + assert refused.json()["code"] == "LOSSY_EXPORT_NOT_CONSENTED" + report = refused.json()["detail"]["compatibility"] + assert (report["format"], report["target"]) == ("targeted", "polygon-trainer") + (sign,) = [one for one in report["classes"] if one["status"] == "dropped"] + assert sign["reason"] == "Polygon trainer does not accept a bbox, so the export drops it" + + # By format alone the same release is carried whole. + whole = client.get(f"/releases/{release}/export-compatibility", params={"format": "targeted"}) + assert whole.json()["compatible"] is True + assert whole.json()["target"] is None + + consented = exported(client, release, target="polygon-trainer", allow_lossy="true") + with zipfile.ZipFile(io.BytesIO(consented.content)) as archive: + assert archive.read("annotations.txt") == b"0" + written = json.loads(archive.read(EXPORT_REPORT_FILENAME)) + assert written["target"] == "polygon-trainer" + + +def test_the_job_carries_the_target_and_the_resolved_format( + client: TestClient, release: str +) -> None: + with_exporters(client.app, TargetedExporter()) + + launched = client.post( + f"/releases/{release}/export", params={"target": "polygon-trainer", "allow_lossy": "true"} + ) + settled = client.get(f"/background-jobs/{launched.json()['id']}").json() + + assert settled["result"]["format"] == "targeted" + assert settled["result"]["target"] == "polygon-trainer" + + def test_an_unknown_format_is_404_and_names_what_is_installed( client: TestClient, release: str ) -> None: