From ad1d5af560cafe6fa9e5cff9b6f0414737a91db2 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:21:40 -0700 Subject: [PATCH 1/9] feat(kernel): pre-processing recipes as a named project resource A recipe is stored under a slug unique per project, in a table created whole by migration 17, and read back as the value an export will snapshot. The service refuses an unknown name and a taken name with their own errors, mapped on the server and listed in the API reference. Part of the pre-processing epic (#785). --- docs/content/api.md | 4 +- docs/content/persistence.md | 9 +- src/visionset/kernel/__init__.py | 4 + src/visionset/kernel/adapters/_mappers.py | 22 ++ src/visionset/kernel/adapters/_tables.py | 27 +++ src/visionset/kernel/adapters/migrations.py | 15 ++ .../kernel/adapters/sqlite_metadata_store.py | 1 + src/visionset/kernel/domain/preprocessing.py | 5 +- src/visionset/kernel/errors.py | 18 ++ src/visionset/kernel/ports/metadata_store.py | 11 + src/visionset/kernel/services/__init__.py | 2 + .../services/preprocessing_recipe_service.py | 221 ++++++++++++++++++ src/visionset/server/errors.py | 4 + tests/kernel/test_preprocessing.py | 4 +- .../test_preprocessing_recipe_service.py | 209 +++++++++++++++++ tests/kernel/test_schema_draft_service.py | 4 +- tests/server/test_errors.py | 2 + 17 files changed, 551 insertions(+), 11 deletions(-) create mode 100644 src/visionset/kernel/services/preprocessing_recipe_service.py create mode 100644 tests/kernel/test_preprocessing_recipe_service.py diff --git a/docs/content/api.md b/docs/content/api.md index 7195bca5..f097c97d 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -609,9 +609,9 @@ argument for branching on `code`. | Status | Codes | | --- | --- | | **401** | `UNAUTHORIZED` — with a `WWW-Authenticate: Bearer` challenge | -| **404** | `PROJECT_NOT_FOUND` · `SCHEMA_NOT_FOUND` · `SCHEMA_DRAFT_NOT_FOUND` · `BATCH_NOT_FOUND` · `JOB_NOT_FOUND` · `INGEST_JOB_NOT_FOUND` · `BACKGROUND_JOB_NOT_FOUND` · `ASSET_NOT_FOUND` · `SOURCE_NOT_FOUND` · `DATASET_NOT_FOUND` · `ANNOTATION_NOT_FOUND` · `RELEASE_NOT_FOUND` · `TOKEN_NOT_FOUND` · `INFERENCE_CONNECTION_NOT_FOUND` · `ASSET_NOT_IN_JOB` · `ASSET_NOT_IN_DATASET` · `NO_SPLIT_RECIPE` · `EXPORT_FORMAT_NOT_FOUND` · `EXPORT_TARGET_NOT_FOUND` · `THUMBNAIL_NOT_CACHED` · `NOT_FOUND` (no such route) | +| **404** | `PROJECT_NOT_FOUND` · `SCHEMA_NOT_FOUND` · `SCHEMA_DRAFT_NOT_FOUND` · `BATCH_NOT_FOUND` · `JOB_NOT_FOUND` · `INGEST_JOB_NOT_FOUND` · `BACKGROUND_JOB_NOT_FOUND` · `ASSET_NOT_FOUND` · `SOURCE_NOT_FOUND` · `DATASET_NOT_FOUND` · `ANNOTATION_NOT_FOUND` · `RELEASE_NOT_FOUND` · `TOKEN_NOT_FOUND` · `INFERENCE_CONNECTION_NOT_FOUND` · `ASSET_NOT_IN_JOB` · `ASSET_NOT_IN_DATASET` · `NO_SPLIT_RECIPE` · `EXPORT_FORMAT_NOT_FOUND` · `EXPORT_TARGET_NOT_FOUND` · `THUMBNAIL_NOT_CACHED` · `PREPROCESSING_RECIPE_NOT_FOUND` · `NOT_FOUND` (no such route) | | **405** | `METHOD_NOT_ALLOWED` | -| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `TOKEN_NAME_TAKEN` · `INFERENCE_CONNECTION_NAME_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_IMMUTABLE` · `BATCH_NOT_IN_ANNOTATION` · `ASSET_NOT_WRITABLE` · `JOB_FINISHED` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `SCHEMA_HAS_NO_DETECTABLE_CLASS` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` · `EXPORT_SOURCE_UNREADABLE` · `INFERENCE_CONNECTION_NOT_DOWNLOADABLE` · `INFERENCE_CONNECTION_NOT_CHECKABLE` · `INFERENCE_CONNECTION_NOT_TESTABLE` · `INFERENCE_CONNECTION_MODEL_FIXED` · `WEIGHTS_DAMAGED` · `INFERENCE_CONNECTION_NOT_SET_UP` · `AUGMENTATION_REQUIRES_SPLIT` · `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY` | +| **409** | `PROJECT_NAME_TAKEN` · `RELEASE_TAG_TAKEN` · `TOKEN_NAME_TAKEN` · `INFERENCE_CONNECTION_NAME_TAKEN` · `PREPROCESSING_RECIPE_NAME_TAKEN` · `WORKSPACE_ALREADY_EXISTS` · `WORKSPACE_NOT_EMPTY` · `SCHEMA_VERSION_CONFLICT` · `INVALID_TRANSITION` · `STALE_WRITE` · `BATCH_NOT_EDITABLE` · `BATCH_IMMUTABLE` · `BATCH_NOT_IN_ANNOTATION` · `ASSET_NOT_WRITABLE` · `JOB_FINISHED` · `BATCH_NOT_COMPLETE` · `JOB_NOT_COMPLETE` · `EMPTY_BATCH` · `EMPTY_RELEASE` · `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA` · `CONFIRMATION_REQUIRED` · `DESTRUCTIVE_SCHEMA_CHANGE` · `SCHEMA_CHANGE_WOULD_ORPHAN` · `SCHEMA_HAS_NO_DETECTABLE_CLASS` · `UNSERIALIZABLE_MANIFEST` · `LOSSY_EXPORT_NOT_CONSENTED` · `EXPORT_SOURCE_UNREADABLE` · `INFERENCE_CONNECTION_NOT_DOWNLOADABLE` · `INFERENCE_CONNECTION_NOT_CHECKABLE` · `INFERENCE_CONNECTION_NOT_TESTABLE` · `INFERENCE_CONNECTION_MODEL_FIXED` · `WEIGHTS_DAMAGED` · `INFERENCE_CONNECTION_NOT_SET_UP` · `AUGMENTATION_REQUIRES_SPLIT` · `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY` | | **422** | `VALIDATION_ERROR` · `ASSET_NOT_IN_BATCH` · `ANNOTATION_NOT_FROM_MODEL` · `INVALID_NAME` · `INFERENCE_CONNECTION_INVALID` · `INVALID_SCHEMA` · `UNSUPPORTED_GEOMETRY` · `INVALID_ANNOTATION` · `LABEL_CLASS_NOT_IN_SCHEMA` · `DISALLOWED_GEOMETRY` · `ANNOTATION_GEOMETRY_OUT_OF_BOUNDS` · `DUPLICATE_CLASSIFICATION_TAG` · `MISSING_REQUIRED_ATTRIBUTE` · `UNKNOWN_ATTRIBUTE` · `INVALID_ATTRIBUTE_VALUE` · `INVALID_PARTITION` · `UNKNOWN_JOB_TYPE` · `MEDIA_ERROR` · `UNSUPPORTED_MEDIA` · `CORRUPT_MEDIA` · `UNSUPPORTED_PROMPT` · `PROMPT_POINT_OUT_OF_BOUNDS` · `GEOMETRY_NOT_PRODUCED` | | **502** | `INFERENCE_ENDPOINT_UNAVAILABLE` | | **503** | `WORKSPACE_BUSY` | diff --git a/docs/content/persistence.md b/docs/content/persistence.md index c40d8ebe..79f363ba 100644 --- a/docs/content/persistence.md +++ b/docs/content/persistence.md @@ -169,11 +169,12 @@ MIGRATIONS: list[Migration] = [ Migration(version=14, name="project_created_at", upgrade=_add_project_created_at), Migration(version=15, name="connection_origin", upgrade=_add_connection_origin), Migration(version=16, name="source_clip_ranges", upgrade=_reshape_source_origin_index), + Migration(version=17, name="preprocessing_recipes", upgrade=_add_preprocessing_recipes), ] -FORMAT_VERSION: int = MIGRATIONS[-1].version # 16 +FORMAT_VERSION: int = MIGRATIONS[-1].version # 17 ``` -**Generation 1 is the baseline, and the fifteen entries after it are ordinary migrations.** A long +**Generation 1 is the baseline, and the sixteen entries after it are ordinary migrations.** A long chain of generations got this schema to its present shape while VisionSet was unreleased. Every database they could have upgraded was disposable test data inside this repository, so what they actually bought was an idempotency argument and an undo line per generation, plus @@ -186,7 +187,7 @@ force again for every entry appended after the baseline. `tests/kernel/test_migrations.py` that builds an old-looking file. The failure is the silent kind: a column left in place makes its own migration find the column already there and return early, so `test_a_fresh_database_and_a_migrated_one_have_the_same_schema` compares a file -against itself and passes while proving nothing. The table-creating migrations - 4, 6 and 10 - +against itself and passes while proving nothing. The table-creating migrations - 4, 6, 10 and 17 - are the standing exception: dropping a whole table in the helper would exercise SQLite rather than this module. @@ -210,6 +211,8 @@ docstring says which, so a reader does not mistake an honest absence for a forgo Migration 16 adds no column at all: it reshapes the source-origin index to carry the canonical-ranges term, and backfills nothing — a row written before ranges existed has no `$.ranges` key, which the new index reads as the same `''` a whole-clip selection stores. +Migration 17 creates the `preprocessing_recipes` table whole and backfills nothing: no recipe +existed before it, and an export that applied none is what every earlier export was. **There are no downgrade paths, deliberately.** Nothing walks a file backwards and the tests no longer do either. A downgrade is a compatibility promise and a promise is owed diff --git a/src/visionset/kernel/__init__.py b/src/visionset/kernel/__init__.py index a6b0b01d..26ba1e7d 100644 --- a/src/visionset/kernel/__init__.py +++ b/src/visionset/kernel/__init__.py @@ -69,6 +69,8 @@ NoSplitRecipe, NotAWorkspace, PreprocessingDriverNotFound, + PreprocessingRecipeNameTaken, + PreprocessingRecipeNotFound, PreprocessingStepUnsupportedGeometry, ProjectNameTaken, ProjectNotFound, @@ -154,6 +156,8 @@ "NoSplitRecipe", "NotAWorkspace", "PreprocessingDriverNotFound", + "PreprocessingRecipeNameTaken", + "PreprocessingRecipeNotFound", "PreprocessingStepUnsupportedGeometry", "ProjectNameTaken", "ProjectNotFound", diff --git a/src/visionset/kernel/adapters/_mappers.py b/src/visionset/kernel/adapters/_mappers.py index 0b7cbcfe..33f4d801 100644 --- a/src/visionset/kernel/adapters/_mappers.py +++ b/src/visionset/kernel/adapters/_mappers.py @@ -62,6 +62,7 @@ IngestState, ItemFailure, LabelClass, + PreprocessingRecipe, Project, Release, SchemaDraft, @@ -525,6 +526,21 @@ def _release_to_row(entity: Release) -> t.Base: ) +def _recipe_to_row(entity: PreprocessingRecipe) -> t.Base: + return t.PreprocessingRecipeRow( + id=entity.id, + project_id=entity.project_id, + name=entity.name, + spec=entity.spec.model_dump(mode="json"), + created_at=entity.created_at.isoformat(), + updated_at=entity.updated_at.isoformat(), + ) + + +def _recipe_to_domain(_: Session, row: Any) -> PreprocessingRecipe: + return PreprocessingRecipe.model_validate(_columns(row)) + + def _release_to_domain(_: Session, row: Any) -> Release: return Release( id=row.id, @@ -733,6 +749,12 @@ def _job_write_children(session: Session, entity: AnnotationJob, *, inserting: b to_row=_token_to_row, to_domain=_token_to_domain, ) +PREPROCESSING_RECIPES: EntityMapping[PreprocessingRecipe] = EntityMapping( + row=t.PreprocessingRecipeRow, + parent_column="project_id", + to_row=_recipe_to_row, + to_domain=_recipe_to_domain, +) RELEASES: EntityMapping[Release] = EntityMapping( row=t.ReleaseRow, parent_column="dataset_id", diff --git a/src/visionset/kernel/adapters/_tables.py b/src/visionset/kernel/adapters/_tables.py index c1bcafb3..e231594b 100644 --- a/src/visionset/kernel/adapters/_tables.py +++ b/src/visionset/kernel/adapters/_tables.py @@ -622,6 +622,33 @@ class ReleaseRow(Base): visionset_version: Mapped[str] = mapped_column(String, nullable=False) +class PreprocessingRecipeRow(Base): + """A named pre-processing recipe of one project: a ``RecipeSpec`` under a name. + + The spec is one JSON value, the way a release keeps its split recipe: the + domain model validates it, every field of it is read together, and nothing + queries inside it. The unique constraint is the name rule — one name per + project — and it is what refuses the loser of a create race the service's + pre-check let through. ``ON DELETE CASCADE`` for ``AnnotationSchemaRow``'s + reason: a recipe is meaningless without the project it belongs to. + """ + + __tablename__ = "preprocessing_recipes" + __table_args__ = ( + UniqueConstraint("project_id", "name", name="uq_preprocessing_recipe_project_name"), + ) + + id: Mapped[UUID] = mapped_column(SaUuid, primary_key=True) + project_id: Mapped[UUID] = mapped_column( + SaUuid, ForeignKey("project.id", ondelete="CASCADE"), index=True, nullable=False + ) + name: Mapped[str] = mapped_column(String, nullable=False) + spec: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + #: ISO-8601 with offset, never SQLite ``DATETIME``. See the module docstring. + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + class TokenRow(Base): """API credentials, hashed. diff --git a/src/visionset/kernel/adapters/migrations.py b/src/visionset/kernel/adapters/migrations.py index b1065645..0376e7b0 100644 --- a/src/visionset/kernel/adapters/migrations.py +++ b/src/visionset/kernel/adapters/migrations.py @@ -387,6 +387,20 @@ def _reshape_source_origin_index(connection: Connection) -> None: connection.execute(CreateIndex(SOURCE_ORIGIN_UNIQUE, if_not_exists=True)) +def _add_preprocessing_recipes(connection: Connection) -> None: + """``preprocessing_recipes``: the named recipes a project's exports can apply. + + Migration 4's kind — a table created whole, ``create_all`` restricted to it + for migration 6's reason — so the rule about a key column never arriving by + ``ALTER`` does not come up. + + **Nothing to backfill.** Before this table no recipe existed anywhere, and + an export that applied none is exactly what every earlier export was, so an + existing workspace starts with an empty table and loses nothing. + """ + Base.metadata.create_all(connection, tables=[Base.metadata.tables["preprocessing_recipes"]]) + + MIGRATIONS: list[Migration] = [ Migration(version=1, name="baseline_schema", upgrade=_create_baseline_schema), Migration(version=2, name="batch_lineage", upgrade=_add_batch_lineage), @@ -404,6 +418,7 @@ def _reshape_source_origin_index(connection: Connection) -> None: Migration(version=14, name="project_created_at", upgrade=_add_project_created_at), Migration(version=15, name="connection_origin", upgrade=_add_connection_origin), Migration(version=16, name="source_clip_ranges", upgrade=_reshape_source_origin_index), + Migration(version=17, name="preprocessing_recipes", upgrade=_add_preprocessing_recipes), ] FORMAT_VERSION: int = MIGRATIONS[-1].version diff --git a/src/visionset/kernel/adapters/sqlite_metadata_store.py b/src/visionset/kernel/adapters/sqlite_metadata_store.py index 948380c1..c28eb33b 100644 --- a/src/visionset/kernel/adapters/sqlite_metadata_store.py +++ b/src/visionset/kernel/adapters/sqlite_metadata_store.py @@ -368,6 +368,7 @@ def __init__(self, session: Session) -> None: self.dataset_members = SqlRepository(session, m.DATASET_MEMBERS) self.dataset_changes = SqlRepository(session, m.DATASET_CHANGES) self.releases = SqlRepository(session, m.RELEASES) + self.preprocessing_recipes = SqlRepository(session, m.PREPROCESSING_RECIPES) self.tokens = SqlRepository(session, m.TOKENS) self.jobs = SqlRepository(session, m.BACKGROUND_JOBS) self.inference_connections = SqlRepository(session, m.INFERENCE_CONNECTIONS) diff --git a/src/visionset/kernel/domain/preprocessing.py b/src/visionset/kernel/domain/preprocessing.py index 9d64bb04..87c7ca5a 100644 --- a/src/visionset/kernel/domain/preprocessing.py +++ b/src/visionset/kernel/domain/preprocessing.py @@ -20,6 +20,7 @@ from datetime import datetime from enum import StrEnum from typing import Annotated, Literal +from uuid import UUID, uuid4 from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -122,8 +123,8 @@ class PreprocessingRecipe(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") - id: str - project_id: str + id: UUID = Field(default_factory=uuid4) + project_id: UUID name: str spec: RecipeSpec created_at: datetime diff --git a/src/visionset/kernel/errors.py b/src/visionset/kernel/errors.py index 3ae3a8e7..c594fc86 100644 --- a/src/visionset/kernel/errors.py +++ b/src/visionset/kernel/errors.py @@ -1375,3 +1375,21 @@ def __init__(self, message: str, *, installed: tuple[str, ...] | None = None) -> super().__init__(message) if installed is not None: self.installed = installed + + +class PreprocessingRecipeNotFound(VisionSetError): + """The project has no pre-processing recipe under that name. + + Names are unique per project and compared exactly, so this is the one + answer to a name nobody created, a name deleted since, and a name that + belongs to another project. + """ + + +class PreprocessingRecipeNameTaken(VisionSetError): + """Another recipe of this project already carries that name. + + The ``ReleaseTagTaken`` rule for a recipe: checked before writing so the + caller gets a sentence, and refused by a unique index so a race cannot slip + past the check. Renaming onto a taken name is the same refusal. + """ diff --git a/src/visionset/kernel/ports/metadata_store.py b/src/visionset/kernel/ports/metadata_store.py index 920b2e83..23e230aa 100644 --- a/src/visionset/kernel/ports/metadata_store.py +++ b/src/visionset/kernel/ports/metadata_store.py @@ -29,6 +29,7 @@ InferenceConnection, IngestJob, OrphanGuard, + PreprocessingRecipe, Project, Release, SchemaDraft, @@ -145,6 +146,16 @@ def dataset_changes(self) -> Repository[DatasetChange]: ... @property def releases(self) -> Repository[Release]: ... + @property + def preprocessing_recipes(self) -> Repository[PreprocessingRecipe]: + """A project's named pre-processing recipes, parented on the project. + + ``list(project_id)`` is the read a name lookup goes through — a project + holds a handful of recipes, and a query by name on the port would be the + query language the port does not have. + """ + ... + @property def jobs(self) -> Repository[BackgroundJob]: """The background executor's queue. diff --git a/src/visionset/kernel/services/__init__.py b/src/visionset/kernel/services/__init__.py index cb92aef4..230feb5c 100644 --- a/src/visionset/kernel/services/__init__.py +++ b/src/visionset/kernel/services/__init__.py @@ -13,6 +13,7 @@ from visionset.kernel.services.inference_connection_service import InferenceConnectionService from visionset.kernel.services.ingest_service import IngestService from visionset.kernel.services.job_service import JobService +from visionset.kernel.services.preprocessing_recipe_service import PreprocessingRecipeService from visionset.kernel.services.project_service import ProjectService from visionset.kernel.services.release_service import EXPORT_REPORT_FILENAME, ReleaseService from visionset.kernel.services.schema_draft_service import SchemaDraftService @@ -39,6 +40,7 @@ "InferenceConnectionService", "IngestService", "JobService", + "PreprocessingRecipeService", "ProjectService", "ReleaseService", "SchemaDraftService", diff --git a/src/visionset/kernel/services/preprocessing_recipe_service.py b/src/visionset/kernel/services/preprocessing_recipe_service.py new file mode 100644 index 00000000..cca9706b --- /dev/null +++ b/src/visionset/kernel/services/preprocessing_recipe_service.py @@ -0,0 +1,221 @@ +# usage: from visionset.kernel.services import PreprocessingRecipeService +"""Pre-processing recipes: the named, editable resource an export snapshots. + +A recipe is a project resource with no state of its own — no lifecycle, no +``allowed_actions`` — because nothing depends on it once an export has run: the +export keeps the spec by value, so editing or deleting the recipe afterwards +changes no artifact anybody already produced. That is what lets every write +here be unconditional where a schema draft's has to name a revision. + +The name is the identifier. It is a path segment on the REST surface and an +argument on the command line, so it is held to a slug rather than to the loose +rule a project name follows, and it is compared exactly and refused twice on +collision — the ``ReleaseService`` shape: the service checks before writing so +the caller gets a sentence, and ``uq_preprocessing_recipe_project_name`` +refuses the loser of a race the check let through. + +Composition follows the rule in ``docs/content/workspaces.md``: this service +takes an open :class:`WorkspaceService` and nothing else. +""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime +from typing import Final +from uuid import UUID + +from visionset.kernel.domain import PreprocessingRecipe, Project, RecipeSpec, normalize_name +from visionset.kernel.errors import ( + ConstraintViolated, + InvalidName, + PreprocessingRecipeNameTaken, + PreprocessingRecipeNotFound, + ProjectNotFound, +) +from visionset.kernel.ports import UnitOfWork +from visionset.kernel.services.dataset_service import DatasetService +from visionset.kernel.services.release_service import ReleaseService +from visionset.kernel.services.workspace_service import WorkspaceService + +#: What a recipe name may look like: a slug, because it travels as a path +#: segment and a command-line argument and is compared exactly. +_SLUG: Final = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") + +#: How SQLite words the name index's refusal — matched exactly, the +#: ``ReleaseService`` precedent, so another constraint is never mistaken for it. +_NAME_INDEX_MESSAGE: Final = "preprocessing_recipes.project_id, preprocessing_recipes.name" + + +class PreprocessingRecipeService: + """Create, read, edit and delete the recipes of one project.""" + + def __init__(self, workspace: WorkspaceService) -> None: + self._workspace = workspace + self._datasets = DatasetService(workspace) + self._releases = ReleaseService(workspace) + + def create(self, project_id: UUID, name: str, spec: RecipeSpec) -> PreprocessingRecipe: + """Store a new recipe under ``name``. + + Raises: + ProjectNotFound: no such project in this workspace. + InvalidName: the name is not a slug. + PreprocessingRecipeNameTaken: the project already has a recipe of + that name. + """ + cleaned = _slug(name) + now = datetime.now(UTC) + try: + with self._workspace.unit_of_work() as uow: + self._require_project(uow, project_id) + self._require_name_free(uow, project_id, cleaned) + return uow.preprocessing_recipes.add( + PreprocessingRecipe( + project_id=project_id, + name=cleaned, + spec=spec, + created_at=now, + updated_at=now, + ) + ) + except ConstraintViolated as exc: + raise _as_name_collision(exc, cleaned) from exc + + def get(self, project_id: UUID, name: str) -> PreprocessingRecipe: + """The project's recipe under that name. + + Raises: + ProjectNotFound: no such project in this workspace. + PreprocessingRecipeNotFound: the project has no recipe of that name. + """ + with self._workspace.unit_of_work() as uow: + self._require_project(uow, project_id) + return self._require(uow, project_id, name) + + def for_release(self, release_id: UUID, name: str) -> PreprocessingRecipe: + """The recipe a release's own project stores under that name. + + The export surfaces address a release, not a project, and a recipe + belongs to the project the release's dataset hangs off. Resolved here + so the three surfaces agree on the walk rather than each spelling it. + + Raises: + ReleaseNotFound: no such release in this workspace. + PreprocessingRecipeNotFound: the release's project has no recipe of + that name. + """ + release = self._releases.get(release_id) + with self._workspace.unit_of_work() as uow: + dataset = self._datasets.require_dataset(uow, release.dataset_id) + return self._require(uow, dataset.project_id, name) + + def update( + self, project_id: UUID, name: str, *, spec: RecipeSpec, new_name: str | None = None + ) -> PreprocessingRecipe: + """Replace the recipe's spec, and rename it when ``new_name`` differs. + + Whole-value, like a schema draft: the spec is one value with + cross-field rules, and a field-at-a-time edit would need a merge rule. + No revision is asked for, because nothing downstream depends on the + stored value — an export keeps its own copy. + + Raises: + ProjectNotFound: no such project in this workspace. + PreprocessingRecipeNotFound: the project has no recipe of that name. + InvalidName: ``new_name`` is not a slug. + PreprocessingRecipeNameTaken: ``new_name`` belongs to another recipe + of the project. + """ + renamed = None if new_name is None else _slug(new_name) + try: + with self._workspace.unit_of_work() as uow: + self._require_project(uow, project_id) + stored = self._require(uow, project_id, name) + if renamed is not None and renamed != stored.name: + self._require_name_free(uow, project_id, renamed) + return uow.preprocessing_recipes.update( + stored.model_copy( + update={ + "name": stored.name if renamed is None else renamed, + "spec": spec, + "updated_at": datetime.now(UTC), + } + ) + ) + except ConstraintViolated as exc: + raise _as_name_collision(exc, renamed or name) from exc + + def delete(self, project_id: UUID, name: str) -> PreprocessingRecipe: + """Remove the recipe, and answer what was removed. + + No ``confirm=``: a recipe is a few lines of configuration, and every + export that used it kept its own copy, so nothing that exists is lost. + + Raises: + ProjectNotFound: no such project in this workspace. + PreprocessingRecipeNotFound: the project has no recipe of that name. + """ + with self._workspace.unit_of_work() as uow: + self._require_project(uow, project_id) + stored = self._require(uow, project_id, name) + uow.preprocessing_recipes.delete(stored.id) + return stored + + def list(self, project_id: UUID) -> list[PreprocessingRecipe]: + """Every recipe of the project, oldest first. + + Last in the class on purpose: an annotation after a method named + ``list`` would resolve it to this method rather than to the builtin. + + Raises: + ProjectNotFound: no such project in this workspace. + """ + with self._workspace.unit_of_work() as uow: + self._require_project(uow, project_id) + return uow.preprocessing_recipes.list(project_id) + + def _require_project(self, uow: UnitOfWork, project_id: UUID) -> Project: + """The project, or refuse because this workspace does not have it.""" + project = uow.projects.get(project_id) + if project is None or project.workspace_id != self._workspace.workspace_id: + raise ProjectNotFound( + f"no project {project_id} in workspace {self._workspace.workspace.name!r}" + ) + return project + + def _require(self, uow: UnitOfWork, project_id: UUID, name: str) -> PreprocessingRecipe: + for recipe in uow.preprocessing_recipes.list(project_id): + if recipe.name == name: + return recipe + raise PreprocessingRecipeNotFound( + f"project {project_id} has no pre-processing recipe named {name!r}; " + f"list the project's recipes to see which exist" + ) + + def _require_name_free(self, uow: UnitOfWork, project_id: UUID, name: str) -> None: + if any(recipe.name == name for recipe in uow.preprocessing_recipes.list(project_id)): + raise PreprocessingRecipeNameTaken( + f"project {project_id} already has a pre-processing recipe named {name!r}; " + f"choose another name or update that one" + ) + + +def _slug(name: str) -> str: + cleaned = normalize_name(name, what="recipe") + if not _SLUG.match(cleaned): + raise InvalidName( + f"{cleaned!r} is not a recipe name: use lowercase letters, digits, dots, " + f"hyphens and underscores, starting with a letter or digit, at most 64 characters" + ) + return cleaned + + +def _as_name_collision( + exc: ConstraintViolated, name: str +) -> PreprocessingRecipeNameTaken | ConstraintViolated: + if _NAME_INDEX_MESSAGE in str(exc): + return PreprocessingRecipeNameTaken( + f"another writer created a recipe named {name!r} first; choose another name" + ) + return exc diff --git a/src/visionset/server/errors.py b/src/visionset/server/errors.py index 21af5299..d3e93c94 100644 --- a/src/visionset/server/errors.py +++ b/src/visionset/server/errors.py @@ -106,6 +106,8 @@ NoSplitRecipe, NotAWorkspace, PreprocessingDriverNotFound, + PreprocessingRecipeNameTaken, + PreprocessingRecipeNotFound, PreprocessingStepUnsupportedGeometry, ProjectNameTaken, ProjectNotFound, @@ -257,11 +259,13 @@ class ErrorRule: # A 404 rather than an empty 200 because the caller asked for a specific # thing that is not there, and because the remedy is real — a backfill. ThumbnailNotCached: ErrorRule(404, "THUMBNAIL_NOT_CACHED"), + PreprocessingRecipeNotFound: ErrorRule(404, "PREPROCESSING_RECIPE_NOT_FOUND"), # --- 409: well-formed request, the resource's state refuses it --------- ProjectNameTaken: ErrorRule(409, "PROJECT_NAME_TAKEN"), ReleaseTagTaken: ErrorRule(409, "RELEASE_TAG_TAKEN"), TokenNameTaken: ErrorRule(409, "TOKEN_NAME_TAKEN"), InferenceConnectionNameTaken: ErrorRule(409, "INFERENCE_CONNECTION_NAME_TAKEN"), + PreprocessingRecipeNameTaken: ErrorRule(409, "PREPROCESSING_RECIPE_NAME_TAKEN"), WorkspaceAlreadyExists: ErrorRule(409, "WORKSPACE_ALREADY_EXISTS"), WorkspaceNotEmpty: ErrorRule(409, "WORKSPACE_NOT_EMPTY"), # Retryable, but immediately rather than after a wait — a re-read lands on diff --git a/tests/kernel/test_preprocessing.py b/tests/kernel/test_preprocessing.py index 80dd7442..dd086799 100644 --- a/tests/kernel/test_preprocessing.py +++ b/tests/kernel/test_preprocessing.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime +from uuid import uuid4 import pytest from pydantic import ValidationError @@ -102,8 +103,7 @@ def test_a_spec_discriminates_steps_on_kind() -> None: def test_a_recipe_wraps_a_spec_with_its_identity() -> None: now = datetime.now(UTC) recipe = PreprocessingRecipe( - id="r1", - project_id="p1", + project_id=uuid4(), name="yolo-640", spec=RecipeSpec(target="yolo11", steps=(RESIZE,)), created_at=now, diff --git a/tests/kernel/test_preprocessing_recipe_service.py b/tests/kernel/test_preprocessing_recipe_service.py new file mode 100644 index 00000000..0f705853 --- /dev/null +++ b/tests/kernel/test_preprocessing_recipe_service.py @@ -0,0 +1,209 @@ +"""Recipes as a project resource: named, editable, and unique per project.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from tests.kernel.test_release_service import SIGN, Fixture + +from visionset.kernel.domain import ( + AugmentOp, + AugmentStep, + RecipeSpec, + ResizeStep, + ResizeStrategy, + SplitRecipe, +) +from visionset.kernel.errors import ( + InvalidName, + PreprocessingRecipeNameTaken, + PreprocessingRecipeNotFound, + ProjectNotFound, + ReleaseNotFound, +) +from visionset.kernel.services import ( + PreprocessingRecipeService, + ProjectService, + ReleaseService, + WorkspaceService, +) + +LETTERBOX = RecipeSpec( + target="yolo11", + steps=(ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=640, height=640),), +) +AUGMENTED = RecipeSpec( + target=None, + steps=(AugmentStep(op=AugmentOp.HFLIP),), + variants_per_asset=2, +) + + +@pytest.fixture +def workspace(tmp_path: Path) -> WorkspaceService: + service = WorkspaceService.init(tmp_path / "ws") + yield service + service.close() + + +def test_a_recipe_is_stored_under_its_name_and_read_back_whole( + workspace: WorkspaceService, +) -> None: + project = ProjectService(workspace).create("p") + recipes = PreprocessingRecipeService(workspace) + + created = recipes.create(project.id, "letterbox-640", LETTERBOX) + + assert created.name == "letterbox-640" + assert created.spec == LETTERBOX + assert created.created_at == created.updated_at + assert recipes.get(project.id, "letterbox-640") == created + assert recipes.list(project.id) == [created] + + +def test_a_name_is_normalized_and_must_be_a_slug(workspace: WorkspaceService) -> None: + project = ProjectService(workspace).create("p") + recipes = PreprocessingRecipeService(workspace) + + assert recipes.create(project.id, " flips.v2 ", AUGMENTED).name == "flips.v2" + for bad in ("", "Letterbox", "has space", "-leading", "x" * 65): + with pytest.raises(InvalidName): + recipes.create(project.id, bad, LETTERBOX) + + +def test_a_name_is_unique_per_project_and_free_across_projects( + workspace: WorkspaceService, +) -> None: + projects = ProjectService(workspace) + first, second = projects.create("one"), projects.create("two") + recipes = PreprocessingRecipeService(workspace) + recipes.create(first.id, "same", LETTERBOX) + + with pytest.raises(PreprocessingRecipeNameTaken): + recipes.create(first.id, "same", AUGMENTED) + assert recipes.create(second.id, "same", AUGMENTED).spec == AUGMENTED + + +def test_update_replaces_the_spec_and_moves_updated_at(workspace: WorkspaceService) -> None: + project = ProjectService(workspace).create("p") + recipes = PreprocessingRecipeService(workspace) + created = recipes.create(project.id, "r", LETTERBOX) + + updated = recipes.update(project.id, "r", spec=AUGMENTED) + + assert updated.id == created.id + assert updated.spec == AUGMENTED + assert updated.created_at == created.created_at + assert updated.updated_at >= created.updated_at + assert recipes.get(project.id, "r") == updated + + +def test_update_can_rename_and_a_rename_onto_a_taken_name_is_refused( + workspace: WorkspaceService, +) -> None: + project = ProjectService(workspace).create("p") + recipes = PreprocessingRecipeService(workspace) + recipes.create(project.id, "a", LETTERBOX) + recipes.create(project.id, "b", AUGMENTED) + + renamed = recipes.update(project.id, "a", spec=LETTERBOX, new_name="c") + assert renamed.name == "c" + with pytest.raises(PreprocessingRecipeNotFound): + recipes.get(project.id, "a") + with pytest.raises(PreprocessingRecipeNameTaken): + recipes.update(project.id, "c", spec=LETTERBOX, new_name="b") + # Renaming onto its own name is not a collision. + assert recipes.update(project.id, "c", spec=LETTERBOX, new_name="c").name == "c" + + +def test_delete_answers_what_went_and_a_second_delete_is_refused( + workspace: WorkspaceService, +) -> None: + project = ProjectService(workspace).create("p") + recipes = PreprocessingRecipeService(workspace) + created = recipes.create(project.id, "r", LETTERBOX) + + assert recipes.delete(project.id, "r") == created + assert recipes.list(project.id) == [] + with pytest.raises(PreprocessingRecipeNotFound): + recipes.delete(project.id, "r") + + +def test_an_unknown_project_is_refused_on_every_operation(workspace: WorkspaceService) -> None: + from uuid import uuid4 + + recipes = PreprocessingRecipeService(workspace) + missing = uuid4() + with pytest.raises(ProjectNotFound): + recipes.create(missing, "r", LETTERBOX) + with pytest.raises(ProjectNotFound): + recipes.list(missing) + with pytest.raises(ProjectNotFound): + recipes.get(missing, "r") + with pytest.raises(ProjectNotFound): + recipes.delete(missing, "r") + + +def test_deleting_the_project_takes_its_recipes_with_it(workspace: WorkspaceService) -> None: + projects = ProjectService(workspace) + project = projects.create("p") + PreprocessingRecipeService(workspace).create(project.id, "r", LETTERBOX) + + projects.delete(project.id, confirm=True) + + with workspace.unit_of_work() as uow: + assert uow.preprocessing_recipes.list(project.id) == [] + + +def test_a_recipe_is_resolved_for_a_release_through_its_project(tmp_path: Path) -> None: + fixture = Fixture(tmp_path) + fixture.schemas.create_version(fixture.project.id, [SIGN]) + fixture.promote() + release = fixture.releases.publish(fixture.dataset_id, "v1", split=SplitRecipe(train=0.6, val=0.2, test=0.2, seed=1)) + recipes = PreprocessingRecipeService(fixture.workspace) + created = recipes.create(fixture.project.id, "r", LETTERBOX) + + assert recipes.for_release(release.id, "r") == created + with pytest.raises(PreprocessingRecipeNotFound): + recipes.for_release(release.id, "other") + from uuid import uuid4 + + with pytest.raises(ReleaseNotFound): + recipes.for_release(uuid4(), "r") + fixture.close() + + +def test_migration_seventeen_adds_the_table_to_an_older_file(tmp_path: Path) -> None: + """A file stamped 16 without the table gains it on open, and the stamp moves.""" + from sqlalchemy import inspect, text + + from visionset.kernel.adapters._tables import META_TABLE + from visionset.kernel.adapters.migrations import FORMAT_VERSION + from visionset.kernel.adapters.sqlite_metadata_store import SqliteMetadataStore + + path = tmp_path / "old.db" + store = SqliteMetadataStore(path) + store.initialize() + with store.engine.begin() as connection: + connection.execute(text("DROP TABLE preprocessing_recipes")) + connection.execute(text(f"UPDATE {META_TABLE} SET format_version = 16")) + store.close() + + reopened = SqliteMetadataStore(path) + reopened.initialize() + with reopened.engine.connect() as connection: + assert "preprocessing_recipes" in inspect(connection).get_table_names() + assert reopened.format_version == FORMAT_VERSION == 17 + reopened.close() + + +def test_release_service_is_untouched_by_recipes(tmp_path: Path) -> None: + """A recipe changes nothing about publication: the manifest is the same bytes.""" + fixture = Fixture(tmp_path) + dataset_id = fixture.ready() + before = ReleaseService(fixture.workspace).publish(dataset_id, "v1").manifest_hash + PreprocessingRecipeService(fixture.workspace).create(fixture.project.id, "r", AUGMENTED) + after = ReleaseService(fixture.workspace).publish(dataset_id, "v2").manifest_hash + assert before == after + fixture.close() diff --git a/tests/kernel/test_schema_draft_service.py b/tests/kernel/test_schema_draft_service.py index f7737c8b..a1486553 100644 --- a/tests/kernel/test_schema_draft_service.py +++ b/tests/kernel/test_schema_draft_service.py @@ -41,8 +41,8 @@ def _drafts( return workspace, SchemaDraftService(workspace), project -def test_the_format_version_is_sixteen() -> None: - assert FORMAT_VERSION == 16 +def test_the_format_version_is_seventeen() -> None: + assert FORMAT_VERSION == 17 def test_a_draft_round_trips_with_its_half_typed_classes_intact(tmp_path: Path) -> None: diff --git a/tests/server/test_errors.py b/tests/server/test_errors.py index e3f4a4fa..3fa25e18 100644 --- a/tests/server/test_errors.py +++ b/tests/server/test_errors.py @@ -53,6 +53,7 @@ "ProjectNotFound": (404, "PROJECT_NOT_FOUND"), "SchemaNotFound": (404, "SCHEMA_NOT_FOUND"), "SchemaDraftNotFound": (404, "SCHEMA_DRAFT_NOT_FOUND"), + "PreprocessingRecipeNotFound": (404, "PREPROCESSING_RECIPE_NOT_FOUND"), "BatchNotFound": (404, "BATCH_NOT_FOUND"), "JobNotFound": (404, "JOB_NOT_FOUND"), "IngestJobNotFound": (404, "INGEST_JOB_NOT_FOUND"), @@ -85,6 +86,7 @@ # 409 — well-formed request, the resource's state refuses it "ProjectNameTaken": (409, "PROJECT_NAME_TAKEN"), "ReleaseTagTaken": (409, "RELEASE_TAG_TAKEN"), + "PreprocessingRecipeNameTaken": (409, "PREPROCESSING_RECIPE_NAME_TAKEN"), "TokenNameTaken": (409, "TOKEN_NAME_TAKEN"), "InferenceConnectionNameTaken": (409, "INFERENCE_CONNECTION_NAME_TAKEN"), "WorkspaceAlreadyExists": (409, "WORKSPACE_ALREADY_EXISTS"), From dbefd174f8ed94dae5f8f9c947b886322a8d6a61 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:32:27 -0700 Subject: [PATCH 2/9] feat(kernel): an export applies a recipe, and the report records it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReleaseService.export takes a recipe and the installed drivers. The manifest is narrowed to the target first, then transformed, so a geometry the target drops never reaches a step that refuses it; folds are the release's own split over the frozen manifest, and augmented variants are written for the train fold only. The plugin sees one manifest asset per file — a base image under its source hash, a variant under -aug — and the content reader resolves either to the driver-transformed bytes. The result separates source from augmented counts, the report gains a preprocessing block with the snapshot, its hash, the Pillow version and a file mapping, and the export routes take a recipe by name and carry its snapshot to the job. Part of the pre-processing epic (#785). --- frontend/ui-core/src/generated/api.ts | 41 +- openapi.json | 50 ++- src/visionset/formats/_layout.py | 18 +- src/visionset/jobs/export.py | 37 +- src/visionset/kernel/domain/__init__.py | 18 +- src/visionset/kernel/domain/export_report.py | 107 +++++ .../kernel/domain/preprocessing_transform.py | 110 +++++- src/visionset/kernel/domain/release.py | 39 -- src/visionset/kernel/ports/__init__.py | 3 +- src/visionset/kernel/ports/preprocessing.py | 24 ++ .../kernel/services/release_service.py | 302 ++++++++++++-- src/visionset/preprocessing/registry.py | 18 +- src/visionset/server/routes/releases.py | 69 +++- src/visionset/wire/__init__.py | 55 +++ tests/formats/test_report_agreement.py | 81 ++++ tests/jobs/test_export_payload.py | 31 ++ tests/kernel/test_export_seam.py | 373 ++++++++++++++++++ .../test_preprocessing_recipe_service.py | 4 +- tests/kernel/test_release_service.py | 14 +- tests/mcp/test_release_tools.py | 2 +- tests/server/test_releases.py | 114 ++++++ 21 files changed, 1401 insertions(+), 109 deletions(-) create mode 100644 src/visionset/kernel/domain/export_report.py create mode 100644 tests/jobs/test_export_payload.py create mode 100644 tests/kernel/test_export_seam.py diff --git a/frontend/ui-core/src/generated/api.ts b/frontend/ui-core/src/generated/api.ts index 8dfe5de9..8420d08b 100644 --- a/frontend/ui-core/src/generated/api.ts +++ b/frontend/ui-core/src/generated/api.ts @@ -2735,6 +2735,18 @@ export interface paths { * declare is 500 `EXPORT_TARGET_CONFLICT`, and a release whose manifest blob * is gone is 500 `WORKSPACE_CORRUPT`. * + * **`recipe` applies a pre-processing recipe of the release's project**, by + * name, and the job carries the recipe as it stood when this request was + * made: editing or deleting the recipe afterwards changes nothing about the + * export. Whether the recipe can run is answered now, like consent — 409 + * `AUGMENTATION_REQUIRES_SPLIT` for an augmenting recipe over a release + * published without a split, 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY` + * for a step that cannot move a geometry the export would carry, 409 + * `EXPORT_SOURCE_UNREADABLE` for a step needing a source size the manifest + * never recorded, and 404 `PREPROCESSING_RECIPE_NOT_FOUND` for a name the + * project does not have. The report written into the output records the + * recipe, its hash, and which written file came from which asset. + * * 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. @@ -2758,9 +2770,19 @@ export interface paths { * @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 - * 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. + * address, same recipe, 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. + * + * `recipe` names a pre-processing recipe of the release's project, and the + * answer then includes whether that recipe can run over this release: a + * recipe that augments against a release published without a split is 409 + * `AUGMENTATION_REQUIRES_SPLIT`, a step that cannot move a geometry the + * export would carry is 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`, and a + * step needing a source size the manifest never recorded is 409 + * `EXPORT_SOURCE_UNREADABLE`. An unknown recipe is 404 + * `PREPROCESSING_RECIPE_NOT_FOUND`. The report itself does not change: what + * a format drops is decided before any transform. * * 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` @@ -11638,6 +11660,8 @@ export interface operations { query?: { /** @description Required when the format cannot carry everything the release holds. */ allow_lossy?: boolean; + /** @description A pre-processing recipe of the release's project, by name. `GET /projects/{project_id}/preprocessing-recipes` lists them. Omit to apply no transform. */ + recipe?: string | null; /** @description An export target's name. `GET /export-targets` lists them. */ target?: string | null; /** @description An installed format's name. `GET /formats` lists them. */ @@ -11719,6 +11743,8 @@ export interface operations { check_export: { parameters: { query?: { + /** @description A pre-processing recipe of the release's project, by name. `GET /projects/{project_id}/preprocessing-recipes` lists them. Omit to apply no transform. */ + recipe?: string | null; /** @description An export target's name. `GET /export-targets` lists them. */ target?: string | null; /** @description An installed format's name. `GET /formats` lists them. */ @@ -11759,6 +11785,15 @@ export interface operations { "application/json": components["schemas"]["ErrorBody"]; }; }; + /** @description The resource's state refuses this request */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; /** @description The request payload is not processable */ 422: { headers: { diff --git a/openapi.json b/openapi.json index ac90aaeb..b205e5e5 100644 --- a/openapi.json +++ b/openapi.json @@ -13980,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**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.", + "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\n**`recipe` applies a pre-processing recipe of the release's project**, by\nname, and the job carries the recipe as it stood when this request was\nmade: editing or deleting the recipe afterwards changes nothing about the\nexport. Whether the recipe can run is answered now, like consent \u2014 409\n`AUGMENTATION_REQUIRES_SPLIT` for an augmenting recipe over a release\npublished without a split, 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`\nfor a step that cannot move a geometry the export would carry, 409\n`EXPORT_SOURCE_UNREADABLE` for a step needing a source size the manifest\nnever recorded, and 404 `PREPROCESSING_RECIPE_NOT_FOUND` for a name the\nproject does not have. The report written into the output records the\nrecipe, its hash, and which written file came from which asset.\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": [ { @@ -14005,6 +14005,24 @@ "type": "boolean" } }, + { + "description": "A pre-processing recipe of the release's project, by name. `GET /projects/{project_id}/preprocessing-recipes` lists them. Omit to apply no transform.", + "in": "query", + "name": "recipe", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A pre-processing recipe of the release's project, by name. `GET /projects/{project_id}/preprocessing-recipes` lists them. Omit to apply no transform.", + "title": "Recipe" + } + }, { "description": "An export target's name. `GET /export-targets` lists them.", "in": "query", @@ -14127,7 +14145,7 @@ }, "/releases/{release_id}/export-compatibility": { "get": { - "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.", + "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 recipe, same document the export refuses with and writes into\nits own output. A client showing a consent dialog asks this first; one that\nwould rather find out by being refused does not have to.\n\n`recipe` names a pre-processing recipe of the release's project, and the\nanswer then includes whether that recipe can run over this release: a\nrecipe that augments against a release published without a split is 409\n`AUGMENTATION_REQUIRES_SPLIT`, a step that cannot move a geometry the\nexport would carry is 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`, and a\nstep needing a source size the manifest never recorded is 409\n`EXPORT_SOURCE_UNREADABLE`. An unknown recipe is 404\n`PREPROCESSING_RECIPE_NOT_FOUND`. The report itself does not change: what\na format drops is decided before any transform.\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": [ { @@ -14140,6 +14158,24 @@ "type": "string" } }, + { + "description": "A pre-processing recipe of the release's project, by name. `GET /projects/{project_id}/preprocessing-recipes` lists them. Omit to apply no transform.", + "in": "query", + "name": "recipe", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A pre-processing recipe of the release's project, by name. `GET /projects/{project_id}/preprocessing-recipes` lists them. Omit to apply no transform.", + "title": "Recipe" + } + }, { "description": "An export target's name. `GET /export-targets` lists them.", "in": "query", @@ -14208,6 +14244,16 @@ }, "description": "No such resource" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { diff --git a/src/visionset/formats/_layout.py b/src/visionset/formats/_layout.py index 547a5fa4..6116c813 100644 --- a/src/visionset/formats/_layout.py +++ b/src/visionset/formats/_layout.py @@ -20,7 +20,13 @@ from typing import Final from uuid import UUID -from visionset.kernel.domain import Manifest, ManifestAsset, Release, assign_split +from visionset.kernel.domain import ( + Manifest, + ManifestAsset, + Release, + assign_split, + source_of_content_hash, +) from visionset.kernel.errors import ExportSourceUnreadable from visionset.kernel.ports import ContentReader @@ -70,7 +76,15 @@ def folds_of(release: Release, manifest: Manifest) -> dict[UUID, str]: """ if release.split is None: return {asset.asset_id: DEFAULT_FOLD for asset in manifest.assets} - assignment = assign_split(release.split, manifest.assets) + # Over the base images only. A manifest handed to a plugin under a recipe + # also lists augmented variants, keyed ``-aug`` and sharing their + # source's ``asset_id``; cutting over those would move the fold boundaries + # the release's own assignment drew, and looking the variant up by the id + # it shares is what keeps it in its source's fold. + bases = [ + asset for asset in manifest.assets if source_of_content_hash(asset.content_hash)[1] == 0 + ] + assignment = assign_split(release.split, bases) return { asset_id: fold for fold, members in zip( diff --git a/src/visionset/jobs/export.py b/src/visionset/jobs/export.py index 37b3c427..f6fc5226 100644 --- a/src/visionset/jobs/export.py +++ b/src/visionset/jobs/export.py @@ -36,8 +36,10 @@ from visionset.formats import registry from visionset.jobs.context import workspace_for from visionset.jobs.registry import HandlerRef, register +from visionset.kernel.domain import RecipeSpec from visionset.kernel.ports import ProgressReporter, resolve_target from visionset.kernel.services import ReleaseService +from visionset.preprocessing import registry as preprocessing_registry JOB_TYPE = "export.release" @@ -51,22 +53,35 @@ def payload_for( - release_id: UUID, format_name: str, *, target: str | None, allow_lossy: bool + release_id: UUID, + format_name: str, + *, + target: str | None, + allow_lossy: bool, + recipe: tuple[str | None, RecipeSpec] | None = None, ) -> dict[str, JsonValue]: """The payload this handler expects, built where the type is known. - One place names these four keys and the same place reads them — a route + One place names these five 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. ``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. + + ``recipe`` is carried as a **snapshot** — the stored recipe's name beside + the spec as it stood at the request — never as a name to look up again: + an edit or a delete between the request and the worker's run must not + change what the worker applies. """ return { "release_id": str(release_id), "format": format_name, "target": target, "allow_lossy": allow_lossy, + "recipe": None + if recipe is None + else {"name": recipe[0], "spec": recipe[1].model_dump(mode="json")}, } @@ -107,6 +122,12 @@ def run( format_name = str(payload["format"]) target_name = None if payload.get("target") is None else str(payload["target"]) allow_lossy = bool(payload["allow_lossy"]) + snapshot = payload.get("recipe") + recipe_name: str | None = None + spec: RecipeSpec | None = None + if isinstance(snapshot, dict): + recipe_name = None if snapshot.get("name") is None else str(snapshot["name"]) + spec = RecipeSpec.model_validate(snapshot["spec"]) workspace = workspace_for(workspace_root) # Through the *module*, never ``from ... import exporters``: a module global @@ -125,7 +146,14 @@ def run( # 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, target=target + release_id, + exporter, + destination, + allow_lossy=allow_lossy, + target=target, + recipe=spec, + recipe_name=recipe_name, + drivers=None if spec is None else preprocessing_registry.drivers(), ) archive = archive_path(workspace_root, release_id, format_name) @@ -140,4 +168,7 @@ def run( "archive": str(archive.relative_to(workspace_root)), "file_count": result.file_count, "total_bytes": result.total_bytes, + "source_file_count": result.source_file_count, + "augmented_file_count": result.augmented_file_count, + "recipe_hash": None if result.preprocessing is None else result.preprocessing.recipe_hash, } diff --git a/src/visionset/kernel/domain/__init__.py b/src/visionset/kernel/domain/__init__.py index 2a85a804..df4e8363 100644 --- a/src/visionset/kernel/domain/__init__.py +++ b/src/visionset/kernel/domain/__init__.py @@ -66,6 +66,11 @@ IngestCompleted, ReleasePublished, ) +from visionset.kernel.domain.export_report import ( + ExportFileMapping, + ExportPreprocessing, + ExportResult, +) from visionset.kernel.domain.export_target import ( TARGET_NAME_PATTERN, ExportTarget, @@ -184,13 +189,18 @@ variant_seed, ) from visionset.kernel.domain.preprocessing_transform import ( + VARIANT_ID_NAMESPACE, Fold, LetterboxFit, TransformedAnnotation, TransformedFile, TransformedView, + fit_within, letterbox_fit, + plugin_manifest, + source_of_content_hash, transform_manifest, + variant_content_hash, ) from visionset.kernel.domain.project import Project, ProjectPreview, ProjectStats from visionset.kernel.domain.release import ( @@ -198,7 +208,6 @@ ClassCompatibility, ClassExportStatus, ExportCompatibility, - ExportResult, Manifest, ManifestAnnotation, ManifestAsset, @@ -408,6 +417,8 @@ "ClassCompatibility", "ClassExportStatus", "ExportCompatibility", + "ExportFileMapping", + "ExportPreprocessing", "ExportResult", "ExportTarget", "PreprocessingHints", @@ -424,11 +435,16 @@ "ResizeStep", "Step", "TransformedAnnotation", + "VARIANT_ID_NAMESPACE", "TransformedFile", "TransformedView", "brightness_contrast_factors", "hflip_applied", "letterbox_fit", + "variant_content_hash", + "source_of_content_hash", + "plugin_manifest", + "fit_within", "recipe_hash", "rot90_quarter_turns", "transform_manifest", diff --git a/src/visionset/kernel/domain/export_report.py b/src/visionset/kernel/domain/export_report.py new file mode 100644 index 00000000..f6e6781f --- /dev/null +++ b/src/visionset/kernel/domain/export_report.py @@ -0,0 +1,107 @@ +# usage: from visionset.kernel.domain import ExportResult, ExportPreprocessing +"""What one run of an exporter left on disk, and what pre-processing did to it. + +Its own module rather than a tail of ``release.py`` because the report names a +:class:`RecipeSpec`, and the recipe module already reads the manifest's +canonical encoder from ``release.py`` — the two would import each other. This +module sits above both. +""" + +from __future__ import annotations + +from pathlib import Path +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from visionset.kernel.domain.preprocessing import RecipeSpec +from visionset.kernel.domain.release import ExportCompatibility + + +class ExportFileMapping(BaseModel): + """One image the export wrote, traced to the manifest asset it came from. + + ``file`` is the path the exporter chose, relative to the export directory. + ``source_content_hash`` is the manifest asset's hash — the blob the bytes + were derived from — and ``exported_sha256`` is the digest of what was + written, which differs from the source for every resized or augmented + file. ``variant`` 0 is the base image; augmented variants count from 1. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + file: str + source_content_hash: str + exported_sha256: str + variant: int = Field(ge=0) + + +class ExportPreprocessing(BaseModel): + """The recipe an export applied, snapshotted by value, and what it produced. + + ``spec`` is the recipe as it stood at export time and ``recipe_hash`` names + that value, so editing or deleting the stored recipe afterwards changes + nothing here. ``recipe_name`` is informational: which stored recipe the + caller pointed at, or ``None`` for a spec handed over directly. + ``pillow_version`` says which codecs produced the bytes, because byte + stability is promised within one environment only. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + recipe_name: str | None + spec: RecipeSpec + recipe_hash: str + pillow_version: str + mapping: tuple[ExportFileMapping, ...] = () + + +class ExportResult(BaseModel): + """What one run of an exporter left on disk. + + Small on purpose. The exporter writes a directory and returns nothing, so + without this a caller has no answer at all — and a caller that reaches an + export through something other than HTTP (the CLI, an MCP tool) needs one, + because it never sees the bytes. The REST route hands back the files + themselves and uses these numbers only to describe what it is sending. + + ``file_count`` and ``total_bytes`` are counted by walking ``directory`` + after the plugin returns rather than reported by the plugin itself: an + exporter that miscounts its own output would then be trusted about it, and + the whole point of the number is to be checkable. It also means a plugin + that writes nothing — ``DummyExporter`` does exactly that — reports zero + rather than lying. + + The four source/augmented counts separate what the release held from what + a recipe added. ``source_file_count`` and ``augmented_file_count`` count + the *images* the plugin read through the content reader and wrote — base + images and augmented variants — so labels and descriptors stay in + ``file_count`` alone; ``file_count`` is the total of everything written. + The annotation pair counts the labels handed to the plugin the same way. + + Not frozen for the usual immutability argument but for the same one every + report here uses: this describes a moment that has already passed. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + 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 + #: caller that never sees the bytes — the CLI, an MCP tool, an SDK user — + #: would otherwise have to open the file to learn what it consented to. + compatibility: ExportCompatibility + #: Where the files were written. Absolute, and the caller's own choice — + #: the kernel never picks a location. + directory: Path + file_count: int = Field(default=0, ge=0) + total_bytes: int = Field(default=0, ge=0) + source_file_count: int = Field(default=0, ge=0) + augmented_file_count: int = Field(default=0, ge=0) + source_annotation_count: int = Field(default=0, ge=0) + augmented_annotation_count: int = Field(default=0, ge=0) + #: The recipe applied, or ``None`` for an export that applied no transform. + preprocessing: ExportPreprocessing | None = None diff --git a/src/visionset/kernel/domain/preprocessing_transform.py b/src/visionset/kernel/domain/preprocessing_transform.py index 59dbcad2..ee26bc61 100644 --- a/src/visionset/kernel/domain/preprocessing_transform.py +++ b/src/visionset/kernel/domain/preprocessing_transform.py @@ -1,4 +1,4 @@ -# usage: from visionset.kernel.domain import transform_manifest, letterbox_fit +# usage: from visionset.kernel.domain import transform_manifest, plugin_manifest """What a recipe does to a manifest's geometry, worked out without a pixel. The kernel owns every coordinate an export writes: the pixel driver moves @@ -15,8 +15,8 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Literal -from uuid import UUID +from typing import Final, Literal +from uuid import NAMESPACE_URL, UUID, uuid5 from pydantic import BaseModel, ConfigDict, Field @@ -54,6 +54,17 @@ Fold = Literal["train", "val", "test"] +#: What separates a source content hash from a variant index in the key an +#: augmented file is read and named under: ``-aug``. A content hash +#: is hexadecimal, so the marker cannot occur inside one. +VARIANT_MARKER: Final = "-aug" + +#: The namespace a variant's annotation id is derived in. A manifest annotation +#: id is a UUID, and ``"{id}-aug{k}"`` is not one, so the manifest handed to a +#: plugin carries ``uuid5(namespace, "{id}-aug{k}")`` — derived, not drawn, so +#: two exports of one release agree on every id. +VARIANT_ID_NAMESPACE: Final = uuid5(NAMESPACE_URL, "visionset:preprocessing:variant") + class LetterboxFit(BaseModel): """Where letterboxed content lands on the padded canvas. @@ -396,3 +407,96 @@ def _rotated_once(geometry: Geometry, width: float) -> Geometry: if isinstance(geometry, PolygonGeometry): return PolygonGeometry(points=points) return PolylineGeometry(points=points) + + +def variant_content_hash(content_hash: str, variant: int) -> str: + """The key an exported file is read and named under. + + Variant 0 is the source hash itself, so a base image keeps its + original-hash-derived name; variant ``k`` is ``"{hash}-aug{k}"``, which is + both the name on disk and what the export's content reader resolves. + """ + return content_hash if variant == 0 else f"{content_hash}{VARIANT_MARKER}{variant}" + + +def source_of_content_hash(key: str) -> tuple[str, int]: + """The source hash and variant index a content key was built from.""" + head, marker, tail = key.rpartition(VARIANT_MARKER) + if marker and tail.isdigit(): + return head, int(tail) + return key, 0 + + +def plugin_manifest(manifest: Manifest, view: TransformedView) -> Manifest: + """The view as the manifest a plugin is handed: one asset per file to write. + + The port speaks manifests and content hashes and has no word for a variant, + so each transformed file becomes a manifest asset. Its ``asset_id`` stays + the source's — which is what keeps a variant in its source's fold when the + plugin recomputes folds — and its ``content_hash`` is + :func:`variant_content_hash`, distinct per variant so the plugin names and + reads each file on its own. ``uri`` is copied from the source; ``width`` + and ``height`` are the transformed size. Classes stay the manifest's. + """ + sources = {asset.asset_id: asset for asset in manifest.assets} + return manifest.model_copy( + update={ + "assets": tuple( + ManifestAsset( + asset_id=file.asset_id, + content_hash=variant_content_hash(file.content_hash, file.variant), + uri=sources[file.asset_id].uri, + width=file.width, + height=file.height, + annotations=tuple( + _manifest_annotation(annotation, file.variant) + for annotation in file.annotations + ), + ) + for file in view.files + ) + } + ) + + +def _manifest_annotation(annotation: TransformedAnnotation, variant: int) -> ManifestAnnotation: + return ManifestAnnotation( + id=UUID(annotation.id) if variant == 0 else uuid5(VARIANT_ID_NAMESPACE, annotation.id), + label_class=annotation.label_class, + schema_version=annotation.schema_version, + geometry=annotation.geometry, + attributes=dict(annotation.attributes), + provenance=annotation.provenance, + model_ref=annotation.model_ref, + confidence=annotation.confidence, + ) + + +def fit_within(file: TransformedFile, max_edge: int) -> TransformedFile: + """The file scaled so its longer edge is at most ``max_edge``, aspect kept. + + A preview's size cap, applied as the stretch arithmetic the export uses so + the annotations land where a resize driver asked for the same size would + put the pixels. A file already within the cap, or one with no recorded + size, is returned as it is. + """ + if file.width is None or file.height is None or max(file.width, file.height) <= max_edge: + return file + scale = max_edge / max(file.width, file.height) + width, height = max(1, round(file.width * scale)), max(1, round(file.height * scale)) + return file.model_copy( + update={ + "width": width, + "height": height, + "annotations": tuple( + annotation.model_copy( + update={ + "geometry": _scaled( + annotation.geometry, width / file.width, height / file.height, 0.0, 0.0 + ) + } + ) + for annotation in file.annotations + ), + } + ) diff --git a/src/visionset/kernel/domain/release.py b/src/visionset/kernel/domain/release.py index 44cc0edf..0387220e 100644 --- a/src/visionset/kernel/domain/release.py +++ b/src/visionset/kernel/domain/release.py @@ -40,7 +40,6 @@ from collections.abc import Sequence from datetime import UTC, datetime from enum import StrEnum -from pathlib import Path from uuid import UUID, uuid4 from pydantic import ( @@ -601,41 +600,3 @@ def degraded(self) -> tuple[ClassCompatibility, ...]: for one in self.classes if one.status is ClassExportStatus.DEGRADED and one.annotations > 0 ) - - -class ExportResult(BaseModel): - """What one run of an exporter left on disk. - - Small on purpose. The exporter writes a directory and returns nothing, so - without this a caller has no answer at all — and a caller that reaches an - export through something other than HTTP (the CLI, an MCP tool) needs one, - because it never sees the bytes. The REST route hands back the files - themselves and uses these numbers only to describe what it is sending. - - ``file_count`` and ``total_bytes`` are counted by walking ``directory`` - after the plugin returns rather than reported by the plugin itself: an - exporter that miscounts its own output would then be trusted about it, and - the whole point of the number is to be checkable. It also means a plugin - that writes nothing — ``DummyExporter`` does exactly that — reports zero - rather than lying. - - Not frozen for the usual immutability argument but for the same one every - report here uses: this describes a moment that has already passed. - """ - - model_config = ConfigDict(frozen=True, extra="forbid") - - 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 - #: caller that never sees the bytes — the CLI, an MCP tool, an SDK user — - #: would otherwise have to open the file to learn what it consented to. - compatibility: ExportCompatibility - #: Where the files were written. Absolute, and the caller's own choice — - #: the kernel never picks a location. - directory: Path - file_count: int = Field(default=0, ge=0) - total_bytes: int = Field(default=0, ge=0) diff --git a/src/visionset/kernel/ports/__init__.py b/src/visionset/kernel/ports/__init__.py index 4df53d64..e95fad00 100644 --- a/src/visionset/kernel/ports/__init__.py +++ b/src/visionset/kernel/ports/__init__.py @@ -29,7 +29,7 @@ ) from visionset.kernel.ports.model_provider import ModelProvider from visionset.kernel.ports.point_segmenter import PointSegmenter -from visionset.kernel.ports.preprocessing import PreprocessingDriver +from visionset.kernel.ports.preprocessing import PreprocessingDriver, driver_for from visionset.kernel.ports.progress_reporter import ProgressReporter from visionset.kernel.ports.provider import Provider, Runner, WeightsSource from visionset.kernel.ports.video_processor import ( @@ -56,6 +56,7 @@ "ModelProvider", "PointSegmenter", "PreprocessingDriver", + "driver_for", "ProgressReporter", "Provider", "Repository", diff --git a/src/visionset/kernel/ports/preprocessing.py b/src/visionset/kernel/ports/preprocessing.py index 3d8cfc42..c83e0429 100644 --- a/src/visionset/kernel/ports/preprocessing.py +++ b/src/visionset/kernel/ports/preprocessing.py @@ -1,6 +1,8 @@ +from collections.abc import Mapping from typing import Protocol, runtime_checkable from visionset.kernel.domain import Step +from visionset.kernel.errors import PreprocessingDriverNotFound @runtime_checkable @@ -26,3 +28,25 @@ class PreprocessingDriver(Protocol): step_kinds: frozenset[str] def apply(self, step: Step, image: bytes, *, seed: bytes, variant: int) -> bytes: ... + + +def driver_for(installed: Mapping[str, PreprocessingDriver], step_kind: str) -> PreprocessingDriver: + """One driver out of a set already in hand, or say none applies that kind. + + Beside the port for ``resolve_target``'s reason: the kernel may not scan + entry points, so whoever composed the call passes what is installed, keyed + by step kind, and the refusal has one wording wherever it is raised. A + caller must not index the mapping directly — a ``KeyError`` is outside the + ``VisionSetError`` tree. + + Raises: + PreprocessingDriverNotFound: no installed driver applies ``step_kind``. + """ + if step_kind not in installed: + known = tuple(sorted(installed)) + raise PreprocessingDriverNotFound( + f"no pre-processing driver is installed for step kind {step_kind!r}; " + f"installed step kinds: {', '.join(known) or 'none'}", + installed=known, + ) + return installed[step_kind] diff --git a/src/visionset/kernel/services/release_service.py b/src/visionset/kernel/services/release_service.py index 09673866..c1591d2f 100644 --- a/src/visionset/kernel/services/release_service.py +++ b/src/visionset/kernel/services/release_service.py @@ -41,11 +41,14 @@ from __future__ import annotations import json +from collections.abc import Mapping +from dataclasses import dataclass from io import BytesIO from pathlib import Path from typing import BinaryIO, Final from uuid import UUID +from PIL import __version__ as PILLOW_VERSION from pydantic import ValidationError from visionset import __version__ @@ -54,27 +57,38 @@ Annotation, AnnotationSchema, Asset, + AugmentStep, ClassCompatibility, ClassCount, ClassExportStatus, Dataset, ExportCompatibility, + ExportFileMapping, + ExportPreprocessing, ExportResult, ExportTarget, GeometryType, Manifest, ManifestAnnotation, ManifestAsset, + RecipeSpec, Release, ReleasePublished, ReleaseVerification, + ResizeStep, SplitAssignment, SplitRecipe, + TransformedView, assign_split, canonical_bytes, normalize_name, + plugin_manifest, + recipe_hash, sha256_hex, + source_of_content_hash, + transform_manifest, validate_schema_annotation, + variant_seed, ) from visionset.kernel.errors import ( ConstraintViolated, @@ -88,7 +102,13 @@ ReleaseTagTaken, WorkspaceCorrupt, ) -from visionset.kernel.ports import BlobStore, ContentReader, Exporter, UnitOfWork +from visionset.kernel.ports import ( + BlobStore, + Exporter, + PreprocessingDriver, + UnitOfWork, + driver_for, +) from visionset.kernel.services.dataset_service import DatasetService, assets_of from visionset.kernel.services.schema_service import SchemaService from visionset.kernel.services.workspace_service import WorkspaceService @@ -348,7 +368,12 @@ def assignment(self, release_id: UUID) -> SplitAssignment: # --- handing the snapshot to a format plugin --------------------------- def check_export( - self, release_id: UUID, exporter: Exporter, *, target: ExportTarget | None = None + self, + release_id: UUID, + exporter: Exporter, + *, + target: ExportTarget | None = None, + recipe: RecipeSpec | None = None, ) -> ExportCompatibility: """What this format would drop from this release, before anything is written. @@ -357,6 +382,13 @@ def check_export( report names the target it answers for. Without one the format alone is judged. + ``recipe`` is checked here too, as :meth:`export` will check it: the + geometry transform runs over what the export would hand the plugin, so + a step that cannot move a label this release carries, or augmentation + against a release with no split, refuses now rather than in a worker. + The report itself says nothing about the recipe — what a format drops + is decided before any transform. + 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 @@ -379,11 +411,20 @@ def check_export( Raises: ReleaseNotFound: no such release in this workspace. + AugmentationRequiresSplit: the recipe augments and the release was + published without a split recipe. + PreprocessingStepUnsupportedGeometry: a recipe step cannot + transform a geometry the export would carry. + ExportSourceUnreadable: a recipe step needs a source size the + manifest never recorded. WorkspaceCorrupt: the manifest blob is gone, or is not a manifest. """ release = self.get(release_id) manifest = self._read_manifest(release) - return _compatibility(release, manifest, exporter, target) + compatibility = _compatibility(release, manifest, exporter, target) + if recipe is not None: + _transformed(release, manifest, target, recipe) + return compatibility def require_export_consent( self, @@ -392,6 +433,7 @@ def require_export_consent( *, allow_lossy: bool, target: ExportTarget | None = None, + recipe: RecipeSpec | None = None, ) -> ExportCompatibility: """The compatibility report, or refuse because the caller has not consented. @@ -411,10 +453,20 @@ def require_export_consent( and a format declaring itself lossless still cannot silently drop a geometry it never claimed to write. + Consent comes first and the recipe is checked after it: a caller who + has not accepted the loss is answered about the loss, and only a + consented export goes on to ask whether its recipe can run. + Raises: ReleaseNotFound: no such release in this workspace. LossyExportNotConsented: the format drops information and the caller has not said that is acceptable. + AugmentationRequiresSplit: the recipe augments and the release was + published without a split recipe. + PreprocessingStepUnsupportedGeometry: a recipe step cannot + transform a geometry the export would carry. + ExportSourceUnreadable: a recipe step needs a source size the + manifest never recorded. WorkspaceCorrupt: the manifest blob is gone, or is not a manifest. """ release = self.get(release_id) @@ -426,6 +478,8 @@ def require_export_consent( f"{release.tag!r} holds; re-run with allow_lossy to accept the loss", compatibility=compatibility, ) + if recipe is not None: + _transformed(release, manifest, target, recipe) return compatibility def export( @@ -436,6 +490,9 @@ def export( *, allow_lossy: bool = False, target: ExportTarget | None = None, + recipe: RecipeSpec | None = None, + recipe_name: str | None = None, + drivers: Mapping[str, PreprocessingDriver] | None = None, ) -> ExportResult: """Write this release into ``dest`` in the exporter's format. @@ -445,6 +502,19 @@ def export( word for a target, and a drop the report promises must not depend on every plugin reading a declaration it cannot see. + ``recipe`` is the pre-processing to apply, snapshotted by value; the + stored recipe it came from, if any, is named by ``recipe_name`` for the + report alone. The narrowing above happens **first** and the recipe runs + over what is left, so a geometry the target drops can never make a + step refuse. Folds come from the release's own split recipe over the + frozen manifest — the same cut :meth:`assignment` answers — and + augmented variants are generated for the train fold only. The plugin + then sees one manifest asset per file to write: base images under + their source hash, variants under ``-aug``, and ``content`` + resolves either to the driver-transformed bytes. ``drivers`` are the + installed :class:`PreprocessingDriver` instances keyed by step kind, + composed by the caller because the kernel may not scan entry points. + 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 @@ -483,6 +553,14 @@ def export( ReleaseNotFound: no such release in this workspace. LossyExportNotConsented: the format drops information and the caller has not said that is acceptable. + AugmentationRequiresSplit: the recipe augments and the release was + published without a split recipe. + PreprocessingStepUnsupportedGeometry: a recipe step cannot + transform a geometry the export would carry. + PreprocessingDriverNotFound: no driver in ``drivers`` applies a + step the recipe holds. + ExportSourceUnreadable: a recipe step needs a source size the + manifest never recorded. WorkspaceCorrupt: the manifest blob is gone, or is not a manifest. """ release = self.get(release_id) @@ -490,13 +568,16 @@ def export( compatibility = self.require_export_consent( release_id, exporter, allow_lossy=allow_lossy, target=target ) + addressed = manifest if target is None else _addressed_to(manifest, target) + reader = _RecordingReader(manifest, self._workspace.blob_store) + if recipe is None: + handed, view = addressed, None + else: + view = _transformed(release, manifest, target, recipe) + handed = plugin_manifest(addressed, view) + reader.transform(recipe, drivers or {}) dest.mkdir(parents=True, exist_ok=True) - exporter.export( - release, - manifest if target is None else _addressed_to(manifest, target), - dest, - content=_content_reader(manifest, self._workspace.blob_store), - ) + exporter.export(release, handed, dest, content=reader.read) # The report is **excluded from the count**, on both sides: it is not # written until after the walk, and a report left by an earlier run into # the same directory is skipped. Both halves are needed for the same @@ -508,7 +589,19 @@ def export( for path in dest.rglob("*") if path.is_file() and path.name != EXPORT_REPORT_FILENAME ] - _write_report(dest, compatibility) + preprocessing = ( + None + if recipe is None + else ExportPreprocessing( + recipe_name=recipe_name, + spec=recipe, + recipe_hash=recipe_hash(recipe), + pillow_version=PILLOW_VERSION, + mapping=reader.mapping(dest, written), + ) + ) + _write_report(dest, compatibility, preprocessing) + source_annotations, augmented_annotations = _annotation_counts(handed, view) return ExportResult( compatibility=compatibility, release_id=release.id, @@ -517,6 +610,11 @@ def export( directory=dest, file_count=len(written), total_bytes=sum(path.stat().st_size for path in written), + source_file_count=reader.read_count(variant=0), + augmented_file_count=reader.read_count(variant=None) - reader.read_count(variant=0), + source_annotation_count=source_annotations, + augmented_annotation_count=augmented_annotations, + preprocessing=preprocessing, ) # --- the blob store side ---------------------------------------------- @@ -721,31 +819,172 @@ def _cache_mismatches(release: Release, manifest: Manifest) -> tuple[str, ...]: ) -def _content_reader(manifest: Manifest, blobs: BlobStore) -> ContentReader: +def _transformed( + release: Release, manifest: Manifest, target: ExportTarget | None, recipe: RecipeSpec +) -> TransformedView: + """What the recipe makes of the manifest an export would hand the plugin. + + Narrowed to the target first, so a geometry the target drops never + reaches a step that cannot move it; folds from the release's own split + over the frozen manifest, the cut ``assignment`` answers. + + Raises: + AugmentationRequiresSplit, PreprocessingStepUnsupportedGeometry, + ExportSourceUnreadable: from ``transform_manifest``. + """ + addressed = manifest if target is None else _addressed_to(manifest, target) + folds = None if release.split is None else assign_split(release.split, manifest.assets) + return transform_manifest(addressed, recipe, folds) + + +def transformed_bytes( + spec: RecipeSpec, + drivers: Mapping[str, PreprocessingDriver], + source: bytes, + *, + content_hash: str, + variant: int, +) -> bytes: + """One image through the recipe's steps, for one variant. + + The resize step runs for every variant; augmentation steps run for + variants ``1..n`` only, each seeded from the recipe, the source and the + variant index so the pixels land where ``transform_manifest`` put the + labels. Shared by the export seam and the preview, which is what makes a + preview show what an export will write. + + Raises: + PreprocessingDriverNotFound: no driver in ``drivers`` applies a step. + """ + seed = variant_seed(recipe_hash(spec), content_hash, variant) + image = source + for step in spec.steps: + if isinstance(step, ResizeStep) or (isinstance(step, AugmentStep) and variant > 0): + image = driver_for(drivers, step.kind).apply(step, image, seed=seed, variant=variant) + return image + + +@dataclass(frozen=True) +class _Produced: + """What one read through the content reader handed the plugin.""" + + source_content_hash: str + variant: int + exported_sha256: str | None + + +class _RecordingReader: """The reader a plugin lays images out with, composed for exactly one export. Closed over the manifest so the refusal can name the *asset* rather than the hash a plugin happened to ask for — a caller who never saw a content hash - cannot act on one. Built here rather than handed the blob store directly + cannot act on one. Built here rather than handing the blob store over because a format plugin has no business writing into the content store; see :data:`~visionset.kernel.ports.ContentReader`. - The handle is **not** closed here, unlike ``_read_blob``'s: this one hands it - over, and a plugin streaming a large image into a file wants the stream, not - its contents in memory. + Without a recipe the handle is the blob store's own and is **not** closed + here: a plugin streaming a large image into a file wants the stream. Under + a recipe every key resolves through :func:`transformed_bytes` — a base + image under its source hash, a variant under ``-aug`` — and the + bytes are held in memory, because a driver returns bytes. + + Every read is recorded, so the result can count the images the plugin took + and, under a recipe, trace each written file to its source. """ - subjects = {asset.content_hash: _asset_subject(asset) for asset in manifest.assets} - def read(content_hash: str) -> BinaryIO: + def __init__(self, manifest: Manifest, blobs: BlobStore) -> None: + self._subjects = {asset.content_hash: _asset_subject(asset) for asset in manifest.assets} + self._blobs = blobs + self._spec: RecipeSpec | None = None + self._drivers: Mapping[str, PreprocessingDriver] = {} + self._produced: dict[str, _Produced] = {} + + def transform(self, spec: RecipeSpec, drivers: Mapping[str, PreprocessingDriver]) -> None: + self._spec = spec + self._drivers = drivers + + def read(self, key: str) -> BinaryIO: + source_hash, variant = source_of_content_hash(key) + if self._spec is None: + self._produced[key] = _Produced(source_hash, variant, None) + return self._open(source_hash) + with self._open(source_hash) as stream: + source = stream.read() + image = transformed_bytes( + self._spec, self._drivers, source, content_hash=source_hash, variant=variant + ) + self._produced[key] = _Produced(source_hash, variant, sha256_hex(image)) + return BytesIO(image) + + def _open(self, content_hash: str) -> BinaryIO: try: - return blobs.get(content_hash) + return self._blobs.get(content_hash) except FileNotFoundError as exc: - named = subjects.get(content_hash, f"content {content_hash}") + named = self._subjects.get(content_hash, f"content {content_hash}") raise ExportSourceUnreadable( f"{named} is not in the blob store; verify the release and restore it" ) from exc - return read + def read_count(self, *, variant: int | None) -> int: + """How many distinct keys were read: all of them, or one variant's.""" + return sum( + 1 for one in self._produced.values() if variant is None or one.variant == variant + ) + + def mapping(self, dest: Path, written: list[Path]) -> tuple[ExportFileMapping, ...]: + """Each read traced to the file it landed in, by name and then by digest. + + Every built-in format names an image after the key it read it under, + so the stem is the first match; a plugin naming files its own way is + found by hashing what it wrote. A read no file matches — a plugin + that read and did not write — is left out rather than guessed. + """ + rows: dict[str, ExportFileMapping] = {} + by_stem = {path.stem: path for path in written} + unmatched: dict[str, _Produced] = {} + for key, produced in self._produced.items(): + if produced.exported_sha256 is None: + continue + if (path := by_stem.get(key)) is not None: + rows[key] = _mapping_row(dest, path, produced) + else: + unmatched[produced.exported_sha256] = produced + if unmatched: + claimed = {path for path in (by_stem.get(key) for key in rows) if path is not None} + for path in written: + if path in claimed: + continue + digest = sha256_hex(path.read_bytes()) + if digest in unmatched: + produced = unmatched.pop(digest) + rows[_key_of(produced)] = _mapping_row(dest, path, produced) + return tuple(rows[key] for key in sorted(rows)) + + +def _key_of(produced: _Produced) -> str: + return ( + produced.source_content_hash + if produced.variant == 0 + else f"{produced.source_content_hash}-aug{produced.variant}" + ) + + +def _mapping_row(dest: Path, path: Path, produced: _Produced) -> ExportFileMapping: + assert produced.exported_sha256 is not None + return ExportFileMapping( + file=path.relative_to(dest).as_posix(), + source_content_hash=produced.source_content_hash, + exported_sha256=produced.exported_sha256, + variant=produced.variant, + ) + + +def _annotation_counts(handed: Manifest, view: TransformedView | None) -> tuple[int, int]: + """Labels handed to the plugin, split into the release's own and the augmented.""" + if view is None: + return handed.annotation_count, 0 + source = sum(len(file.annotations) for file in view.files if file.variant == 0) + return source, sum(len(file.annotations) for file in view.files if file.variant > 0) def _read_blob(blobs: BlobStore, content_hash: str, subject: str) -> bytes: @@ -799,7 +1038,9 @@ def _asset_subject(asset: ManifestAsset) -> str: EXPORT_REPORT_FILENAME: Final = "visionset-export-report.json" -def _write_report(dest: Path, compatibility: ExportCompatibility) -> None: +def _write_report( + dest: Path, compatibility: ExportCompatibility, preprocessing: ExportPreprocessing | None +) -> None: """Put the report in the output, after the plugin has written its own files. After, and not before: a plugin that clears its own subdirectory would @@ -810,14 +1051,23 @@ def _write_report(dest: Path, compatibility: ExportCompatibility) -> None: ``sort_keys`` for the reason ``canonical_bytes`` uses it: two exports of one release must produce identical bytes, and a dict's iteration order is not something to leave to chance in a file somebody will diff. + + ``preprocessing`` is one more key beside the compatibility document — + ``null`` for an export that applied no recipe, so the key is always there + to read. """ dest.joinpath(EXPORT_REPORT_FILENAME).write_text( json.dumps( - # ``by_alias`` is the whole point: this file has to be key-for-key the - # document ``wire.export_compatibility`` and ``ExportCompatibilityOut`` - # publish. The report format is stable across all three surfaces, and - # the artifact is one of them. - compatibility.model_dump(mode="json", by_alias=True), + { + # ``by_alias`` is the whole point: this file has to be key-for-key + # the document ``wire.export_compatibility`` and + # ``ExportCompatibilityOut`` publish. The report format is stable + # across all three surfaces, and the artifact is one of them. + **compatibility.model_dump(mode="json", by_alias=True), + "preprocessing": None + if preprocessing is None + else preprocessing.model_dump(mode="json"), + }, indent=2, sort_keys=True, ) diff --git a/src/visionset/preprocessing/registry.py b/src/visionset/preprocessing/registry.py index b067368f..0e65101b 100644 --- a/src/visionset/preprocessing/registry.py +++ b/src/visionset/preprocessing/registry.py @@ -25,8 +25,8 @@ from importlib.metadata import entry_points from visionset.kernel.domain import Step -from visionset.kernel.errors import PreprocessingDriverNotFound from visionset.kernel.ports import PreprocessingDriver +from visionset.kernel.ports import driver_for as kernel_driver_for GROUP = "visionset.preprocessing" @@ -50,22 +50,14 @@ def drivers() -> dict[str, PreprocessingDriver]: def pick(installed: Mapping[str, PreprocessingDriver], step_kind: str) -> PreprocessingDriver: """One driver out of a set already in hand, or say none applies that kind. - Split from :func:`driver` so the refusal has one wording no matter who - scanned the entry points. A caller holding the mapping must not index it - directly: a ``KeyError`` is outside the ``VisionSetError`` tree and would - answer 500 with no message to a request the installation cannot serve. + The kernel's own :func:`~visionset.kernel.ports.driver_for`, under the + name the format registry uses for the same gesture, so the refusal has one + wording whether the export seam or a surface asked. Raises: PreprocessingDriverNotFound: no installed driver applies ``step_kind``. """ - if step_kind not in installed: - known = tuple(sorted(installed)) - raise PreprocessingDriverNotFound( - f"no pre-processing driver is installed for step kind {step_kind!r}; " - f"installed step kinds: {', '.join(known) or 'none'}", - installed=known, - ) - return installed[step_kind] + return kernel_driver_for(installed, step_kind) def driver_for(installed: Mapping[str, PreprocessingDriver], step: Step) -> PreprocessingDriver: diff --git a/src/visionset/server/routes/releases.py b/src/visionset/server/routes/releases.py index e05b520a..6b72badc 100644 --- a/src/visionset/server/routes/releases.py +++ b/src/visionset/server/routes/releases.py @@ -36,9 +36,9 @@ 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, ExportTarget +from visionset.kernel.domain import BackgroundJobSpec, ExportTarget, RecipeSpec from visionset.kernel.ports import Exporter, resolve_target -from visionset.kernel.services import ReleaseService +from visionset.kernel.services import PreprocessingRecipeService, ReleaseService, WorkspaceService from visionset.server.dependencies import ( ExportersDep, RunnerDep, @@ -113,6 +113,17 @@ def _address(target: TargetQuery = None, format: FormatQuery = None) -> ExportAd AddressQuery = Annotated[ExportAddress, Depends(_address)] +RecipeQuery = Annotated[ + str | None, + Query( + description=( + "A pre-processing recipe of the release's project, by name. " + "`GET /projects/{project_id}/preprocessing-recipes` lists them. " + "Omit to apply no transform." + ) + ), +] + #: 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 @@ -257,19 +268,30 @@ def get_release_assignment(workspace: WorkspaceDep, release_id: UUID) -> SplitAs return SplitAssignmentOut.of(ReleaseService(workspace).assignment(release_id)) -@router.get("/{release_id}/export-compatibility", responses=documented(404)) +@router.get("/{release_id}/export-compatibility", responses=documented(404, 409)) def check_export( workspace: WorkspaceDep, exporters: ExportersDep, release_id: UUID, address: AddressQuery, + recipe: RecipeQuery = None, ) -> ExportCompatibilityOut: """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 - 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. + address, same recipe, 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. + + `recipe` names a pre-processing recipe of the release's project, and the + answer then includes whether that recipe can run over this release: a + recipe that augments against a release published without a split is 409 + `AUGMENTATION_REQUIRES_SPLIT`, a step that cannot move a geometry the + export would carry is 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`, and a + step needing a source size the manifest never recorded is 409 + `EXPORT_SOURCE_UNREADABLE`. An unknown recipe is 404 + `PREPROCESSING_RECIPE_NOT_FOUND`. The report itself does not change: what + a format drops is decided before any transform. 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` @@ -291,11 +313,27 @@ def check_export( release is immutable, so this response is as stable as the release is. """ exporter, target = _addressed(exporters, address) + spec = _recipe_spec(workspace, release_id, recipe) return ExportCompatibilityOut.of( - ReleaseService(workspace).check_export(release_id, exporter, target=target) + ReleaseService(workspace).check_export(release_id, exporter, target=target, recipe=spec) ) +def _recipe_spec( + workspace: WorkspaceService, release_id: UUID, name: str | None +) -> RecipeSpec | None: + """The stored recipe's spec, resolved through the release's own project. + + Raises: + ReleaseNotFound: no such release in this workspace. + PreprocessingRecipeNotFound: the release's project has no recipe of + that name. + """ + if name is None: + return None + return PreprocessingRecipeService(workspace).for_release(release_id, name).spec + + def _addressed( exporters: dict[str, Exporter], address: ExportAddress ) -> tuple[Exporter, ExportTarget | None]: @@ -329,6 +367,7 @@ def export_release( release_id: UUID, address: AddressQuery, allow_lossy: AllowLossyQuery = False, + recipe: RecipeQuery = None, ) -> BackgroundJobOut: """Queue the release for writing, and answer at once with the job to poll. @@ -359,17 +398,30 @@ def export_release( declare is 500 `EXPORT_TARGET_CONFLICT`, and a release whose manifest blob is gone is 500 `WORKSPACE_CORRUPT`. + **`recipe` applies a pre-processing recipe of the release's project**, by + name, and the job carries the recipe as it stood when this request was + made: editing or deleting the recipe afterwards changes nothing about the + export. Whether the recipe can run is answered now, like consent — 409 + `AUGMENTATION_REQUIRES_SPLIT` for an augmenting recipe over a release + published without a split, 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY` + for a step that cannot move a geometry the export would carry, 409 + `EXPORT_SOURCE_UNREADABLE` for a step needing a source size the manifest + never recorded, and 404 `PREPROCESSING_RECIPE_NOT_FOUND` for a name the + project does not have. The report written into the output records the + recipe, its hash, and which written file came from which asset. + 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. """ exporter, target = _addressed(exporters, address) + spec = _recipe_spec(workspace, release_id, recipe) # 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, target=target + release_id, exporter, allow_lossy=allow_lossy, target=target, recipe=spec ) job = workspace.job_queue.enqueue( BackgroundJobSpec( @@ -379,6 +431,7 @@ def export_release( exporter.format_name, target=None if target is None else target.name, allow_lossy=allow_lossy, + recipe=None if spec is None else (recipe, spec), ), idempotent=True, ) diff --git a/src/visionset/wire/__init__.py b/src/visionset/wire/__init__.py index 1683995b..94c99ef3 100644 --- a/src/visionset/wire/__init__.py +++ b/src/visionset/wire/__init__.py @@ -82,6 +82,8 @@ DraftAttribute, DraftLabelClass, ExportCompatibility, + ExportFileMapping, + ExportPreprocessing, ExportResult, ExportTarget, Geometry, @@ -96,8 +98,10 @@ PreprocessingHints, Project, ProjectPreview, + RecipeSpec, Release, ReleaseVerification, + ResizeStep, SchemaChange, SchemaChangePreview, SchemaDiff, @@ -105,6 +109,7 @@ SchemaPublication, Source, SplitRecipe, + Step, ThumbnailBackfill, VideoProvenance, WeightDownload, @@ -785,6 +790,49 @@ def export_compatibility(value: ExportCompatibility) -> dict[str, Any]: } +def recipe_step(value: Step) -> dict[str, Any]: + """One step of a recipe, tagged by ``kind``. Also the *input* shape a recipe body reads.""" + if isinstance(value, ResizeStep): + return { + "kind": "resize", + "strategy": value.strategy.value, + "width": value.width, + "height": value.height, + "pad_value": value.pad_value, + } + return {"kind": "augment", "op": value.op.value, "amount": value.amount} + + +def recipe_spec(value: RecipeSpec) -> dict[str, Any]: + """What a recipe does to every exported image. Also the *input* shape a recipe body reads.""" + return { + "target": value.target, + "steps": [recipe_step(step) for step in value.steps], + "variants_per_asset": value.variants_per_asset, + } + + +def export_file_mapping(value: ExportFileMapping) -> dict[str, Any]: + """One written image traced to the manifest asset it came from.""" + return { + "file": value.file, + "source_content_hash": value.source_content_hash, + "exported_sha256": value.exported_sha256, + "variant": value.variant, + } + + +def export_preprocessing(value: ExportPreprocessing) -> dict[str, Any]: + """The recipe an export applied, by value, and what it produced.""" + return { + "recipe_name": value.recipe_name, + "spec": recipe_spec(value.spec), + "recipe_hash": value.recipe_hash, + "pillow_version": value.pillow_version, + "mapping": [export_file_mapping(row) for row in value.mapping], + } + + def export_result(value: ExportResult) -> dict[str, Any]: """What an export left on disk. **Surface-defined**: the API returns the archive. @@ -799,7 +847,14 @@ def export_result(value: ExportResult) -> dict[str, Any]: "directory": str(value.directory), "file_count": value.file_count, "total_bytes": value.total_bytes, + "source_file_count": value.source_file_count, + "augmented_file_count": value.augmented_file_count, + "source_annotation_count": value.source_annotation_count, + "augmented_annotation_count": value.augmented_annotation_count, "compatibility": export_compatibility(value.compatibility), + "preprocessing": None + if value.preprocessing is None + else export_preprocessing(value.preprocessing), } diff --git a/tests/formats/test_report_agreement.py b/tests/formats/test_report_agreement.py index 45331f88..e74adf10 100644 --- a/tests/formats/test_report_agreement.py +++ b/tests/formats/test_report_agreement.py @@ -737,3 +737,84 @@ def test_ultralytics_drops_a_tag_beside_a_box_though_its_declaration_carries_tag assert tag.status is ClassExportStatus.SUPPORTED assert tag.annotations == 1 assert _installed()["ultralytics"].lossy + + +# --- a recipe at export --------------------------------------------------------- +# +# The accounting a recipe adds: every fold's images and labels on disk are the +# fold's source files plus the augmented variants the recipe made for it, and +# the report's mapping traces each one. Real pixels, through the installed +# Pillow drivers, because the seam's own tests use a marking double. + + +def _fold_files(root: Path, fold: str, suffix: str) -> list[Path]: + return ( + sorted((root / "images" / fold).glob(f"*{suffix}")) + if suffix != ".txt" + else sorted((root / LABELS_DIRNAME / fold).glob("*.txt")) + ) + + +def test_written_files_are_source_plus_augmented_per_fold( + tmp_path: Path, labelled: Fixture +) -> None: + from PIL import Image + + from visionset.kernel.domain import ( + AugmentOp, + AugmentStep, + RecipeSpec, + ResizeStep, + ResizeStrategy, + SplitRecipe, + source_of_content_hash, + ) + from visionset.preprocessing.registry import drivers + + release_id = labelled.publish(split=SplitRecipe(train=0.6, val=0.2, test=0.2, seed=3)) + folds = labelled.releases.assignment(release_id) + recipe = RecipeSpec( + target="yolo11", + steps=( + ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=64, height=64), + AugmentStep(op=AugmentOp.HFLIP), + ), + variants_per_asset=1, + ) + dest = tmp_path / "out" + + result = labelled.releases.export( + release_id, + _installed()["ultralytics"], + dest, + allow_lossy=True, + recipe=recipe, + recipe_name="flip", + drivers=drivers(), + ) + labelled.close() + + per_fold = {"train": len(folds.train), "val": len(folds.val), "test": len(folds.test)} + assert result.source_file_count == sum(per_fold.values()) == 3 + assert result.augmented_file_count == per_fold["train"] + for fold, sources in per_fold.items(): + augmented = sources if fold == "train" else 0 + images = _fold_files(dest, fold, ".png") + labels = _fold_files(dest, fold, ".txt") + assert len(images) == sources + augmented, fold + assert len(labels) == sources + augmented, fold + variants = [path for path in images if source_of_content_hash(path.stem)[1] == 1] + assert len(variants) == augmented, fold + for path in images: + with Image.open(path) as image: + assert image.size == (64, 64) + assert any(path.stem.endswith("-aug1") for path in _fold_files(dest, "train", ".txt")) + + report = json.loads((dest / "visionset-export-report.json").read_text(encoding="utf-8")) + assert report["preprocessing"]["recipe_name"] == "flip" + assert report["preprocessing"]["recipe_hash"] == result.preprocessing.recipe_hash + mapping = report["preprocessing"]["mapping"] + assert len(mapping) == result.source_file_count + result.augmented_file_count + assert {row["variant"] for row in mapping} == {0, 1} + for row in mapping: + assert (dest / row["file"]).is_file() diff --git a/tests/jobs/test_export_payload.py b/tests/jobs/test_export_payload.py new file mode 100644 index 00000000..b871ef0e --- /dev/null +++ b/tests/jobs/test_export_payload.py @@ -0,0 +1,31 @@ +"""The export payload carries a recipe by value, never by name.""" + +from __future__ import annotations + +from uuid import uuid4 + +from visionset.jobs.export import payload_for +from visionset.kernel.domain import AugmentOp, AugmentStep, RecipeSpec, ResizeStep, ResizeStrategy + +SPEC = RecipeSpec( + target="yolo11", + steps=( + ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=640, height=640), + AugmentStep(op=AugmentOp.BRIGHTNESS_CONTRAST, amount=0.3), + ), + variants_per_asset=2, +) + + +def test_the_payload_snapshots_the_spec_beside_its_name() -> None: + payload = payload_for( + uuid4(), "ultralytics", target="yolo11", allow_lossy=True, recipe=("r", SPEC) + ) + + assert payload["recipe"] == {"name": "r", "spec": SPEC.model_dump(mode="json")} + assert RecipeSpec.model_validate(payload["recipe"]["spec"]) == SPEC # type: ignore[index] + + +def test_no_recipe_is_carried_as_null() -> None: + payload = payload_for(uuid4(), "ultralytics", target=None, allow_lossy=False) + assert payload["recipe"] is None diff --git a/tests/kernel/test_export_seam.py b/tests/kernel/test_export_seam.py new file mode 100644 index 00000000..33b6e5c1 --- /dev/null +++ b/tests/kernel/test_export_seam.py @@ -0,0 +1,373 @@ +"""A recipe at export: what the plugin is handed, what the reader answers, what the report says. + +Drivers are doubles here — the kernel takes instances and does geometry, and a +driver that appends a marker to the bytes is enough to prove which step ran +over which variant. Real pixels are ``tests/formats``' business. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from uuid import UUID, uuid5 + +import pytest +from tests.kernel.test_release_service import SIGN, Fixture, _box + +from visionset.kernel.domain import ( + VARIANT_ID_NAMESPACE, + Annotation, + AugmentOp, + AugmentStep, + ExportTarget, + GeometryType, + LabelClass, + Manifest, + PolylineGeometry, + PreprocessingHints, + RecipeSpec, + Release, + ResizeStep, + ResizeStrategy, + SplitRecipe, + Step, + TargetFamily, + Task, + recipe_hash, +) +from visionset.kernel.errors import ( + AugmentationRequiresSplit, + LossyExportNotConsented, + PreprocessingDriverNotFound, + PreprocessingStepUnsupportedGeometry, +) +from visionset.kernel.ports import ContentReader +from visionset.kernel.services import EXPORT_REPORT_FILENAME + +SPLIT = SplitRecipe(train=0.6, val=0.2, test=0.2, seed=7) +RESIZE = ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=640, height=640) +LETTERBOX = RecipeSpec(target="yolo11", steps=(RESIZE,)) +AUGMENTED = RecipeSpec( + target=None, + steps=(RESIZE, AugmentStep(op=AugmentOp.HFLIP), AugmentStep(op=AugmentOp.ROT90)), + variants_per_asset=2, +) + + +class MarkingDriver: + """Appends ``|:`` to the bytes, so the export shows what ran.""" + + step_kinds = frozenset({"resize", "augment"}) + + def __init__(self) -> None: + self.calls: list[tuple[str, int]] = [] + + def apply(self, step: Step, image: bytes, *, seed: bytes, variant: int) -> bytes: + self.calls.append((step.kind, variant)) + return image + f"|{step.kind}:{variant}".encode() + + +def drivers_of(driver: MarkingDriver) -> dict[str, MarkingDriver]: + return {"resize": driver, "augment": driver} + + +class ImageWriter: + """Writes every manifest asset's bytes under its content key, and a label per asset.""" + + format_name = "image-writer" + lossy = False + supported_geometries = frozenset(GeometryType) + degraded_geometries: frozenset[GeometryType] = frozenset() + supported_modalities = frozenset({"image"}) + targets: frozenset[ExportTarget] = frozenset() + + def __init__(self) -> None: + self.handed: Manifest | None = None + + def export( + self, release: Release, manifest: Manifest, dest: Path, *, content: ContentReader + ) -> None: + self.handed = manifest + (dest / "images").mkdir() + (dest / "labels").mkdir() + for asset in manifest.assets: + with content(asset.content_hash) as stream: + (dest / "images" / f"{asset.content_hash}.bin").write_bytes(stream.read()) + (dest / "labels" / f"{asset.content_hash}.txt").write_text( + "\n".join(str(annotation.id) for annotation in asset.annotations) + ) + + +class RenamingWriter(ImageWriter): + """Names its images by position, so the report has to find them by digest.""" + + format_name = "renaming-writer" + + def export( + self, release: Release, manifest: Manifest, dest: Path, *, content: ContentReader + ) -> None: + self.handed = manifest + (dest / "images").mkdir() + for index, asset in enumerate(manifest.assets): + with content(asset.content_hash) as stream: + (dest / "images" / f"{index:04d}.bin").write_bytes(stream.read()) + + +def _report(dest: Path) -> dict: + return json.loads((dest / EXPORT_REPORT_FILENAME).read_text(encoding="utf-8")) + + +def _published(fixture: Fixture, *, split: SplitRecipe | None): + return fixture.releases.publish(fixture.ready(), "v1", split=split) + + +def test_without_a_recipe_nothing_about_the_export_moves(tmp_path: Path) -> None: + fixture = Fixture(tmp_path) + release = _published(fixture, split=None) + dest = tmp_path / "out" + plugin = ImageWriter() + + result = fixture.releases.export(release.id, plugin, dest) + + manifest = fixture.releases.manifest(release.id) + assert plugin.handed == manifest + assert result.preprocessing is None + assert (result.source_file_count, result.augmented_file_count) == (5, 0) + assert (result.source_annotation_count, result.augmented_annotation_count) == (5, 0) + assert _report(dest)["preprocessing"] is None + fixture.close() + + +def test_a_resize_recipe_transforms_every_base_image_under_its_own_name( + tmp_path: Path, +) -> None: + fixture = Fixture(tmp_path) + release = _published(fixture, split=None) + dest = tmp_path / "out" + plugin, driver = ImageWriter(), MarkingDriver() + + result = fixture.releases.export( + release.id, plugin, dest, recipe=LETTERBOX, recipe_name="lb", drivers=drivers_of(driver) + ) + + manifest = fixture.releases.manifest(release.id) + assert plugin.handed is not None + assert [a.content_hash for a in plugin.handed.assets] == [ + a.content_hash for a in manifest.assets + ] + assert {(a.width, a.height) for a in plugin.handed.assets} == {(640, 640)} + for asset in manifest.assets: + written = (dest / "images" / f"{asset.content_hash}.bin").read_bytes() + assert written == fixture.blob_path(asset.content_hash).read_bytes() + b"|resize:0" + assert driver.calls == [("resize", 0)] * 5 + assert result.preprocessing is not None + assert result.preprocessing.recipe_name == "lb" + assert result.preprocessing.spec == LETTERBOX + assert result.preprocessing.recipe_hash == recipe_hash(LETTERBOX) + assert result.preprocessing.pillow_version + assert [(row.file, row.variant) for row in result.preprocessing.mapping] == sorted( + (f"images/{asset.content_hash}.bin", 0) for asset in manifest.assets + ) + assert (result.source_file_count, result.augmented_file_count) == (5, 0) + assert result.file_count == 10 + fixture.close() + + +def test_augmentation_writes_variants_for_the_train_fold_under_aug_keys( + tmp_path: Path, +) -> None: + fixture = Fixture(tmp_path) + release = _published(fixture, split=SPLIT) + dest = tmp_path / "out" + plugin, driver = ImageWriter(), MarkingDriver() + + result = fixture.releases.export( + release.id, plugin, dest, recipe=AUGMENTED, drivers=drivers_of(driver) + ) + + manifest = fixture.releases.manifest(release.id) + folds = fixture.releases.assignment(release.id) + assert plugin.handed is not None + train = {asset.content_hash for asset in manifest.assets if asset.asset_id in folds.train} + expected_keys = sorted( + [asset.content_hash for asset in manifest.assets] + + [f"{content_hash}-aug{k}" for content_hash in train for k in (1, 2)] + ) + assert sorted(a.content_hash for a in plugin.handed.assets) == expected_keys + # A variant keeps its source's asset id and gets derived annotation ids. + by_key = {a.content_hash: a for a in plugin.handed.assets} + source = next(a for a in manifest.assets if a.content_hash in train) + variant = by_key[f"{source.content_hash}-aug1"] + assert variant.asset_id == source.asset_id + assert [a.id for a in variant.annotations] == [ + uuid5(VARIANT_ID_NAMESPACE, f"{a.id}-aug1") for a in source.annotations + ] + # The variant's bytes went through the resize and both augmentations. + written = (dest / "images" / f"{source.content_hash}-aug1.bin").read_bytes() + assert written == ( + fixture.blob_path(source.content_hash).read_bytes() + b"|resize:1|augment:1|augment:1" + ) + assert (result.source_file_count, result.augmented_file_count) == (5, 2 * len(train)) + assert result.source_annotation_count == 5 + assert result.augmented_annotation_count == 2 * len(train) + assert result.file_count == result.source_file_count + result.augmented_file_count + len( + expected_keys + ) + assert result.preprocessing is not None + assert {row.variant for row in result.preprocessing.mapping} == {0, 1, 2} + report = _report(dest) + assert report["preprocessing"]["recipe_hash"] == recipe_hash(AUGMENTED) + assert len(report["preprocessing"]["mapping"]) == len(expected_keys) + fixture.close() + + +def test_augmentation_against_a_release_without_a_split_is_refused_at_every_stage( + tmp_path: Path, +) -> None: + fixture = Fixture(tmp_path) + release = _published(fixture, split=None) + dest = tmp_path / "out" + plugin, driver = ImageWriter(), MarkingDriver() + + with pytest.raises(AugmentationRequiresSplit): + fixture.releases.check_export(release.id, plugin, recipe=AUGMENTED) + with pytest.raises(AugmentationRequiresSplit): + fixture.releases.require_export_consent( + release.id, plugin, allow_lossy=False, recipe=AUGMENTED + ) + with pytest.raises(AugmentationRequiresSplit): + fixture.releases.export( + release.id, plugin, dest, recipe=AUGMENTED, drivers=drivers_of(driver) + ) + assert not dest.exists() + assert driver.calls == [] + fixture.close() + + +def test_consent_is_asked_before_any_step_runs(tmp_path: Path) -> None: + class Lossy(ImageWriter): + format_name = "lossy-writer" + lossy = True + + fixture = Fixture(tmp_path) + release = _published(fixture, split=None) + driver = MarkingDriver() + + with pytest.raises(LossyExportNotConsented): + fixture.releases.export( + release.id, Lossy(), tmp_path / "out", recipe=LETTERBOX, drivers=drivers_of(driver) + ) + assert driver.calls == [] + fixture.close() + + +def test_a_missing_driver_is_refused_by_kind(tmp_path: Path) -> None: + fixture = Fixture(tmp_path) + release = _published(fixture, split=None) + + with pytest.raises(PreprocessingDriverNotFound) as caught: + fixture.releases.export( + release.id, ImageWriter(), tmp_path / "out", recipe=LETTERBOX, drivers={} + ) + assert "'resize'" in str(caught.value) + fixture.close() + + +def _polyline(asset_id: UUID) -> Annotation: + return Annotation( + asset_id=asset_id, + label_class="lane", + schema_version=1, + geometry=PolylineGeometry(points=[(0.0, 0.0), (10.0, 20.0)]), + provenance="human", + ) + + +BOXES_ONLY_TARGET = ExportTarget( + name="boxes-only", + label="Boxes only", + family=TargetFamily.OTHER, + tasks=frozenset({Task.DETECT}), + supported_geometries=frozenset({GeometryType.BBOX}), + hints=PreprocessingHints( + recommended_size=None, + recommended_strategy=None, + trainer_resizes=True, + augmentation_common=False, + ), +) + + +def test_a_geometry_the_target_drops_never_reaches_a_step_that_refuses_it( + tmp_path: Path, +) -> None: + """Narrowing runs before the transform: a dropped polyline cannot make rot90 refuse.""" + fixture = Fixture(tmp_path) + fixture.schemas.create_version( + fixture.project.id, [SIGN, LabelClass(name="lane", geometries=(GeometryType.POLYLINE,))] + ) + batch = fixture.batches.create(fixture.project.id, "mixed", fixture.asset_ids) + fixture.batches.approve(batch.id) + (job,) = fixture.batches.jobs(batch.id) + fixture.batches.start(batch.id) + fixture.jobs.start(job.id) + for asset_id in fixture.asset_ids: + fixture.annotations.add(job.id, [_box(asset_id), _polyline(asset_id)]) + fixture.jobs.complete(job.id) + fixture.batches.complete(batch.id) + fixture.datasets.promote(batch.id) + release = fixture.releases.publish(fixture.dataset_id, "v1", split=SPLIT) + rot90 = RecipeSpec(target=None, steps=(AugmentStep(op=AugmentOp.ROT90),), variants_per_asset=1) + plugin = ImageWriter() + + with pytest.raises(PreprocessingStepUnsupportedGeometry) as caught: + fixture.releases.check_export(release.id, plugin, recipe=rot90) + assert (caught.value.step, caught.value.geometry) == ("rot90", "polyline") + + fixture.releases.check_export(release.id, plugin, target=BOXES_ONLY_TARGET, recipe=rot90) + result = fixture.releases.export( + release.id, + plugin, + tmp_path / "out", + allow_lossy=True, + target=BOXES_ONLY_TARGET, + recipe=rot90, + drivers=drivers_of(MarkingDriver()), + ) + assert plugin.handed is not None + assert {a.geometry.type for asset in plugin.handed.assets for a in asset.annotations} == { + "bbox" + } + assert result.augmented_file_count == len(fixture.releases.assignment(release.id).train) + fixture.close() + + +def test_the_check_report_is_the_same_document_with_or_without_a_recipe( + tmp_path: Path, +) -> None: + fixture = Fixture(tmp_path) + release = _published(fixture, split=SPLIT) + plugin = ImageWriter() + assert fixture.releases.check_export(release.id, plugin) == fixture.releases.check_export( + release.id, plugin, recipe=AUGMENTED + ) + fixture.close() + + +def test_a_plugin_naming_files_its_own_way_is_traced_by_digest(tmp_path: Path) -> None: + fixture = Fixture(tmp_path) + release = _published(fixture, split=SPLIT) + dest = tmp_path / "out" + + result = fixture.releases.export( + release.id, RenamingWriter(), dest, recipe=AUGMENTED, drivers=drivers_of(MarkingDriver()) + ) + + assert result.preprocessing is not None + mapping = result.preprocessing.mapping + assert len(mapping) == result.source_file_count + result.augmented_file_count + for row in mapping: + assert row.file.startswith("images/") + assert (dest / row.file).is_file() + assert len({row.file for row in mapping}) == len(mapping) + fixture.close() diff --git a/tests/kernel/test_preprocessing_recipe_service.py b/tests/kernel/test_preprocessing_recipe_service.py index 0f705853..20419258 100644 --- a/tests/kernel/test_preprocessing_recipe_service.py +++ b/tests/kernel/test_preprocessing_recipe_service.py @@ -160,7 +160,9 @@ def test_a_recipe_is_resolved_for_a_release_through_its_project(tmp_path: Path) fixture = Fixture(tmp_path) fixture.schemas.create_version(fixture.project.id, [SIGN]) fixture.promote() - release = fixture.releases.publish(fixture.dataset_id, "v1", split=SplitRecipe(train=0.6, val=0.2, test=0.2, seed=1)) + release = fixture.releases.publish( + fixture.dataset_id, "v1", split=SplitRecipe(train=0.6, val=0.2, test=0.2, seed=1) + ) recipes = PreprocessingRecipeService(fixture.workspace) created = recipes.create(fixture.project.id, "r", LETTERBOX) diff --git a/tests/kernel/test_release_service.py b/tests/kernel/test_release_service.py index f4aac2d0..93fd7cca 100644 --- a/tests/kernel/test_release_service.py +++ b/tests/kernel/test_release_service.py @@ -1245,10 +1245,11 @@ def test_the_report_is_written_into_the_export_directory(tmp_path: Path) -> None assert written["compatible"] is False assert written["excluded_annotations"] == 2 # Key-for-key what `visionset.wire` hands the CLI and MCP, and what - # `ExportCompatibilityOut` puts on the API's refusal. One document, four - # places — the report format is stable across all three, and it - # is the reason `format_name` carries a serialization alias. - assert written == wire.export_compatibility(result.compatibility) + # `ExportCompatibilityOut` puts on the API's refusal, plus the one key the + # file carries on its own: which recipe ran, `null` when none did. One + # document, four places — the report format is stable across all three, + # and it is the reason `format_name` carries a serialization alias. + assert written == {**wire.export_compatibility(result.compatibility), "preprocessing": None} fixture.close() @@ -1260,8 +1261,9 @@ def test_the_report_parses_back_into_the_model_that_wrote_it(tmp_path: Path) -> result = fixture.releases.export(release.id, _BoxesOnly(), dest, allow_lossy=True) - written = (dest / EXPORT_REPORT_FILENAME).read_text(encoding="utf-8") - assert ExportCompatibility.model_validate_json(written) == result.compatibility + written = json.loads((dest / EXPORT_REPORT_FILENAME).read_text(encoding="utf-8")) + written.pop("preprocessing") + assert ExportCompatibility.model_validate(written) == result.compatibility fixture.close() diff --git a/tests/mcp/test_release_tools.py b/tests/mcp/test_release_tools.py index b4e14b23..78219770 100644 --- a/tests/mcp/test_release_tools.py +++ b/tests/mcp/test_release_tools.py @@ -628,4 +628,4 @@ def test_an_export_carries_the_report_it_was_consented_to( # …and the same document is on disk, which is what makes the answer readable # by whatever picks the directory up later. written = json.loads((dest / EXPORT_REPORT_FILENAME).read_text(encoding="utf-8")) - assert written == result["compatibility"] + assert written == {**result["compatibility"], "preprocessing": result["preprocessing"]} diff --git a/tests/server/test_releases.py b/tests/server/test_releases.py index c4b00b82..05a05ed9 100644 --- a/tests/server/test_releases.py +++ b/tests/server/test_releases.py @@ -715,3 +715,117 @@ def test_the_report_travels_inside_the_archive_as_well(client: TestClient, relea response = exported(client, release, format="polygons-only", allow_lossy="true") assert EXPORT_REPORT_FILENAME in _names_in(response.content) + + +# --- a recipe on the request ----------------------------------------------------- +# +# The recipe routes themselves are tested in `test_preprocessing_recipes.py`; +# here a recipe is arranged through the kernel so the export routes can be held +# to what they promise about one: resolved by name through the release's +# project, refused now for what a worker would otherwise discover, and carried +# to the job as a snapshot. + + +def _recipe(tmp_path: Path, client: TestClient, release: str, name: str, spec: Any) -> None: + from visionset.kernel.domain import RecipeSpec + from visionset.kernel.services import PreprocessingRecipeService, WorkspaceService + + project_id = _project_of(client, release) + workspace = WorkspaceService.open(tmp_path / "ws") + try: + PreprocessingRecipeService(workspace).create( + project_id, name, RecipeSpec.model_validate(spec) + ) + finally: + workspace.close() + + +def _project_of(client: TestClient, release: str) -> Any: + from uuid import UUID + + dataset_id = client.get(f"/releases/{release}").json()["dataset_id"] + return UUID(client.get(f"/datasets/{dataset_id}").json()["project_id"]) + + +LETTERBOX = { + "target": "yolo11", + "steps": [{"kind": "resize", "strategy": "letterbox", "width": 64, "height": 64}], + "variants_per_asset": 0, +} +FLIPS = {"target": None, "steps": [{"kind": "augment", "op": "hflip"}], "variants_per_asset": 1} + + +def test_an_unknown_recipe_is_404_on_both_routes_and_creates_no_job( + client: TestClient, release: str, runner: InlineDispatcher +) -> None: + with_exporters(client.app, WritingExporter()) + params = {"format": "writing", "recipe": "nope"} + before = client.get("/background-jobs").json()["total"] + + checked = client.get(f"/releases/{release}/export-compatibility", params=params) + launched = client.post(f"/releases/{release}/export", params=params) + + for response in (checked, launched): + assert response.status_code == 404, response.text + assert response.json()["code"] == "PREPROCESSING_RECIPE_NOT_FOUND" + assert client.get("/background-jobs").json()["total"] == before + + +def test_an_augmenting_recipe_over_an_unsplit_release_is_refused_on_the_request( + tmp_path: Path, client: TestClient, release: str +) -> None: + with_exporters(client.app, WritingExporter()) + _recipe(tmp_path, client, release, "flips", FLIPS) + params = {"format": "writing", "recipe": "flips"} + before = client.get("/background-jobs").json()["total"] + + checked = client.get(f"/releases/{release}/export-compatibility", params=params) + launched = client.post(f"/releases/{release}/export", params=params) + + for response in (checked, launched): + assert response.status_code == 409, response.text + assert response.json()["code"] == "AUGMENTATION_REQUIRES_SPLIT" + assert client.get("/background-jobs").json()["total"] == before + + +def test_a_recipe_is_carried_to_the_job_as_a_snapshot_and_recorded_in_the_report( + tmp_path: Path, client: TestClient, release: str +) -> None: + with_exporters(client.app, WritingExporter()) + _recipe(tmp_path, client, release, "lb", LETTERBOX) + + launched = client.post( + f"/releases/{release}/export", params={"format": "writing", "recipe": "lb"} + ) + assert launched.status_code == 202, launched.text + job = client.get(f"/background-jobs/{launched.json()['id']}").json() + + assert job["state"] == "succeeded", job + assert job["result"]["recipe_hash"] + assert (job["result"]["source_file_count"], job["result"]["augmented_file_count"]) == (0, 0) + archive = client.get(f"/background-jobs/{job['id']}/artifact").content + with zipfile.ZipFile(io.BytesIO(archive)) as opened: + report = json.loads(opened.read(EXPORT_REPORT_FILENAME)) + assert report["preprocessing"]["recipe_name"] == "lb" + assert report["preprocessing"]["recipe_hash"] == job["result"]["recipe_hash"] + from visionset.kernel.domain import RecipeSpec + + assert RecipeSpec.model_validate(report["preprocessing"]["spec"]) == RecipeSpec.model_validate( + LETTERBOX + ) + assert report["preprocessing"]["mapping"] == [] + + +def test_the_compatibility_report_is_unchanged_by_a_recipe( + tmp_path: Path, client: TestClient, release: str +) -> None: + with_exporters(client.app, WritingExporter()) + _recipe(tmp_path, client, release, "lb", LETTERBOX) + + plain = client.get(f"/releases/{release}/export-compatibility", params={"format": "writing"}) + with_recipe = client.get( + f"/releases/{release}/export-compatibility", params={"format": "writing", "recipe": "lb"} + ) + + assert with_recipe.status_code == 200, with_recipe.text + assert with_recipe.json() == plain.json() From 6096cc79376d07a86374fd27eec7fd3752b7b4e3 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:38:18 -0700 Subject: [PATCH 3/9] feat(server): pre-processing recipe routes, a preview, and their wire projections A project's recipes are a plain resource under /projects/{id}/preprocessing-recipes with no state gating, and POST /projects/{id}/preprocessing-preview renders one asset through a spec on the export's own kernel path, capped to 512 pixels and never cached. The wire projections pair with the server models under the JSON-contract test, and the contract and the generated client are regenerated. Part of the pre-processing epic (#785). --- frontend/ui-core/src/generated/api.ts | 779 +++ frontend/ui-core/src/generated/checks.ts | 33 + openapi.json | 4293 ++++++++++------- src/visionset/kernel/domain/__init__.py | 2 + .../kernel/domain/preprocessing_transform.py | 19 + .../services/preprocessing_recipe_service.py | 135 +- src/visionset/server/dependencies.py | 15 +- src/visionset/server/models.py | 245 + src/visionset/server/routes/__init__.py | 3 + .../server/routes/preprocessing_recipes.py | 147 + src/visionset/wire/__init__.py | 43 + tests/cli/test_json_contract.py | 16 +- tests/fixtures/samples.py | 81 + tests/server/test_preprocessing_recipes.py | 238 + 14 files changed, 4413 insertions(+), 1636 deletions(-) create mode 100644 src/visionset/server/routes/preprocessing_recipes.py create mode 100644 tests/server/test_preprocessing_recipes.py diff --git a/frontend/ui-core/src/generated/api.ts b/frontend/ui-core/src/generated/api.ts index 8420d08b..e5d0baf4 100644 --- a/frontend/ui-core/src/generated/api.ts +++ b/frontend/ui-core/src/generated/api.ts @@ -2172,6 +2172,113 @@ export interface paths { patch?: never; trace?: never; }; + "/projects/{project_id}/preprocessing-preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Preview Preprocessing + * @description Render one asset through a spec, the way an export would write it. + * + * The same kernel path as an export, over the one asset as if it were in the + * train fold, so every variant the spec declares can be seen whether or not a + * release exists. The image is capped to 512 pixels on its longer side, with + * the annotations scaled to match, and comes back base64-encoded beside its + * `media_type`. Never cached: the spec is the request's own. + * + * An unknown project is 404 `PROJECT_NOT_FOUND` and an asset outside it 404 + * `ASSET_NOT_FOUND`. A step that cannot transform a geometry the asset carries + * is 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`, and a step needing a source + * size the asset never recorded, or an asset whose bytes are gone, is 409 + * `EXPORT_SOURCE_UNREADABLE`. A rendered image in an encoding this server + * cannot name is 422 `UNSUPPORTED_MEDIA`, and a step kind no installed driver + * applies is 500 `PREPROCESSING_DRIVER_NOT_FOUND`. + */ + post: operations["preview_preprocessing"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/projects/{project_id}/preprocessing-recipes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Preprocessing Recipes + * @description Every recipe of the project, oldest first. An unknown project is 404 `PROJECT_NOT_FOUND`. + */ + get: operations["list_preprocessing_recipes"]; + put?: never; + /** + * Create Preprocessing Recipe + * @description Store a new recipe under a name. + * + * A recipe binds at export time, by name, and the export keeps the spec by + * value. An unknown project is 404 `PROJECT_NOT_FOUND`; a name the project + * already uses is 409 `PREPROCESSING_RECIPE_NAME_TAKEN`; a name that is not a + * slug is 422 `INVALID_NAME`, and a spec that breaks the recipe grammar is a + * 422 `VALIDATION_ERROR` naming the rule. + */ + post: operations["create_preprocessing_recipe"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/projects/{project_id}/preprocessing-recipes/{name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Preprocessing Recipe + * @description The recipe under that name. + * + * An unknown project is 404 `PROJECT_NOT_FOUND` and an unknown name 404 + * `PREPROCESSING_RECIPE_NOT_FOUND`. + */ + get: operations["get_preprocessing_recipe"]; + /** + * Update Preprocessing Recipe + * @description Replace the recipe whole, and rename it when the body's `name` differs. + * + * Whole-value: the spec is one value with cross-field rules, so there is no + * field-at-a-time edit. Nothing downstream depends on the stored value — an + * export keeps its own copy — so no revision is asked for. An unknown project + * is 404 `PROJECT_NOT_FOUND`, an unknown name 404 + * `PREPROCESSING_RECIPE_NOT_FOUND`; a rename onto a name the project already + * uses is 409 `PREPROCESSING_RECIPE_NAME_TAKEN`, and a new name that is not + * a slug is 422 `INVALID_NAME`. + */ + put: operations["update_preprocessing_recipe"]; + post?: never; + /** + * Delete Preprocessing Recipe + * @description Remove the recipe. + * + * No confirmation: every export that used it kept its own copy, so nothing + * that exists is lost. An unknown project is 404 `PROJECT_NOT_FOUND` and an + * unknown name 404 `PREPROCESSING_RECIPE_NOT_FOUND`. + */ + delete: operations["delete_preprocessing_recipe"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/projects/{project_id}/schema": { parameters: { query?: never; @@ -3251,6 +3358,32 @@ export interface components { */ required: boolean; }; + /** + * AugmentOp + * @description An augmentation a recipe can apply when generating variants. + * @enum {string} + */ + AugmentOp: "hflip" | "brightness_contrast" | "rot90"; + /** + * AugmentStepBody + * @description One augmentation applied when generating variants. + * + * `amount` bounds the brightness and contrast factors, drawn uniformly from + * `[1 - amount, 1 + amount]`, and means nothing to `hflip` or `rot90`. + */ + AugmentStepBody: { + /** + * Amount + * @default 0.2 + */ + amount: number; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "augment"; + op: components["schemas"]["AugmentOp"]; + }; /** * BackgroundJobOut * @description One unit of background work, and how far it has got. @@ -4751,6 +4884,150 @@ export interface components { /** Trainer Resizes */ trainer_resizes: boolean; }; + /** + * PreprocessingPreviewBody + * @description One asset to render through a spec, and which variant of it. + * + * `variant` 0 is the base image; `1` to `spec.variants_per_asset` are the + * augmented outputs. A variant the spec does not make is refused. + */ + PreprocessingPreviewBody: { + /** + * Asset Id + * Format: uuid + */ + asset_id: string; + spec: components["schemas"]["RecipeSpecBody"]; + /** + * Variant + * @default 0 + */ + variant: number; + }; + /** + * PreprocessingPreviewOut + * @description One asset through a recipe, rendered for a screen. + * + * `image_base64` is the transformed image, encoded as `media_type`, capped to + * 512 pixels on its longer side with `annotations` scaled to match. `width` + * and `height` are the rendered size; they are null only when the asset never + * recorded a size and no resize step decided one. + */ + PreprocessingPreviewOut: { + /** Annotations */ + annotations: components["schemas"]["PreviewAnnotationOut"][]; + /** + * Asset Id + * Format: uuid + */ + asset_id: string; + /** Height */ + height: number | null; + /** Image Base64 */ + image_base64: string; + /** Media Type */ + media_type: string; + /** Variant */ + variant: number; + /** Width */ + width: number | null; + }; + /** + * PreprocessingRecipeCreate + * @description A new recipe: its name and what it does. + * + * `name` is a slug — lowercase letters, digits, dots, hyphens and + * underscores, starting with a letter or digit, at most 64 characters — + * unique within the project. + */ + PreprocessingRecipeCreate: { + /** Name */ + name: string; + spec: components["schemas"]["RecipeSpecBody"]; + }; + /** + * PreprocessingRecipeOut + * @description A named pre-processing recipe of a project. + * + * A recipe binds at export time — `POST /releases/{release_id}/export?recipe=` + * takes `name` — and the export keeps the spec by value, so editing or + * deleting a recipe never alters a past export. There is no state and no + * `allowed_actions`: every operation is always offered. + */ + PreprocessingRecipeOut: { + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Id + * Format: uuid + */ + id: string; + /** Name */ + name: string; + /** + * Project Id + * Format: uuid + */ + project_id: string; + spec: components["schemas"]["RecipeSpecBody"]; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** + * PreprocessingRecipePage + * @description A page of pre-processing recipes. + */ + PreprocessingRecipePage: { + /** Items */ + items: components["schemas"]["PreprocessingRecipeOut"][]; + /** Total */ + total: number; + }; + /** + * PreprocessingRecipeUpdate + * @description The whole recipe, replaced. `name` renames it when it differs from the path. + */ + PreprocessingRecipeUpdate: { + /** Name */ + name: string; + spec: components["schemas"]["RecipeSpecBody"]; + }; + /** + * PreviewAnnotationOut + * @description One label as the export would write it, placed on the transformed image. + * + * `id` is the source annotation's id for the base image and `"{id}-aug{k}"` + * for variant `k`, so a rendered label traces to the label it came from. + */ + PreviewAnnotationOut: { + /** Attributes */ + attributes: { + [key: string]: boolean | number | string; + }; + /** Confidence */ + confidence: number | null; + /** Geometry */ + geometry: components["schemas"]["BboxBody"] | components["schemas"]["PolygonBody"] | components["schemas"]["PolylineBody"] | components["schemas"]["ClassificationBody"]; + /** Id */ + id: string; + /** Label Class */ + label_class: string; + /** Model Ref */ + model_ref: string | null; + /** + * Provenance + * @enum {string} + */ + provenance: "human" | "model" | "import"; + /** Schema Version */ + schema_version: number; + }; /** * ProgressCounts * @description How many assets sit in each annotation state. @@ -4923,6 +5200,30 @@ export interface components { /** Total */ total: number; }; + /** + * RecipeSpecBody + * @description What a recipe does to every exported image, and how many variants it makes. + * + * At most one `resize` step, and it comes first. `variants_per_asset` counts + * augmented outputs — 0 to 8 — and requires at least one `augment` step, + * which in turn requires at least one variant; each augmentation appears at + * most once. `target` records which export target's hints the recipe was + * written from and is informational: a recipe applies to any export. + */ + RecipeSpecBody: { + /** + * Steps + * @default [] + */ + steps: (components["schemas"]["ResizeStepBody"] | components["schemas"]["AugmentStepBody"])[]; + /** Target */ + target?: string | null; + /** + * Variants Per Asset + * @default 0 + */ + variants_per_asset: number; + }; /** * ReleaseCreate * @description What publishing a release needs. @@ -5001,6 +5302,39 @@ export interface components { */ release_id: string; }; + /** + * ResizeStepBody + * @description Bring every exported image to one size, by one strategy. + * + * `pad_value` is the grey a letterbox pads with and means nothing to + * `stretch`. Sizes are 32 to 8192 pixels a side. + */ + ResizeStepBody: { + /** Height */ + height: number; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + kind: "resize"; + /** + * Pad Value + * @default 114 + */ + pad_value: number; + strategy: components["schemas"]["ResizeStrategy"]; + /** Width */ + width: number; + }; + /** + * ResizeStrategy + * @description How an image reaches a requested size. + * + * ``stretch`` scales each axis independently onto the size; ``letterbox`` + * scales by the limiting axis and pads the rest, preserving aspect ratio. + * @enum {string} + */ + ResizeStrategy: "stretch" | "letterbox"; /** * ResumeKind * @description What an open batch is being offered for, and so what `next_asset_id` is. @@ -10437,6 +10771,451 @@ export interface operations { }; }; }; + preview_preprocessing: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PreprocessingPreviewBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreprocessingPreviewOut"]; + }; + }; + /** @description Missing or invalid bearer token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description No such resource */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description The resource's state refuses this request */ + 409: { + 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_preprocessing_recipes: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreprocessingRecipePage"]; + }; + }; + /** @description Missing or invalid bearer token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description No such resource */ + 404: { + 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"]; + }; + }; + }; + }; + create_preprocessing_recipe: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PreprocessingRecipeCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreprocessingRecipeOut"]; + }; + }; + /** @description Missing or invalid bearer token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description No such resource */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description The resource's state refuses this request */ + 409: { + 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"]; + }; + }; + }; + }; + get_preprocessing_recipe: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + /** @description The recipe's name, unique within the project. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreprocessingRecipeOut"]; + }; + }; + /** @description Missing or invalid bearer token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description No such resource */ + 404: { + 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"]; + }; + }; + }; + }; + update_preprocessing_recipe: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + /** @description The recipe's name, unique within the project. */ + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PreprocessingRecipeUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PreprocessingRecipeOut"]; + }; + }; + /** @description Missing or invalid bearer token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description No such resource */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description The resource's state refuses this request */ + 409: { + 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"]; + }; + }; + }; + }; + delete_preprocessing_recipe: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + /** @description The recipe's name, unique within the project. */ + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing or invalid bearer token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorBody"]; + }; + }; + /** @description No such resource */ + 404: { + 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"]; + }; + }; + }; + }; get_active_schema: { parameters: { query?: never; diff --git a/frontend/ui-core/src/generated/checks.ts b/frontend/ui-core/src/generated/checks.ts index d172067b..cf4f85e1 100644 --- a/frontend/ui-core/src/generated/checks.ts +++ b/frontend/ui-core/src/generated/checks.ts @@ -273,6 +273,33 @@ export const checkPreLabelExclusionOut: Check = export const checkPreLabelPlanOut: Check = /*#__PURE__*/ object({ "asked_classes": [true, arrayOf(isString)], "excluded_classes": [true, arrayOf(checkPreLabelExclusionOut)], "produces": [true, arrayOf(checkGeometryType)], "schema_version": [true, isInteger] } as const); +export const checkPreviewAnnotationOut: Check = + /*#__PURE__*/ object({ "attributes": [true, mapOf(either([isBoolean, isNumber, isString] as const))], "confidence": [true, either([isNumber, isNull] as const)], "geometry": [true, tagged("type", { "bbox": checkBboxBody, "classification_tag": checkClassificationBody, "polygon": checkPolygonBody, "polyline": checkPolylineBody })], "id": [true, isString], "label_class": [true, isString], "model_ref": [true, either([isString, isNull] as const)], "provenance": [true, oneOf(["human", "model", "import"] as const)], "schema_version": [true, isInteger] } as const); + +export const checkPreprocessingPreviewOut: Check = + /*#__PURE__*/ object({ "annotations": [true, arrayOf(checkPreviewAnnotationOut)], "asset_id": [true, isString], "height": [true, either([isInteger, isNull] as const)], "image_base64": [true, isString], "media_type": [true, isString], "variant": [true, isInteger], "width": [true, either([isInteger, isNull] as const)] } as const); + +export const checkAugmentOp: Check = + /*#__PURE__*/ oneOf(["hflip", "brightness_contrast", "rot90"] as const); + +export const checkAugmentStepBody: Check = + /*#__PURE__*/ object({ "amount": [true, isNumber], "kind": [true, lit("augment")], "op": [true, checkAugmentOp] } as const); + +export const checkResizeStrategy: Check = + /*#__PURE__*/ oneOf(["stretch", "letterbox"] as const); + +export const checkResizeStepBody: Check = + /*#__PURE__*/ object({ "height": [true, isInteger], "kind": [true, lit("resize")], "pad_value": [true, isInteger], "strategy": [true, checkResizeStrategy], "width": [true, isInteger] } as const); + +export const checkRecipeSpecBody: Check = + /*#__PURE__*/ object({ "steps": [true, arrayOf(tagged("kind", { "augment": checkAugmentStepBody, "resize": checkResizeStepBody }))], "target": [false, either([isString, isNull] as const)], "variants_per_asset": [true, isInteger] } as const); + +export const checkPreprocessingRecipeOut: Check = + /*#__PURE__*/ object({ "created_at": [true, isString], "id": [true, isString], "name": [true, isString], "project_id": [true, isString], "spec": [true, checkRecipeSpecBody], "updated_at": [true, isString] } as const); + +export const checkPreprocessingRecipePage: Check = + /*#__PURE__*/ object({ "items": [true, arrayOf(checkPreprocessingRecipeOut)], "total": [true, isInteger] } as const); + export const checkProjectOut: Check = /*#__PURE__*/ object({ "created_at": [true, either([isString, isNull] as const)], "description": [true, either([isString, isNull] as const)], "id": [true, isString], "name": [true, isString], "thumbnail_asset_id": [true, either([isString, isNull] as const)], "thumbnail_hash": [true, either([isString, isNull] as const)] } as const); @@ -400,12 +427,14 @@ export const checkCompleteJob = checkJobOut; export const checkCreateBatch = checkBatchOut; export const checkCreateCorrectionBatch = checkBatchOut; export const checkCreateInferenceConnection = checkConnectionOut; +export const checkCreatePreprocessingRecipe = checkPreprocessingRecipeOut; export const checkCreateProject = checkProjectOut; export const checkCreateSchemaVersion = checkSchemaPublicationOut; export const checkDatasetStats = checkDatasetStatsOut; export const checkDeleteAnnotations = checkNoContent; export const checkDeleteBatch = checkNoContent; export const checkDeleteInferenceConnection = checkNoContent; +export const checkDeletePreprocessingRecipe = checkNoContent; export const checkDeleteProject = checkNoContent; export const checkDiscardSchemaDraft = checkNoContent; export const checkDownloadConnectionWeights = checkBackgroundJobOut; @@ -423,6 +452,7 @@ export const checkGetInferenceConnection = checkConnectionOut; export const checkGetIngestJob = checkIngestJobOut; export const checkGetJob = checkJobOut; export const checkGetJobProgress = checkProgressCounts; +export const checkGetPreprocessingRecipe = checkPreprocessingRecipeOut; export const checkGetProject = checkProjectOut; export const checkGetProjectDataset = checkDatasetOut; export const checkGetProjectStats = checkProjectStatsOut; @@ -449,6 +479,7 @@ export const checkListExportTargets = checkExportTargetPage; export const checkListFormats = checkFormatPage; export const checkListInferenceConnections = checkConnectionPage; export const checkListIngestJobs = checkIngestJobPage; +export const checkListPreprocessingRecipes = checkPreprocessingRecipePage; export const checkListProjectAssets = checkAssetPage; export const checkListProjects = checkProjectPage; export const checkListProviders = checkProviderPage; @@ -460,6 +491,7 @@ export const checkPreLabelBatch = checkPreLabelFanOutOut; export const checkPreLabelJob = checkBackgroundJobOut; export const checkPreLabelPlan = checkPreLabelPlanOut; export const checkPreLabelProjectBatches = checkPreLabelFanOutOut; +export const checkPreviewPreprocessing = checkPreprocessingPreviewOut; export const checkPreviewSchemaChange = checkSchemaChangePreviewOut; export const checkPromoteBatch = checkAssetPage; export const checkPublishRelease = checkReleaseOut; @@ -480,4 +512,5 @@ export const checkSuggestRegion = checkSuggestionOut; export const checkTestConnectionEndpoint = checkConnectionOut; export const checkUpdateAnnotations = checkAnnotationPage; export const checkUpdateInferenceConnection = checkConnectionOut; +export const checkUpdatePreprocessingRecipe = checkPreprocessingRecipeOut; export const checkVerifyRelease = checkReleaseVerificationOut; diff --git a/openapi.json b/openapi.json index b205e5e5..e455bc60 100644 --- a/openapi.json +++ b/openapi.json @@ -828,6 +828,41 @@ "title": "AttributeBody", "type": "object" }, + "AugmentOp": { + "description": "An augmentation a recipe can apply when generating variants.", + "enum": [ + "hflip", + "brightness_contrast", + "rot90" + ], + "title": "AugmentOp", + "type": "string" + }, + "AugmentStepBody": { + "additionalProperties": false, + "description": "One augmentation applied when generating variants.\n\n`amount` bounds the brightness and contrast factors, drawn uniformly from\n`[1 - amount, 1 + amount]`, and means nothing to `hflip` or `rot90`.", + "properties": { + "amount": { + "default": 0.2, + "title": "Amount", + "type": "number" + }, + "kind": { + "const": "augment", + "default": "augment", + "title": "Kind", + "type": "string" + }, + "op": { + "$ref": "#/components/schemas/AugmentOp" + } + }, + "required": [ + "op" + ], + "title": "AugmentStepBody", + "type": "object" + }, "BackgroundJobOut": { "description": "One unit of background work, and how far it has got.", "properties": { @@ -4022,101 +4057,119 @@ "title": "PreprocessingHintsOut", "type": "object" }, - "ProgressCounts": { - "description": "How many assets sit in each annotation state.", + "PreprocessingPreviewBody": { + "additionalProperties": false, + "description": "One asset to render through a spec, and which variant of it.\n\n`variant` 0 is the base image; `1` to `spec.variants_per_asset` are the\naugmented outputs. A variant the spec does not make is refused.", "properties": { - "accepted": { - "title": "Accepted", - "type": "integer" - }, - "annotated": { - "title": "Annotated", - "type": "integer" - }, - "pre_labeled": { - "title": "Pre Labeled", - "type": "integer" - }, - "review_pending": { - "title": "Review Pending", - "type": "integer" - }, - "skipped": { - "title": "Skipped", - "type": "integer" + "asset_id": { + "format": "uuid", + "title": "Asset Id", + "type": "string" }, - "total": { - "title": "Total", - "type": "integer" + "spec": { + "$ref": "#/components/schemas/RecipeSpecBody" }, - "unannotated": { - "title": "Unannotated", + "variant": { + "default": 0, + "title": "Variant", "type": "integer" } }, "required": [ - "unannotated", - "pre_labeled", - "annotated", - "skipped", - "review_pending", - "accepted", - "total" + "spec", + "asset_id" ], - "title": "ProgressCounts", + "title": "PreprocessingPreviewBody", "type": "object" }, - "ProjectCreate": { - "additionalProperties": false, - "description": "What creating a project needs.", + "PreprocessingPreviewOut": { + "description": "One asset through a recipe, rendered for a screen.\n\n`image_base64` is the transformed image, encoded as `media_type`, capped to\n512 pixels on its longer side with `annotations` scaled to match. `width`\nand `height` are the rendered size; they are null only when the asset never\nrecorded a size and no resize step decided one.", "properties": { - "description": { + "annotations": { + "items": { + "$ref": "#/components/schemas/PreviewAnnotationOut" + }, + "title": "Annotations", + "type": "array" + }, + "asset_id": { + "format": "uuid", + "title": "Asset Id", + "type": "string" + }, + "height": { "anyOf": [ { - "type": "string" + "type": "integer" }, { "type": "null" } ], - "title": "Description" + "title": "Height" + }, + "image_base64": { + "title": "Image Base64", + "type": "string" }, + "media_type": { + "title": "Media Type", + "type": "string" + }, + "variant": { + "title": "Variant", + "type": "integer" + }, + "width": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Width" + } + }, + "required": [ + "asset_id", + "variant", + "width", + "height", + "annotations", + "image_base64", + "media_type" + ], + "title": "PreprocessingPreviewOut", + "type": "object" + }, + "PreprocessingRecipeCreate": { + "additionalProperties": false, + "description": "A new recipe: its name and what it does.\n\n`name` is a slug \u2014 lowercase letters, digits, dots, hyphens and\nunderscores, starting with a letter or digit, at most 64 characters \u2014\nunique within the project.", + "properties": { "name": { "title": "Name", "type": "string" + }, + "spec": { + "$ref": "#/components/schemas/RecipeSpecBody" } }, "required": [ - "name" + "name", + "spec" ], - "title": "ProjectCreate", + "title": "PreprocessingRecipeCreate", "type": "object" }, - "ProjectOut": { - "description": "A project.\n\n`thumbnail_asset_id` names the image that stands for the project in a\nlisting \u2014 the first asset of its earliest batch that has one \u2014 and\n`thumbnail_hash` is that asset's cached preview. Both are null for a\nproject with no images; the id set with a null hash means the asset has no\ncached preview, so there is nothing to fetch.\n\n`created_at` is when the project was made, in UTC; null for a project\nwritten before the workspace recorded it.", + "PreprocessingRecipeOut": { + "description": "A named pre-processing recipe of a project.\n\nA recipe binds at export time \u2014 `POST /releases/{release_id}/export?recipe=`\ntakes `name` \u2014 and the export keeps the spec by value, so editing or\ndeleting a recipe never alters a past export. There is no state and no\n`allowed_actions`: every operation is always offered.", "properties": { "created_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Created At" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" + "format": "date-time", + "title": "Created At", + "type": "string" }, "id": { "format": "uuid", @@ -4127,47 +4180,37 @@ "title": "Name", "type": "string" }, - "thumbnail_asset_id": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Thumbnail Asset Id" + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" }, - "thumbnail_hash": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Thumbnail Hash" + "spec": { + "$ref": "#/components/schemas/RecipeSpecBody" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" } }, "required": [ "id", + "project_id", "name", - "description", - "thumbnail_asset_id", - "thumbnail_hash", - "created_at" + "spec", + "created_at", + "updated_at" ], - "title": "ProjectOut", + "title": "PreprocessingRecipeOut", "type": "object" }, - "ProjectPage": { - "description": "A page of projects.", + "PreprocessingRecipePage": { + "description": "A page of pre-processing recipes.", "properties": { "items": { "items": { - "$ref": "#/components/schemas/ProjectOut" + "$ref": "#/components/schemas/PreprocessingRecipeOut" }, "title": "Items", "type": "array" @@ -4181,51 +4224,335 @@ "items", "total" ], - "title": "ProjectPage", + "title": "PreprocessingRecipePage", "type": "object" }, - "ProjectPreLabelRequest": { + "PreprocessingRecipeUpdate": { "additionalProperties": false, - "description": "Which model should pre-label this project's open batches, and which batches.\n\n`batch_ids` absent means every batch of the project that is open for\nannotation; present means exactly those \u2014 a batch outside the project is\n404, one not open is 409, an empty list names nothing and is 409 too, and\nthe request is refused whole, never partly launched.", + "description": "The whole recipe, replaced. `name` renames it when it differs from the path.", "properties": { - "batch_ids": { + "name": { + "title": "Name", + "type": "string" + }, + "spec": { + "$ref": "#/components/schemas/RecipeSpecBody" + } + }, + "required": [ + "name", + "spec" + ], + "title": "PreprocessingRecipeUpdate", + "type": "object" + }, + "PreviewAnnotationOut": { + "description": "One label as the export would write it, placed on the transformed image.\n\n`id` is the source annotation's id for the base image and `\"{id}-aug{k}\"`\nfor variant `k`, so a rendered label traces to the label it came from.", + "properties": { + "attributes": { + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "title": "Attributes", + "type": "object" + }, + "confidence": { "anyOf": [ { - "items": { - "format": "uuid", - "type": "string" - }, - "type": "array" + "type": "number" }, { "type": "null" } ], - "title": "Batch Ids" - }, - "connection_id": { - "format": "uuid", - "title": "Connection Id", - "type": "string" + "title": "Confidence" }, - "geometries": { - "anyOf": [ + "geometry": { + "discriminator": { + "mapping": { + "bbox": "#/components/schemas/BboxBody", + "classification_tag": "#/components/schemas/ClassificationBody", + "polygon": "#/components/schemas/PolygonBody", + "polyline": "#/components/schemas/PolylineBody" + }, + "propertyName": "type" + }, + "oneOf": [ { - "items": { - "$ref": "#/components/schemas/GeometryType" - }, - "minItems": 1, - "type": "array" + "$ref": "#/components/schemas/BboxBody" }, { - "type": "null" + "$ref": "#/components/schemas/PolygonBody" + }, + { + "$ref": "#/components/schemas/PolylineBody" + }, + { + "$ref": "#/components/schemas/ClassificationBody" } ], - "title": "Geometries" + "title": "Geometry" }, - "minimum_confidence": { - "default": 0.35, - "maximum": 1.0, + "id": { + "title": "Id", + "type": "string" + }, + "label_class": { + "title": "Label Class", + "type": "string" + }, + "model_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model Ref" + }, + "provenance": { + "enum": [ + "human", + "model", + "import" + ], + "title": "Provenance", + "type": "string" + }, + "schema_version": { + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "id", + "label_class", + "schema_version", + "geometry", + "attributes", + "provenance", + "model_ref", + "confidence" + ], + "title": "PreviewAnnotationOut", + "type": "object" + }, + "ProgressCounts": { + "description": "How many assets sit in each annotation state.", + "properties": { + "accepted": { + "title": "Accepted", + "type": "integer" + }, + "annotated": { + "title": "Annotated", + "type": "integer" + }, + "pre_labeled": { + "title": "Pre Labeled", + "type": "integer" + }, + "review_pending": { + "title": "Review Pending", + "type": "integer" + }, + "skipped": { + "title": "Skipped", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + }, + "unannotated": { + "title": "Unannotated", + "type": "integer" + } + }, + "required": [ + "unannotated", + "pre_labeled", + "annotated", + "skipped", + "review_pending", + "accepted", + "total" + ], + "title": "ProgressCounts", + "type": "object" + }, + "ProjectCreate": { + "additionalProperties": false, + "description": "What creating a project needs.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ProjectCreate", + "type": "object" + }, + "ProjectOut": { + "description": "A project.\n\n`thumbnail_asset_id` names the image that stands for the project in a\nlisting \u2014 the first asset of its earliest batch that has one \u2014 and\n`thumbnail_hash` is that asset's cached preview. Both are null for a\nproject with no images; the id set with a null hash means the asset has no\ncached preview, so there is nothing to fetch.\n\n`created_at` is when the project was made, in UTC; null for a project\nwritten before the workspace recorded it.", + "properties": { + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "thumbnail_asset_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Thumbnail Asset Id" + }, + "thumbnail_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Thumbnail Hash" + } + }, + "required": [ + "id", + "name", + "description", + "thumbnail_asset_id", + "thumbnail_hash", + "created_at" + ], + "title": "ProjectOut", + "type": "object" + }, + "ProjectPage": { + "description": "A page of projects.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ProjectOut" + }, + "title": "Items", + "type": "array" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "title": "ProjectPage", + "type": "object" + }, + "ProjectPreLabelRequest": { + "additionalProperties": false, + "description": "Which model should pre-label this project's open batches, and which batches.\n\n`batch_ids` absent means every batch of the project that is open for\nannotation; present means exactly those \u2014 a batch outside the project is\n404, one not open is 409, an empty list names nothing and is 409 too, and\nthe request is refused whole, never partly launched.", + "properties": { + "batch_ids": { + "anyOf": [ + { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Batch Ids" + }, + "connection_id": { + "format": "uuid", + "title": "Connection Id", + "type": "string" + }, + "geometries": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/GeometryType" + }, + "minItems": 1, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Geometries" + }, + "minimum_confidence": { + "default": 0.35, + "maximum": 1.0, "minimum": 0.0, "title": "Minimum Confidence", "type": "number" @@ -4395,15 +4722,61 @@ "title": "ProviderPage", "type": "object" }, - "ReleaseCreate": { + "RecipeSpecBody": { "additionalProperties": false, - "description": "What publishing a release needs.", + "description": "What a recipe does to every exported image, and how many variants it makes.\n\nAt most one `resize` step, and it comes first. `variants_per_asset` counts\naugmented outputs \u2014 0 to 8 \u2014 and requires at least one `augment` step,\nwhich in turn requires at least one variant; each augmentation appears at\nmost once. `target` records which export target's hints the recipe was\nwritten from and is informational: a recipe applies to any export.", "properties": { - "split": { - "anyOf": [ - { - "$ref": "#/components/schemas/SplitRecipeBody" - }, + "steps": { + "default": [], + "items": { + "discriminator": { + "mapping": { + "augment": "#/components/schemas/AugmentStepBody", + "resize": "#/components/schemas/ResizeStepBody" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ResizeStepBody" + }, + { + "$ref": "#/components/schemas/AugmentStepBody" + } + ] + }, + "title": "Steps", + "type": "array" + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target" + }, + "variants_per_asset": { + "default": 0, + "title": "Variants Per Asset", + "type": "integer" + } + }, + "title": "RecipeSpecBody", + "type": "object" + }, + "ReleaseCreate": { + "additionalProperties": false, + "description": "What publishing a release needs.", + "properties": { + "split": { + "anyOf": [ + { + "$ref": "#/components/schemas/SplitRecipeBody" + }, { "type": "null" } @@ -4569,6 +4942,50 @@ "title": "ReleaseVerificationOut", "type": "object" }, + "ResizeStepBody": { + "additionalProperties": false, + "description": "Bring every exported image to one size, by one strategy.\n\n`pad_value` is the grey a letterbox pads with and means nothing to\n`stretch`. Sizes are 32 to 8192 pixels a side.", + "properties": { + "height": { + "title": "Height", + "type": "integer" + }, + "kind": { + "const": "resize", + "default": "resize", + "title": "Kind", + "type": "string" + }, + "pad_value": { + "default": 114, + "title": "Pad Value", + "type": "integer" + }, + "strategy": { + "$ref": "#/components/schemas/ResizeStrategy" + }, + "width": { + "title": "Width", + "type": "integer" + } + }, + "required": [ + "strategy", + "width", + "height" + ], + "title": "ResizeStepBody", + "type": "object" + }, + "ResizeStrategy": { + "description": "How an image reaches a requested size.\n\n``stretch`` scales each axis independently onto the size; ``letterbox``\nscales by the limiting axis and pads the rest, preserving aspect ratio.", + "enum": [ + "stretch", + "letterbox" + ], + "title": "ResizeStrategy", + "type": "string" + }, "ResumeKind": { "description": "What an open batch is being offered for, and so what `next_asset_id` is.\n\n`annotate` - a frame nobody has judged, whether nobody has labeled it or\nonly a model has, which is that frame. `review` - every frame is judged\nand some await a reviewer, which is the first of those. `open` - neither,\nand `next_asset_id` is null.", "enum": [ @@ -5635,13 +6052,794 @@ "operationId": "get_background_job", "parameters": [ { - "in": "path", - "name": "job_id", - "required": true, + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Job Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundJobOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "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": "Get Background Job", + "tags": [ + "jobs" + ] + } + }, + "/background-jobs/{job_id}/artifact": { + "get": { + "description": "Download whatever the job left behind. Today that is an export archive.\n\nA **second route rather than bytes on the poll**, because the two are read on\ndifferent schedules: a client polls this job every couple of seconds and wants\nJSON each time, and asks for the archive exactly once.\n\nThe path comes from the job's own `result`, is **relative to the workspace\nroot**, and is rejoined here \u2014 an absolute path is a server-side path, which\nis the rule that keeps `Source.path` and `Asset.uri` off the wire. It is also\nre-checked to be inside the root before anything is opened: the value has been\nthrough a JSON column, and a route that trusts a stored path to stay inside\nthe directory it was written for is one bad row away from serving `/etc`.\n\nA job this workspace does not hold is 404 `BACKGROUND_JOB_NOT_FOUND`. There\nis a 404 for the artifact too \u2014 the job never produced one, or the file is\ngone, since an export directory is not garbage-collected but a workspace is a\ndirectory somebody can tidy \u2014 and a 409 while the job has not succeeded,\nbecause \"not yet\" and \"never\" are different answers and only one of them is\nworth retrying. Those last two carry the status's own name rather than a\ndomain code: nothing about them is a state of the job that a client could\nbranch on.", + "operationId": "get_background_job_artifact", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Job Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": {} + }, + "application/zip": { + "schema": {} + } + }, + "description": "The file the job produced." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, + "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": "Get Background Job Artifact", + "tags": [ + "jobs" + ] + } + }, + "/background-jobs/{job_id}/cancel": { + "post": { + "description": "Ask a job to stop, and answer with where that left it.\n\n**Two different things behind one verb, and the answer says which happened.**\nA `queued` job has not started, so it comes back `cancelled` outright. A\n`running` job is only *told*: `cancel_requested` becomes true, `state` stays\n`running`, and the work stops at the next point its handler considers safe \u2014\nwhich for a job with no such point is not until it finishes. Nothing is ever\nkilled mid-write.\n\nCancelling a job that has already settled is a no-op that returns it\nunchanged, not a refusal: the caller wanted it stopped and it is stopped.\n\n200 rather than 202, because this answers with the state it produced rather\nthan promising something later.", + "operationId": "cancel_background_job", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Job Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackgroundJobOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "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": "Cancel Background Job", + "tags": [ + "jobs" + ] + } + }, + "/batches/{batch_id}": { + "delete": { + "description": "Remove a batch, its task groups, its jobs and their progress.\n\n**The work survives.** Annotations hang off assets rather than off batches,\nso deleting the unit of work never deletes the labels; the assets stay in\ntheir project and in every other batch that carries them, and no blob is\ntouched. What goes is the batch's own record of *organisation* \u2014 how the work\nwas cut into jobs, and how far each asset had got.\n\nA `completed` batch cannot be deleted at all and answers 409\n`BATCH_IMMUTABLE`: it is the record of what was labeled, against which pinned\nschema version, and what was deliberately skipped, which is what promotion\nand every later correction are read against. **No flag lifts that**, which is\nalso why it is checked before `confirm` \u2014 a refusal naming a remedy that does\nnot work is worse than a blunt one.\n\nWithout `confirm=true` this answers 409 `CONFIRMATION_REQUIRED` and destroys\nnothing.", + "operationId": "delete_batch", + "parameters": [ + { + "in": "path", + "name": "batch_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Batch Id", + "type": "string" + } + }, + { + "description": "Required to destroy data. The kernel refuses the request without it.", + "in": "query", + "name": "confirm", + "required": false, + "schema": { + "default": false, + "description": "Required to destroy data. The kernel refuses the request without it.", + "title": "Confirm", + "type": "boolean" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, + "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": "Delete Batch", + "tags": [ + "batches" + ] + }, + "get": { + "description": "The batch, with how far its assets have got.\n\n`progress` counts every asset of every job in the batch, so a draft \u2014 which\nhas no jobs yet \u2014 reports zeros across the board while `asset_count` is\nalready whatever the ingest gathered. `schema_version` is null until approval\npins one, and moves after that only through `repin`.", + "operationId": "get_batch", + "parameters": [ + { + "in": "path", + "name": "batch_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Batch Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "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": "Get Batch", + "tags": [ + "batches" + ] + } + }, + "/batches/{batch_id}/approve": { + "post": { + "description": "Freeze the batch: pin the project's active schema version and cut it into jobs.\n\nEverything after this is judged against the version pinned here, so a new\nschema version created while annotators are working does not change the rules\nunder them. Membership stops being editable at the same moment \u2014 an asset\nthat should not be labeled is marked `skipped` from here on, which keeps the\ndecision on the record instead of erasing it.\n\nThe partition defaults to one job for the whole batch. `by_size` cuts jobs of\na fixed length with the last taking the remainder; `by_segments` says exactly\nwhich assets go together, and is refused unless it reproduces the batch with\nnothing missing, repeated or foreign.\n\nA batch that is not a draft is 409 `INVALID_TRANSITION`; an empty one is 409\n`EMPTY_BATCH`, because it would have no jobs and could never complete; a\nproject with no schema is 404 `SCHEMA_NOT_FOUND`, since there is nothing to\npin, and an unknown batch is 404 `BATCH_NOT_FOUND`.", + "operationId": "approve_batch", + "parameters": [ + { + "in": "path", + "name": "batch_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Batch Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/BatchApprove" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, + "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": "Approve Batch", + "tags": [ + "batches" + ] + } + }, + "/batches/{batch_id}/assets": { + "delete": { + "description": "Take assets out of a draft batch. One transaction, however many ids you pass.\n\n**This removes membership, not assets.** The asset stays in its project, in\nthe blob store, and in every other batch that carries it; only this batch\nstops listing it.\n\nDraft only, like adding, and for the sharper half of the same reason: after\napproval a job already describes work over that asset, and removing the\nmember would leave the job describing work that no longer exists. From then\non the way to exclude an asset is to mark it `skipped` \u2014 a decision the\nrecord keeps rather than erases \u2014 and this answers 409 `BATCH_NOT_EDITABLE`.\n\nAn id the batch does not hold is ignored rather than refused, and `changed`\nreports what actually went, so \"removed 3\" can be told from \"3 were already\ngone\".", + "operationId": "remove_batch_assets", + "parameters": [ + { + "in": "path", + "name": "batch_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Batch Id", + "type": "string" + } + }, + { + "description": "An asset to remove from the batch. Repeat the parameter per id.", + "in": "query", + "name": "id", + "required": true, + "schema": { + "description": "An asset to remove from the batch. Repeat the parameter per id.", + "items": { + "format": "uuid", + "type": "string" + }, + "minItems": 1, + "title": "Id", + "type": "array" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchMembershipOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, + "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": "Remove Batch Assets", + "tags": [ + "batches" + ] + }, + "get": { + "description": "The batch's assets, with where each has got to and its labels in two numbers.\n\nMembership order by default, so reading twice gives the same sequence and an\ningest into an existing batch appends rather than reshuffles; `sort=confidence`\nputs the frame whose weakest model label scores lowest first, unscored frames\nlast, ties in membership order. `progress` narrows to the states named, and\n`total` is the size of what matched \u2014 the whole batch when nothing narrows it.\nAn offset past the end is an empty list and a 200, never a 404. The 404 belongs\nto the batch itself, which is resolved first: an unknown one is `BATCH_NOT_FOUND`.\n\n`job` narrows to the assets one job carries, composing with `progress`; a job\nthis batch does not have is 404 `JOB_NOT_FOUND`. `job_id` and `progress` on\neach item are null while the batch is a draft, which has no jobs \u2014 so there,\na `progress` filter or a `job` filter matches nothing rather than refusing.\nBytes are not here: an asset is named by its hashes, and\n`GET /projects/{project_id}/assets/{asset_id}/content` is what serves them.", + "operationId": "list_batch_assets", + "parameters": [ + { + "in": "path", + "name": "batch_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Batch Id", + "type": "string" + } + }, + { + "description": "How many items to return. Everything from `offset` on by default.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "How many items to return. Everything from `offset` on by default.", + "title": "Limit" + } + }, + { + "description": "How many items to skip. Counts from the start of the collection.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "description": "How many items to skip. Counts from the start of the collection.", + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "description": "Keep only assets in these states. Repeat the parameter per state.", + "in": "query", + "name": "progress", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/AssetProgress" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Keep only assets in these states. Repeat the parameter per state.", + "title": "Progress" + } + }, + { + "description": "`membership` is stored order; `confidence` is lowest model confidence first, unscored last, ties in membership order.", + "in": "query", + "name": "sort", + "required": false, "schema": { - "format": "uuid", - "title": "Job Id", - "type": "string" + "$ref": "#/components/schemas/AssetSort", + "default": "membership", + "description": "`membership` is stored order; `confidence` is lowest model confidence first, unscored last, ties in membership order." + } + }, + { + "description": "Keep only the assets this job carries. Omit for the whole batch.", + "in": "query", + "name": "job", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Keep only the assets this job carries. Omit for the whole batch.", + "title": "Job" } } ], @@ -5650,7 +6848,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackgroundJobOut" + "$ref": "#/components/schemas/BatchAssetPage" } } }, @@ -5712,39 +6910,46 @@ "HTTPBearer": [] } ], - "summary": "Get Background Job", + "summary": "List Batch Assets", "tags": [ - "jobs" + "batches" ] - } - }, - "/background-jobs/{job_id}/artifact": { - "get": { - "description": "Download whatever the job left behind. Today that is an export archive.\n\nA **second route rather than bytes on the poll**, because the two are read on\ndifferent schedules: a client polls this job every couple of seconds and wants\nJSON each time, and asks for the archive exactly once.\n\nThe path comes from the job's own `result`, is **relative to the workspace\nroot**, and is rejoined here \u2014 an absolute path is a server-side path, which\nis the rule that keeps `Source.path` and `Asset.uri` off the wire. It is also\nre-checked to be inside the root before anything is opened: the value has been\nthrough a JSON column, and a route that trusts a stored path to stay inside\nthe directory it was written for is one bad row away from serving `/etc`.\n\nA job this workspace does not hold is 404 `BACKGROUND_JOB_NOT_FOUND`. There\nis a 404 for the artifact too \u2014 the job never produced one, or the file is\ngone, since an export directory is not garbage-collected but a workspace is a\ndirectory somebody can tidy \u2014 and a 409 while the job has not succeeded,\nbecause \"not yet\" and \"never\" are different answers and only one of them is\nworth retrying. Those last two carry the status's own name rather than a\ndomain code: nothing about them is a state of the job that a client could\nbranch on.", - "operationId": "get_background_job_artifact", + }, + "post": { + "description": "Put assets into a draft batch.\n\n**Only while the batch is a draft**, which is what `edit_membership` in its\n`allowed_actions` declares. Approval partitions the batch into jobs against a\npinned schema version, so an asset added afterwards would belong to no job \u2014\nhence 409 `BATCH_NOT_EDITABLE` from that point on, and there is no flag that\nlifts it.\n\nIdempotent, and it says so in the answer rather than leaving it to be\ninferred: `changed` lists the ids this call actually wrote, so adding three\nassets of which two were already members reports one. An asset the batch\nalready holds is not an error.\n\nAn id that is not an asset of this batch's project is 404 `ASSET_NOT_FOUND`\nand **nothing is written** \u2014 the whole call is refused, for the reason\nannotation writes are all-or-nothing. An unknown batch is 404\n`BATCH_NOT_FOUND`, resolved before any id is read.", + "operationId": "add_batch_assets", "parameters": [ { "in": "path", - "name": "job_id", + "name": "batch_id", "required": true, "schema": { "format": "uuid", - "title": "Job Id", + "title": "Batch Id", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchMembership" + } + } + }, + "required": true + }, "responses": { "200": { "content": { - "application/octet-stream": { - "schema": {} - }, - "application/zip": { - "schema": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchMembershipOut" + } } }, - "description": "The file the job produced." + "description": "Successful Response" }, "401": { "content": { @@ -5812,24 +7017,24 @@ "HTTPBearer": [] } ], - "summary": "Get Background Job Artifact", + "summary": "Add Batch Assets", "tags": [ - "jobs" + "batches" ] } }, - "/background-jobs/{job_id}/cancel": { + "/batches/{batch_id}/complete": { "post": { - "description": "Ask a job to stop, and answer with where that left it.\n\n**Two different things behind one verb, and the answer says which happened.**\nA `queued` job has not started, so it comes back `cancelled` outright. A\n`running` job is only *told*: `cancel_requested` becomes true, `state` stays\n`running`, and the work stops at the next point its handler considers safe \u2014\nwhich for a job with no such point is not until it finishes. Nothing is ever\nkilled mid-write.\n\nCancelling a job that has already settled is a no-op that returns it\nunchanged, not a refusal: the caller wanted it stopped and it is stopped.\n\n200 rather than 202, because this answers with the state it produced rather\nthan promising something later.", - "operationId": "cancel_background_job", + "description": "Close the batch, if every one of its jobs is finished.\n\nDerived rather than declared: this reads the jobs and answers 409\n`BATCH_NOT_COMPLETE` while any of them is outstanding. A completed batch is\nwhat lets its annotated assets be promoted into the project's dataset.\n\nA batch that is not `in_annotation` has no closing move to make and is 409\n`INVALID_TRANSITION` \u2014 the same one-way table that leaves `completed` with no\nexit at all, which is why correcting settled work is a new batch.", + "operationId": "complete_batch", "parameters": [ { "in": "path", - "name": "job_id", + "name": "batch_id", "required": true, "schema": { "format": "uuid", - "title": "Job Id", + "title": "Batch Id", "type": "string" } } @@ -5839,104 +7044,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackgroundJobOut" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "Missing or invalid bearer token" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "No such resource" - }, - "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" + "$ref": "#/components/schemas/BatchOut" } } }, - "description": "The workspace is busy; retry after the header says" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Cancel Background Job", - "tags": [ - "jobs" - ] - } - }, - "/batches/{batch_id}": { - "delete": { - "description": "Remove a batch, its task groups, its jobs and their progress.\n\n**The work survives.** Annotations hang off assets rather than off batches,\nso deleting the unit of work never deletes the labels; the assets stay in\ntheir project and in every other batch that carries them, and no blob is\ntouched. What goes is the batch's own record of *organisation* \u2014 how the work\nwas cut into jobs, and how far each asset had got.\n\nA `completed` batch cannot be deleted at all and answers 409\n`BATCH_IMMUTABLE`: it is the record of what was labeled, against which pinned\nschema version, and what was deliberately skipped, which is what promotion\nand every later correction are read against. **No flag lifts that**, which is\nalso why it is checked before `confirm` \u2014 a refusal naming a remedy that does\nnot work is worse than a blunt one.\n\nWithout `confirm=true` this answers 409 `CONFIRMATION_REQUIRED` and destroys\nnothing.", - "operationId": "delete_batch", - "parameters": [ - { - "in": "path", - "name": "batch_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Batch Id", - "type": "string" - } - }, - { - "description": "Required to destroy data. The kernel refuses the request without it.", - "in": "query", - "name": "confirm", - "required": false, - "schema": { - "default": false, - "description": "Required to destroy data. The kernel refuses the request without it.", - "title": "Confirm", - "type": "boolean" - } - } - ], - "responses": { - "204": { "description": "Successful Response" }, "401": { @@ -6005,14 +7116,16 @@ "HTTPBearer": [] } ], - "summary": "Delete Batch", + "summary": "Complete Batch", "tags": [ "batches" ] - }, - "get": { - "description": "The batch, with how far its assets have got.\n\n`progress` counts every asset of every job in the batch, so a draft \u2014 which\nhas no jobs yet \u2014 reports zeros across the board while `asset_count` is\nalready whatever the ingest gathered. `schema_version` is null until approval\npins one, and moves after that only through `repin`.", - "operationId": "get_batch", + } + }, + "/batches/{batch_id}/corrections": { + "post": { + "description": "Cut a new draft batch that corrects this completed one.\n\n**The forward-only answer to \"this needs fixing\".** A `completed` batch is\nimmutable as a workflow unit \u2014 it has no exit in the lifecycle and none is\ncoming \u2014 so changing settled work means a new batch over the same assets,\ncarrying lineage back to this one in `parent_batch_id`.\n\nAddressed as a sub-resource of the parent because the parent is what decides:\n`create_correction` is declared on `BatchOut` exactly while the batch is\n`completed`, and 409 `INVALID_TRANSITION` is what a client gets for asking\notherwise.\n\n`asset_ids` defaults to **the parent's whole membership**, since \"correct\nthis batch\" is the ordinary ask. A subset is the other one \u2014 the three frames\nsomebody found wrong \u2014 and every id given must be one the parent carried: a\ncorrection of a batch is a correction *of what was in it*.\n\nThe child pins the project's **active** schema at its own approval, not the\nparent's pin. That is the point of correcting under a contract that has moved\non, and it is the ordinary approval mechanism rather than anything new.", + "operationId": "create_correction_batch", "parameters": [ { "in": "path", @@ -6025,8 +7138,18 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchCorrection" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { @@ -6056,6 +7179,16 @@ }, "description": "No such resource" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { @@ -6092,51 +7225,34 @@ "HTTPBearer": [] } ], - "summary": "Get Batch", + "summary": "Create Correction Batch", "tags": [ "batches" ] } }, - "/batches/{batch_id}/approve": { - "post": { - "description": "Freeze the batch: pin the project's active schema version and cut it into jobs.\n\nEverything after this is judged against the version pinned here, so a new\nschema version created while annotators are working does not change the rules\nunder them. Membership stops being editable at the same moment \u2014 an asset\nthat should not be labeled is marked `skipped` from here on, which keeps the\ndecision on the record instead of erasing it.\n\nThe partition defaults to one job for the whole batch. `by_size` cuts jobs of\na fixed length with the last taking the remainder; `by_segments` says exactly\nwhich assets go together, and is refused unless it reproduces the batch with\nnothing missing, repeated or foreign.\n\nA batch that is not a draft is 409 `INVALID_TRANSITION`; an empty one is 409\n`EMPTY_BATCH`, because it would have no jobs and could never complete; a\nproject with no schema is 404 `SCHEMA_NOT_FOUND`, since there is nothing to\npin, and an unknown batch is 404 `BATCH_NOT_FOUND`.", - "operationId": "approve_batch", - "parameters": [ - { - "in": "path", - "name": "batch_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Batch Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/BatchApprove" - }, - { - "type": "null" - } - ], - "title": "Body" - } + "/batches/{batch_id}/jobs": { + "get": { + "description": "The jobs the batch was cut into, in segment order.\n\nEmpty until the batch is approved \u2014 a draft has no jobs \u2014 and a 200 either\nway.", + "operationId": "list_batch_jobs", + "parameters": [ + { + "in": "path", + "name": "batch_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Batch Id", + "type": "string" } } - }, + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchOut" + "$ref": "#/components/schemas/JobPage" } } }, @@ -6162,16 +7278,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -6208,16 +7314,16 @@ "HTTPBearer": [] } ], - "summary": "Approve Batch", + "summary": "List Batch Jobs", "tags": [ "batches" ] } }, - "/batches/{batch_id}/assets": { - "delete": { - "description": "Take assets out of a draft batch. One transaction, however many ids you pass.\n\n**This removes membership, not assets.** The asset stays in its project, in\nthe blob store, and in every other batch that carries it; only this batch\nstops listing it.\n\nDraft only, like adding, and for the sharper half of the same reason: after\napproval a job already describes work over that asset, and removing the\nmember would leave the job describing work that no longer exists. From then\non the way to exclude an asset is to mark it `skipped` \u2014 a decision the\nrecord keeps rather than erases \u2014 and this answers 409 `BATCH_NOT_EDITABLE`.\n\nAn id the batch does not hold is ignored rather than refused, and `changed`\nreports what actually went, so \"removed 3\" can be told from \"3 were already\ngone\".", - "operationId": "remove_batch_assets", + "/batches/{batch_id}/pre-label": { + "get": { + "description": "The classes a run would ask this connection's model for, and the shapes it would write.\n\nA run's prompt is the batch's pinned schema narrowed to the classes the\nmodel can answer \u2014 a class is asked for when it admits a shape the run\nwrites and demands no attribute a prediction cannot supply \u2014 and that\nnarrowing is invisible once the run has finished. Read this before\nlaunching to say which classes are in the prompt and which are not, with\nthe reason beside each, and which shapes a run will write: `produces` is\nthe model's declared shapes, so a schema of polygon classes is askable of a\nmodel that answers polygons and refused for one that answers boxes.\n\n`connection_id` is required because the plan is a property of the schema\n**and** the model: the same schema yields a different prompt for a detector\nand for a segmenter. `geometries` is the launch's own restriction, read\nhere first: pass the shapes a launch would name and `produces` is that\nselection, with every class only those shapes cannot hold moved to\n`excluded_classes`. Omitted, the plan is for every shape the model produces.\n\nRefused on the same terms the launch uses, in the same order, so reading\nthe plan and then launching gets one set of answers: an unknown connection\nis 404 `INFERENCE_CONNECTION_NOT_FOUND`; a connection not set up yet is 409\n`INFERENCE_CONNECTION_NOT_SET_UP`; one whose model answers places rather\nthan words is 422 `UNSUPPORTED_PROMPT`; a `geometries` naming a shape the\nmodel does not produce is 422 `GEOMETRY_NOT_PRODUCED`; an unknown batch is\n404 `BATCH_NOT_FOUND`; a batch that is not `in_annotation` is 409\n`BATCH_NOT_IN_ANNOTATION`; a pinned schema with no class the selected shapes\ncan be written as is 409 `SCHEMA_HAS_NO_DETECTABLE_CLASS`. A machine\nwithout the optional local runtime answers 500 `LOCAL_INFERENCE_UNAVAILABLE`\nwith the install command, and a batch open for annotation but pinning no\nschema version is a broken invariant and answers 500 `WORKSPACE_CORRUPT`.", + "operationId": "pre_label_plan", "parameters": [ { "in": "path", @@ -6230,19 +7336,34 @@ } }, { - "description": "An asset to remove from the batch. Repeat the parameter per id.", "in": "query", - "name": "id", + "name": "connection_id", "required": true, "schema": { - "description": "An asset to remove from the batch. Repeat the parameter per id.", - "items": { - "format": "uuid", - "type": "string" - }, - "minItems": 1, - "title": "Id", - "type": "array" + "format": "uuid", + "title": "Connection Id", + "type": "string" + } + }, + { + "description": "Which of the model's shapes the run would write \u2014 `PreLabelRequest.geometries`, for the plan. Repeat the parameter per shape. Omit for every shape the model produces.", + "in": "query", + "name": "geometries", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/GeometryType" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Which of the model's shapes the run would write \u2014 `PreLabelRequest.geometries`, for the plan. Repeat the parameter per shape. Omit for every shape the model produces.", + "title": "Geometries" } } ], @@ -6251,7 +7372,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchMembershipOut" + "$ref": "#/components/schemas/PreLabelPlanOut" } } }, @@ -6323,14 +7444,14 @@ "HTTPBearer": [] } ], - "summary": "Remove Batch Assets", + "summary": "Pre Label Plan", "tags": [ "batches" ] }, - "get": { - "description": "The batch's assets, with where each has got to and its labels in two numbers.\n\nMembership order by default, so reading twice gives the same sequence and an\ningest into an existing batch appends rather than reshuffles; `sort=confidence`\nputs the frame whose weakest model label scores lowest first, unscored frames\nlast, ties in membership order. `progress` narrows to the states named, and\n`total` is the size of what matched \u2014 the whole batch when nothing narrows it.\nAn offset past the end is an empty list and a 200, never a 404. The 404 belongs\nto the batch itself, which is resolved first: an unknown one is `BATCH_NOT_FOUND`.\n\n`job` narrows to the assets one job carries, composing with `progress`; a job\nthis batch does not have is 404 `JOB_NOT_FOUND`. `job_id` and `progress` on\neach item are null while the batch is a draft, which has no jobs \u2014 so there,\na `progress` filter or a `job` filter matches nothing rather than refusing.\nBytes are not here: an asset is named by its hashes, and\n`GET /projects/{project_id}/assets/{asset_id}/content` is what serves them.", - "operationId": "list_batch_assets", + "post": { + "description": "Ask a model to label every untouched asset in this batch, and answer at once.\n\n**One row per open job, and the job is the unit.** This launch fans out over\nthe batch's jobs that are still open and queues for each the same\n`annotation.pre_label` row `POST /jobs/{job_id}/pre-label` queues, or joins\nthe one already queued or running for that job (`joined`). A finished job is\npassed over, so a batch whose every job is complete answers an empty page.\nEach row is polled, cancelled and remembered per job:\n`GET /background-jobs/{id}` for progress counted in that job's assets,\n`JobOut.pre_label_run` afterwards. Nothing here reports one total across\njobs, because nothing here is one run.\n\nThe `pre_label` action. Labels land at `pre_labeled`, never at `annotated`:\nnobody judged them, so they arrive editable and correctable rather than\nclaiming to be somebody's work \u2014 and, being unjudged, they never reach the\nDataset until a person has taken them over.\n\n**Only assets nothing has touched \u2014 which is stronger than reading\n`unannotated`.** An asset already `pre_labeled`, annotated, skipped,\nawaiting review or accepted is passed over, and so is an `unannotated` one\nthat still carries annotations from an earlier round that was skipped and\nthen restored: that sequence deletes no labels, so progress alone does not\nprove an asset untouched. A run never writes over what a person did in this\njob, and never writes twice over what a model did \u2014 a plain second run\nextends an earlier one onto whatever is still untouched.\n`replace_model_labels` widens it to every frame still `pre_labeled` and\nsupersedes those labels with this run's answer, one frame per transaction;\na frame anyone edited, confirmed or skipped in this job is never touched,\nand a frame the model now finds nothing on returns to `unannotated`. A\nreplacing request arriving while a run is in flight joins that run,\nwhichever flag it carries.\n\n**The batch's pinned schema is the prompt, narrowed to what this run\nwrites.** The model is asked for each class the schema declares that admits\none of the shapes the run writes and demands no attribute a prediction\ncannot supply; an answer naming one of those classes, matched\ncase-insensitively, is written under the schema's own spelling, and an\nanswer naming none of them is discarded. A schema with no such class has\nnowhere for a prediction to land and is refused \u2014 so the same schema is\naskable of a model that answers polygons and refused for one that answers\nboxes. `GET` this path with the same `connection_id` (and the same\n`geometries`) to read the narrowing before launching.\n\n**What the run writes is every shape the model produces, unless\n`geometries` says which.** A model declaring both a box and a polygon\nwrites both for every region it answers with \u2014 the kernel writes one\nannotation per emitted region and pairs nothing \u2014 and `geometries` filters\nthat to the shapes named: a region in any other shape is discarded and\ncounted in `regions_discarded`. The selection is per run, not per class,\nand it is kept on the queued row, so a run claimed later executes what was\nasked.\n\n**202, not 200.** A job is hundreds of forward passes, so this follows the\nlaunch-and-poll contract the export and weight-download routes use: poll `GET\n/background-jobs/{id}` for each row until `state` is `succeeded`, then\nre-read the batch's assets. Progress on a row is counted in assets. There is\nno `Location` header, because there is no single row for it to name.\n\n**Everything a caller can be told now is told now**, and no refusal creates a\nrow \u2014 so a caller holding a row's id holds one that will run. These refusals\nare about the request, and the caller can act on each. They are checked in\nthis order, and it is the order `pre_label` itself checks in, so a request\nwrong about the connection and the batch both always names the connection:\na connection not set up yet is 409 `INFERENCE_CONNECTION_NOT_SET_UP` \u2014 its\nweights not here, or its endpoint not yet asked what it answers; a\nconnection whose model answers places rather than words is 422\n`UNSUPPORTED_PROMPT`; a `geometries` naming a shape the model does not\nproduce is 422 `GEOMETRY_NOT_PRODUCED`; a batch that is not `in_annotation`\nis 409 `BATCH_NOT_IN_ANNOTATION`; a pinned schema with no class the\nselected shapes can be written as is 409 `SCHEMA_HAS_NO_DETECTABLE_CLASS`.\n\nTwo failures are about this installation rather than about the request, and\nanswer 500 carrying the message that says which: a machine without the\noptional local runtime is `LOCAL_INFERENCE_UNAVAILABLE` and carries the\nexact command that installs it, and a workspace whose records no longer\nhold together \u2014 a batch pinned to a schema version that is not stored \u2014 is\n`WORKSPACE_CORRUPT`. Neither is worth resending unchanged: there is no\nstate here a caller can change, so the remedy is the one the message names.\n\n**Asking twice joins the runs already in flight rather than starting second\nones.** A request arriving while a job here has a pre-labeling run queued or\nrunning is answered with that run's row and `joined` true, so a double-click\nand a second tab watch one run per job instead of paying for the same\ninference twice.", + "operationId": "pre_label_batch", "parameters": [ { "in": "path", @@ -6341,97 +7462,24 @@ "title": "Batch Id", "type": "string" } - }, - { - "description": "How many items to return. Everything from `offset` on by default.", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "anyOf": [ - { - "minimum": 1, - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "How many items to return. Everything from `offset` on by default.", - "title": "Limit" - } - }, - { - "description": "How many items to skip. Counts from the start of the collection.", - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "description": "How many items to skip. Counts from the start of the collection.", - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "description": "Keep only assets in these states. Repeat the parameter per state.", - "in": "query", - "name": "progress", - "required": false, - "schema": { - "anyOf": [ - { - "items": { - "$ref": "#/components/schemas/AssetProgress" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "description": "Keep only assets in these states. Repeat the parameter per state.", - "title": "Progress" - } - }, - { - "description": "`membership` is stored order; `confidence` is lowest model confidence first, unscored last, ties in membership order.", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "$ref": "#/components/schemas/AssetSort", - "default": "membership", - "description": "`membership` is stored order; `confidence` is lowest model confidence first, unscored last, ties in membership order." - } - }, - { - "description": "Keep only the assets this job carries. Omit for the whole batch.", - "in": "query", - "name": "job", - "required": false, - "schema": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Keep only the assets this job carries. Omit for the whole batch.", - "title": "Job" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreLabelRequest" + } + } + }, + "required": true + }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchAssetPage" + "$ref": "#/components/schemas/PreLabelFanOutOut" } } }, @@ -6457,6 +7505,16 @@ }, "description": "No such resource" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { @@ -6493,14 +7551,16 @@ "HTTPBearer": [] } ], - "summary": "List Batch Assets", + "summary": "Pre Label Batch", "tags": [ "batches" ] - }, + } + }, + "/batches/{batch_id}/promote": { "post": { - "description": "Put assets into a draft batch.\n\n**Only while the batch is a draft**, which is what `edit_membership` in its\n`allowed_actions` declares. Approval partitions the batch into jobs against a\npinned schema version, so an asset added afterwards would belong to no job \u2014\nhence 409 `BATCH_NOT_EDITABLE` from that point on, and there is no flag that\nlifts it.\n\nIdempotent, and it says so in the answer rather than leaving it to be\ninferred: `changed` lists the ids this call actually wrote, so adding three\nassets of which two were already members reports one. An asset the batch\nalready holds is not an error.\n\nAn id that is not an asset of this batch's project is 404 `ASSET_NOT_FOUND`\nand **nothing is written** \u2014 the whole call is refused, for the reason\nannotation writes are all-or-nothing. An unknown batch is 404\n`BATCH_NOT_FOUND`, resolved before any id is read.", - "operationId": "add_batch_assets", + "description": "Move the batch's labeled assets into its project's dataset.\n\nThe one gate into the trunk. Which assets go in is derived, not chosen: those\nan annotator left `annotated` or a reviewer left `accepted`. A `skipped`\nasset stays out by design, and the decision stays on the record rather than\nbeing erased from the batch.\n\nIdempotent, and a union rather than a replacement. Promoting the same batch\ntwice returns an empty list the second time and writes no change-log entry,\nbecause nothing happened \u2014 and re-promoting after a curator removed an asset\nputs it back, since the trunk keeps no memory of removals.\n\nA batch that has not reached `completed` is 409 `BATCH_NOT_COMPLETE`.", + "operationId": "promote_batch", "parameters": [ { "in": "path", @@ -6513,22 +7573,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchMembership" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchMembershipOut" + "$ref": "#/components/schemas/AssetPage" } } }, @@ -6600,16 +7650,16 @@ "HTTPBearer": [] } ], - "summary": "Add Batch Assets", + "summary": "Promote Batch", "tags": [ "batches" ] } }, - "/batches/{batch_id}/complete": { + "/batches/{batch_id}/repin": { "post": { - "description": "Close the batch, if every one of its jobs is finished.\n\nDerived rather than declared: this reads the jobs and answers 409\n`BATCH_NOT_COMPLETE` while any of them is outstanding. A completed batch is\nwhat lets its annotated assets be promoted into the project's dataset.\n\nA batch that is not `in_annotation` has no closing move to make and is 409\n`INVALID_TRANSITION` \u2014 the same one-way table that leaves `completed` with no\nexit at all, which is why correcting settled work is a new batch.", - "operationId": "complete_batch", + "description": "Move the batch's schema pin onto the project's current active version.\n\nExplicit, never automatic \u2014 the pin does not follow the schema, because a\ncontract that moved under work in flight is what versioning exists to\nprevent. This is how a class added *after* approval becomes usable in a batch\nsomebody is already annotating, without abandoning it.\n\nAdding a class is additive and goes through with no flag. A change that\nnarrows what the pin allowed \u2014 a class removed, a geometry changed, an\nattribute made required \u2014 is 409 `DESTRUCTIVE_SCHEMA_CHANGE`; retry the\nidentical request with `?allow_destructive=true`. If this batch already holds\nannotations under a class the change would break, it is 409\n`SCHEMA_CHANGE_WOULD_ORPHAN` and **no flag overrides it** \u2014 branch on the\ncode, never on the status. The orphan check is scoped to this batch: a label\nwritten in some *other* batch does not block this one.\n\nLegal only while the batch is `approved` or `in_annotation`; a draft has no\npin yet and a completed batch's pin is history, both 409\n`INVALID_TRANSITION`. Re-pinning onto the version already pinned changes\nnothing. Annotations already written keep the version they were stamped with.", + "operationId": "repin_batch", "parameters": [ { "in": "path", @@ -6620,6 +7670,16 @@ "title": "Batch Id", "type": "string" } + }, + { + "in": "query", + "name": "allow_destructive", + "required": false, + "schema": { + "default": false, + "title": "Allow Destructive", + "type": "boolean" + } } ], "responses": { @@ -6699,16 +7759,16 @@ "HTTPBearer": [] } ], - "summary": "Complete Batch", + "summary": "Repin Batch", "tags": [ "batches" ] } }, - "/batches/{batch_id}/corrections": { + "/batches/{batch_id}/start": { "post": { - "description": "Cut a new draft batch that corrects this completed one.\n\n**The forward-only answer to \"this needs fixing\".** A `completed` batch is\nimmutable as a workflow unit \u2014 it has no exit in the lifecycle and none is\ncoming \u2014 so changing settled work means a new batch over the same assets,\ncarrying lineage back to this one in `parent_batch_id`.\n\nAddressed as a sub-resource of the parent because the parent is what decides:\n`create_correction` is declared on `BatchOut` exactly while the batch is\n`completed`, and 409 `INVALID_TRANSITION` is what a client gets for asking\notherwise.\n\n`asset_ids` defaults to **the parent's whole membership**, since \"correct\nthis batch\" is the ordinary ask. A subset is the other one \u2014 the three frames\nsomebody found wrong \u2014 and every id given must be one the parent carried: a\ncorrection of a batch is a correction *of what was in it*.\n\nThe child pins the project's **active** schema at its own approval, not the\nparent's pin. That is the point of correcting under a contract that has moved\non, and it is the ordinary approval mechanism rather than anything new.", - "operationId": "create_correction_batch", + "description": "Open the batch for annotation. Nothing may be written into it before this.", + "operationId": "start_batch", "parameters": [ { "in": "path", @@ -6721,18 +7781,8 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchCorrection" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { @@ -6808,24 +7858,24 @@ "HTTPBearer": [] } ], - "summary": "Create Correction Batch", + "summary": "Start Batch", "tags": [ "batches" ] } }, - "/batches/{batch_id}/jobs": { + "/datasets/{dataset_id}": { "get": { - "description": "The jobs the batch was cut into, in segment order.\n\nEmpty until the batch is approved \u2014 a draft has no jobs \u2014 and a 200 either\nway.", - "operationId": "list_batch_jobs", + "description": "The dataset with that id.", + "operationId": "get_dataset", "parameters": [ { "in": "path", - "name": "batch_id", + "name": "dataset_id", "required": true, "schema": { "format": "uuid", - "title": "Batch Id", + "title": "Dataset Id", "type": "string" } } @@ -6835,7 +7885,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobPage" + "$ref": "#/components/schemas/DatasetOut" } } }, @@ -6897,56 +7947,57 @@ "HTTPBearer": [] } ], - "summary": "List Batch Jobs", + "summary": "Get Dataset", "tags": [ - "batches" + "datasets" ] } }, - "/batches/{batch_id}/pre-label": { + "/datasets/{dataset_id}/assets": { "get": { - "description": "The classes a run would ask this connection's model for, and the shapes it would write.\n\nA run's prompt is the batch's pinned schema narrowed to the classes the\nmodel can answer \u2014 a class is asked for when it admits a shape the run\nwrites and demands no attribute a prediction cannot supply \u2014 and that\nnarrowing is invisible once the run has finished. Read this before\nlaunching to say which classes are in the prompt and which are not, with\nthe reason beside each, and which shapes a run will write: `produces` is\nthe model's declared shapes, so a schema of polygon classes is askable of a\nmodel that answers polygons and refused for one that answers boxes.\n\n`connection_id` is required because the plan is a property of the schema\n**and** the model: the same schema yields a different prompt for a detector\nand for a segmenter. `geometries` is the launch's own restriction, read\nhere first: pass the shapes a launch would name and `produces` is that\nselection, with every class only those shapes cannot hold moved to\n`excluded_classes`. Omitted, the plan is for every shape the model produces.\n\nRefused on the same terms the launch uses, in the same order, so reading\nthe plan and then launching gets one set of answers: an unknown connection\nis 404 `INFERENCE_CONNECTION_NOT_FOUND`; a connection not set up yet is 409\n`INFERENCE_CONNECTION_NOT_SET_UP`; one whose model answers places rather\nthan words is 422 `UNSUPPORTED_PROMPT`; a `geometries` naming a shape the\nmodel does not produce is 422 `GEOMETRY_NOT_PRODUCED`; an unknown batch is\n404 `BATCH_NOT_FOUND`; a batch that is not `in_annotation` is 409\n`BATCH_NOT_IN_ANNOTATION`; a pinned schema with no class the selected shapes\ncan be written as is 409 `SCHEMA_HAS_NO_DETECTABLE_CLASS`. A machine\nwithout the optional local runtime answers 500 `LOCAL_INFERENCE_UNAVAILABLE`\nwith the install command, and a batch open for annotation but pinning no\nschema version is a broken invariant and answers 500 `WORKSPACE_CORRUPT`.", - "operationId": "pre_label_plan", + "description": "Everything in the trunk, in the order it was promoted, each with its labels summarised.\n\nPaged, and the second route in the API that is \u2014 the trunk accumulates every\nbatch a project ever completed, so it is the other collection that can hold\nfifty thousand items. `total` is the size of the whole trunk and not of the\npage; an offset past the end is an empty list and a 200, never a 404. The\n404 is the dataset itself: an unknown one is `DATASET_NOT_FOUND`.\n\nOrder is the stored insertion order, so reading twice gives the same sequence\nand promoting a new batch appends rather than reshuffles.\n\nEach member carries `annotation_count` and `label_classes` so a gallery can\ncaption a tile without a request per frame; the labels themselves are one\nlevel down, at `GET /datasets/{dataset_id}/assets/{asset_id}/annotations`.", + "operationId": "list_dataset_assets", "parameters": [ { "in": "path", - "name": "batch_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Batch Id", - "type": "string" - } - }, - { - "in": "query", - "name": "connection_id", + "name": "dataset_id", "required": true, "schema": { "format": "uuid", - "title": "Connection Id", + "title": "Dataset Id", "type": "string" } }, { - "description": "Which of the model's shapes the run would write \u2014 `PreLabelRequest.geometries`, for the plan. Repeat the parameter per shape. Omit for every shape the model produces.", + "description": "How many items to return. Everything from `offset` on by default.", "in": "query", - "name": "geometries", + "name": "limit", "required": false, "schema": { "anyOf": [ { - "items": { - "$ref": "#/components/schemas/GeometryType" - }, - "type": "array" + "minimum": 1, + "type": "integer" }, { "type": "null" } ], - "description": "Which of the model's shapes the run would write \u2014 `PreLabelRequest.geometries`, for the plan. Repeat the parameter per shape. Omit for every shape the model produces.", - "title": "Geometries" + "description": "How many items to return. Everything from `offset` on by default.", + "title": "Limit" + } + }, + { + "description": "How many items to skip. Counts from the start of the collection.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "description": "How many items to skip. Counts from the start of the collection.", + "minimum": 0, + "title": "Offset", + "type": "integer" } } ], @@ -6955,7 +8006,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreLabelPlanOut" + "$ref": "#/components/schemas/DatasetAssetPage" } } }, @@ -6981,16 +8032,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -7027,45 +8068,40 @@ "HTTPBearer": [] } ], - "summary": "Pre Label Plan", + "summary": "List Dataset Assets", "tags": [ - "batches" + "datasets" ] - }, - "post": { - "description": "Ask a model to label every untouched asset in this batch, and answer at once.\n\n**One row per open job, and the job is the unit.** This launch fans out over\nthe batch's jobs that are still open and queues for each the same\n`annotation.pre_label` row `POST /jobs/{job_id}/pre-label` queues, or joins\nthe one already queued or running for that job (`joined`). A finished job is\npassed over, so a batch whose every job is complete answers an empty page.\nEach row is polled, cancelled and remembered per job:\n`GET /background-jobs/{id}` for progress counted in that job's assets,\n`JobOut.pre_label_run` afterwards. Nothing here reports one total across\njobs, because nothing here is one run.\n\nThe `pre_label` action. Labels land at `pre_labeled`, never at `annotated`:\nnobody judged them, so they arrive editable and correctable rather than\nclaiming to be somebody's work \u2014 and, being unjudged, they never reach the\nDataset until a person has taken them over.\n\n**Only assets nothing has touched \u2014 which is stronger than reading\n`unannotated`.** An asset already `pre_labeled`, annotated, skipped,\nawaiting review or accepted is passed over, and so is an `unannotated` one\nthat still carries annotations from an earlier round that was skipped and\nthen restored: that sequence deletes no labels, so progress alone does not\nprove an asset untouched. A run never writes over what a person did in this\njob, and never writes twice over what a model did \u2014 a plain second run\nextends an earlier one onto whatever is still untouched.\n`replace_model_labels` widens it to every frame still `pre_labeled` and\nsupersedes those labels with this run's answer, one frame per transaction;\na frame anyone edited, confirmed or skipped in this job is never touched,\nand a frame the model now finds nothing on returns to `unannotated`. A\nreplacing request arriving while a run is in flight joins that run,\nwhichever flag it carries.\n\n**The batch's pinned schema is the prompt, narrowed to what this run\nwrites.** The model is asked for each class the schema declares that admits\none of the shapes the run writes and demands no attribute a prediction\ncannot supply; an answer naming one of those classes, matched\ncase-insensitively, is written under the schema's own spelling, and an\nanswer naming none of them is discarded. A schema with no such class has\nnowhere for a prediction to land and is refused \u2014 so the same schema is\naskable of a model that answers polygons and refused for one that answers\nboxes. `GET` this path with the same `connection_id` (and the same\n`geometries`) to read the narrowing before launching.\n\n**What the run writes is every shape the model produces, unless\n`geometries` says which.** A model declaring both a box and a polygon\nwrites both for every region it answers with \u2014 the kernel writes one\nannotation per emitted region and pairs nothing \u2014 and `geometries` filters\nthat to the shapes named: a region in any other shape is discarded and\ncounted in `regions_discarded`. The selection is per run, not per class,\nand it is kept on the queued row, so a run claimed later executes what was\nasked.\n\n**202, not 200.** A job is hundreds of forward passes, so this follows the\nlaunch-and-poll contract the export and weight-download routes use: poll `GET\n/background-jobs/{id}` for each row until `state` is `succeeded`, then\nre-read the batch's assets. Progress on a row is counted in assets. There is\nno `Location` header, because there is no single row for it to name.\n\n**Everything a caller can be told now is told now**, and no refusal creates a\nrow \u2014 so a caller holding a row's id holds one that will run. These refusals\nare about the request, and the caller can act on each. They are checked in\nthis order, and it is the order `pre_label` itself checks in, so a request\nwrong about the connection and the batch both always names the connection:\na connection not set up yet is 409 `INFERENCE_CONNECTION_NOT_SET_UP` \u2014 its\nweights not here, or its endpoint not yet asked what it answers; a\nconnection whose model answers places rather than words is 422\n`UNSUPPORTED_PROMPT`; a `geometries` naming a shape the model does not\nproduce is 422 `GEOMETRY_NOT_PRODUCED`; a batch that is not `in_annotation`\nis 409 `BATCH_NOT_IN_ANNOTATION`; a pinned schema with no class the\nselected shapes can be written as is 409 `SCHEMA_HAS_NO_DETECTABLE_CLASS`.\n\nTwo failures are about this installation rather than about the request, and\nanswer 500 carrying the message that says which: a machine without the\noptional local runtime is `LOCAL_INFERENCE_UNAVAILABLE` and carries the\nexact command that installs it, and a workspace whose records no longer\nhold together \u2014 a batch pinned to a schema version that is not stored \u2014 is\n`WORKSPACE_CORRUPT`. Neither is worth resending unchanged: there is no\nstate here a caller can change, so the remedy is the one the message names.\n\n**Asking twice joins the runs already in flight rather than starting second\nones.** A request arriving while a job here has a pre-labeling run queued or\nrunning is answered with that run's row and `joined` true, so a double-click\nand a second tab watch one run per job instead of paying for the same\ninference twice.", - "operationId": "pre_label_batch", + } + }, + "/datasets/{dataset_id}/assets/{asset_id}": { + "delete": { + "description": "Take one asset out of the trunk.\n\nCuration, not deletion: the asset, its annotations and its bytes all stay\nexactly where they were, and only the membership row goes. That is why there\nis no `confirm` gate here \u2014 there is nothing to destroy.\n\n204 whether or not the asset was a member. An id that was never in the trunk\nleaves it in the state the caller asked for, and reporting that as a 404\nwould make a retry of a successful request look like a failure. The change\nlog records only the calls that actually changed something. The dataset is\nthe one thing that has to exist: an unknown one is 404 `DATASET_NOT_FOUND`.\n\nNot permanent, either: re-promoting the batch the asset came from puts it\nback, because the trunk keeps no memory of removals.", + "operationId": "remove_dataset_asset", "parameters": [ { "in": "path", - "name": "batch_id", + "name": "dataset_id", "required": true, "schema": { "format": "uuid", - "title": "Batch Id", + "title": "Dataset Id", + "type": "string" + } + }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset Id", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PreLabelRequest" - } - } - }, - "required": true - }, "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PreLabelFanOutOut" - } - } - }, + "204": { "description": "Successful Response" }, "401": { @@ -7088,16 +8124,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -7134,24 +8160,34 @@ "HTTPBearer": [] } ], - "summary": "Pre Label Batch", + "summary": "Remove Dataset Asset", "tags": [ - "batches" + "datasets" ] } }, - "/batches/{batch_id}/promote": { - "post": { - "description": "Move the batch's labeled assets into its project's dataset.\n\nThe one gate into the trunk. Which assets go in is derived, not chosen: those\nan annotator left `annotated` or a reviewer left `accepted`. A `skipped`\nasset stays out by design, and the decision stays on the record rather than\nbeing erased from the batch.\n\nIdempotent, and a union rather than a replacement. Promoting the same batch\ntwice returns an empty list the second time and writes no change-log entry,\nbecause nothing happened \u2014 and re-promoting after a curator removed an asset\nputs it back, since the trunk keeps no memory of removals.\n\nA batch that has not reached `completed` is 409 `BATCH_NOT_COMPLETE`.", - "operationId": "promote_batch", + "/datasets/{dataset_id}/assets/{asset_id}/annotations": { + "get": { + "description": "Every annotation on one member of the trunk, in the order they were added.\n\nThe trunk's view of an asset, so it needs no job id \u2014 a label hangs off its\nasset, and promotion brought everything drawn on it. Empty for a member\nnobody labeled, which is legitimate training data and not an error. An\nunknown dataset is 404 `DATASET_NOT_FOUND`; an asset the trunk does not hold\n\u2014 skipped, or removed by a curator \u2014 is 404 `ASSET_NOT_IN_DATASET`, which is\nnot a claim that the asset is gone.", + "operationId": "list_dataset_asset_annotations", "parameters": [ { "in": "path", - "name": "batch_id", + "name": "dataset_id", "required": true, "schema": { "format": "uuid", - "title": "Batch Id", + "title": "Dataset Id", + "type": "string" + } + }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset Id", "type": "string" } } @@ -7161,7 +8197,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetPage" + "$ref": "#/components/schemas/AnnotationPage" } } }, @@ -7187,16 +8223,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -7233,36 +8259,26 @@ "HTTPBearer": [] } ], - "summary": "Promote Batch", + "summary": "List Dataset Asset Annotations", "tags": [ - "batches" + "datasets" ] } }, - "/batches/{batch_id}/repin": { - "post": { - "description": "Move the batch's schema pin onto the project's current active version.\n\nExplicit, never automatic \u2014 the pin does not follow the schema, because a\ncontract that moved under work in flight is what versioning exists to\nprevent. This is how a class added *after* approval becomes usable in a batch\nsomebody is already annotating, without abandoning it.\n\nAdding a class is additive and goes through with no flag. A change that\nnarrows what the pin allowed \u2014 a class removed, a geometry changed, an\nattribute made required \u2014 is 409 `DESTRUCTIVE_SCHEMA_CHANGE`; retry the\nidentical request with `?allow_destructive=true`. If this batch already holds\nannotations under a class the change would break, it is 409\n`SCHEMA_CHANGE_WOULD_ORPHAN` and **no flag overrides it** \u2014 branch on the\ncode, never on the status. The orphan check is scoped to this batch: a label\nwritten in some *other* batch does not block this one.\n\nLegal only while the batch is `approved` or `in_annotation`; a draft has no\npin yet and a completed batch's pin is history, both 409\n`INVALID_TRANSITION`. Re-pinning onto the version already pinned changes\nnothing. Annotations already written keep the version they were stamped with.", - "operationId": "repin_batch", + "/datasets/{dataset_id}/changes": { + "get": { + "description": "The trunk's append-only mutation log, oldest entry first.\n\nEvery line is a change somebody can point at: a promote that added nothing\nwrites no entry, and neither does removing an asset that was not there.\nEntries are never updated or deleted.\n\n`subject_ids` is shaped by the operation \u2014 for `promote` it is the batch\nfollowed by the assets it contributed, and for `remove_asset` it is the one\nasset. `operation` is an open string rather than an enum, so an entry written\nby a later VisionSet naming an operation this build has not heard of is still\nreadable.", + "operationId": "list_dataset_changes", "parameters": [ { "in": "path", - "name": "batch_id", + "name": "dataset_id", "required": true, "schema": { "format": "uuid", - "title": "Batch Id", + "title": "Dataset Id", "type": "string" } - }, - { - "in": "query", - "name": "allow_destructive", - "required": false, - "schema": { - "default": false, - "title": "Allow Destructive", - "type": "boolean" - } } ], "responses": { @@ -7270,7 +8286,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchOut" + "$ref": "#/components/schemas/DatasetChangePage" } } }, @@ -7296,16 +8312,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -7342,24 +8348,24 @@ "HTTPBearer": [] } ], - "summary": "Repin Batch", + "summary": "List Dataset Changes", "tags": [ - "batches" + "datasets" ] } }, - "/batches/{batch_id}/start": { - "post": { - "description": "Open the batch for annotation. Nothing may be written into it before this.", - "operationId": "start_batch", + "/datasets/{dataset_id}/releases": { + "get": { + "description": "Every release of that dataset, oldest first.", + "operationId": "list_releases", "parameters": [ { "in": "path", - "name": "batch_id", + "name": "dataset_id", "required": true, "schema": { "format": "uuid", - "title": "Batch Id", + "title": "Dataset Id", "type": "string" } } @@ -7369,7 +8375,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchOut" + "$ref": "#/components/schemas/ReleasePage" } } }, @@ -7395,16 +8401,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -7441,16 +8437,14 @@ "HTTPBearer": [] } ], - "summary": "Start Batch", + "summary": "List Releases", "tags": [ - "batches" + "releases" ] - } - }, - "/datasets/{dataset_id}": { - "get": { - "description": "The dataset with that id.", - "operationId": "get_dataset", + }, + "post": { + "description": "Freeze the trunk as it stands into an immutable, named snapshot.\n\nWhat is frozen is the content: every member asset, every annotation on it as\nit was, and the schema version those labels were judged against. Deleting an\nannotation afterwards cannot reach backwards into a published release.\n\nPublishing twice from an unchanged dataset produces byte-identical manifests\nand therefore the same `manifest_hash`, because nothing time-, machine- or\nidentity-specific goes inside the document. The tag, the timestamp and the\nbuild live on the release row instead.\n\n`split` is stored as a recipe, not materialized. `GET\n/releases/{release_id}/assignment` cuts the folds on demand, deterministically\nand from the frozen asset set. Fractions must sum to 1.0.\n\nTags are unique per dataset and **case-sensitive**, like a git tag: `v1.0` and\n`V1.0` are two releases, and reusing one is 409 `RELEASE_TAG_TAKEN`. A dataset\nwith no assets is 409 `EMPTY_RELEASE`; zero *annotations* is fine, since\nunlabeled images are legitimate training data. A project with no schema is 404\n`SCHEMA_NOT_FOUND`, because there is no version to pin, and an unknown dataset\nis 404 `DATASET_NOT_FOUND`.\n\nOne refusal is about the labels rather than about the request: an annotation\ncarrying a coordinate canonical JSON cannot express \u2014 a NaN or an infinity \u2014\nis 409 `UNSERIALIZABLE_MANIFEST`, and the message names it. Nothing is\npublished, because writing that value as `null` would lose it silently and\nwriting it as `NaN` would produce a manifest no other tool can read. The\nremedy is to correct the annotation and publish again.\n\nThe active schema must also describe every annotation the release would\nfreeze. Otherwise publishing is 409\n`RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA`, with per-class blockers in `detail`.\nReconcile those annotations or restore a compatible active schema, then\npublish again.", + "operationId": "publish_release", "parameters": [ { "in": "path", @@ -7463,12 +8457,22 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseCreate" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DatasetOut" + "$ref": "#/components/schemas/ReleaseOut" } } }, @@ -7494,6 +8498,16 @@ }, "description": "No such resource" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { @@ -7530,16 +8544,16 @@ "HTTPBearer": [] } ], - "summary": "Get Dataset", + "summary": "Publish Release", "tags": [ - "datasets" + "releases" ] } }, - "/datasets/{dataset_id}/assets": { + "/datasets/{dataset_id}/stats": { "get": { - "description": "Everything in the trunk, in the order it was promoted, each with its labels summarised.\n\nPaged, and the second route in the API that is \u2014 the trunk accumulates every\nbatch a project ever completed, so it is the other collection that can hold\nfifty thousand items. `total` is the size of the whole trunk and not of the\npage; an offset past the end is an empty list and a 200, never a 404. The\n404 is the dataset itself: an unknown one is `DATASET_NOT_FOUND`.\n\nOrder is the stored insertion order, so reading twice gives the same sequence\nand promoting a new batch appends rather than reshuffles.\n\nEach member carries `annotation_count` and `label_classes` so a gallery can\ncaption a tile without a request per frame; the labels themselves are one\nlevel down, at `GET /datasets/{dataset_id}/assets/{asset_id}/annotations`.", - "operationId": "list_dataset_assets", + "description": "What the trunk currently holds, counted overall and per label class.\n\nCounted on every call rather than cached, so it always describes the trunk as\nit stands. `classes` lists only classes that appear at least once \u2014 which\nclasses *exist* is a property of the schema, and `GET\n/projects/{project_id}/schema` is where to read that.\n\nPer class you get both numbers because they answer different questions: a\nthousand `sign` boxes over a thousand images and the same thousand over ten\nare the same `annotations` and a very different dataset.\n\n`asset_count` minus `annotated_asset_count` is how many members carry no\nlabels at all, which is legitimate \u2014 unlabeled images are training data, and\nonly a release of *zero* assets is refused.", + "operationId": "dataset_stats", "parameters": [ { "in": "path", @@ -7550,38 +8564,6 @@ "title": "Dataset Id", "type": "string" } - }, - { - "description": "How many items to return. Everything from `offset` on by default.", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "anyOf": [ - { - "minimum": 1, - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "How many items to return. Everything from `offset` on by default.", - "title": "Limit" - } - }, - { - "description": "How many items to skip. Counts from the start of the collection.", - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "description": "How many items to skip. Counts from the start of the collection.", - "minimum": 0, - "title": "Offset", - "type": "integer" - } } ], "responses": { @@ -7589,7 +8571,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DatasetAssetPage" + "$ref": "#/components/schemas/DatasetStatsOut" } } }, @@ -7651,53 +8633,28 @@ "HTTPBearer": [] } ], - "summary": "List Dataset Assets", + "summary": "Dataset Stats", "tags": [ "datasets" ] } }, - "/datasets/{dataset_id}/assets/{asset_id}": { - "delete": { - "description": "Take one asset out of the trunk.\n\nCuration, not deletion: the asset, its annotations and its bytes all stay\nexactly where they were, and only the membership row goes. That is why there\nis no `confirm` gate here \u2014 there is nothing to destroy.\n\n204 whether or not the asset was a member. An id that was never in the trunk\nleaves it in the state the caller asked for, and reporting that as a 404\nwould make a retry of a successful request look like a failure. The change\nlog records only the calls that actually changed something. The dataset is\nthe one thing that has to exist: an unknown one is 404 `DATASET_NOT_FOUND`.\n\nNot permanent, either: re-promoting the batch the asset came from puts it\nback, because the trunk keeps no memory of removals.", - "operationId": "remove_dataset_asset", - "parameters": [ - { - "in": "path", - "name": "dataset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Dataset Id", - "type": "string" - } - }, - { - "in": "path", - "name": "asset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Asset Id", - "type": "string" - } - } - ], + "/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": { - "204": { - "description": "Successful Response" - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorBody" + "$ref": "#/components/schemas/ExportTargetPage" } } }, - "description": "Missing or invalid bearer token" + "description": "Successful Response" }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -7705,7 +8662,7 @@ } } }, - "description": "No such resource" + "description": "Missing or invalid bearer token" }, "422": { "content": { @@ -7743,44 +8700,22 @@ "HTTPBearer": [] } ], - "summary": "Remove Dataset Asset", + "summary": "List Export Targets", "tags": [ - "datasets" + "formats" ] } }, - "/datasets/{dataset_id}/assets/{asset_id}/annotations": { + "/formats": { "get": { - "description": "Every annotation on one member of the trunk, in the order they were added.\n\nThe trunk's view of an asset, so it needs no job id \u2014 a label hangs off its\nasset, and promotion brought everything drawn on it. Empty for a member\nnobody labeled, which is legitimate training data and not an error. An\nunknown dataset is 404 `DATASET_NOT_FOUND`; an asset the trunk does not hold\n\u2014 skipped, or removed by a curator \u2014 is 404 `ASSET_NOT_IN_DATASET`, which is\nnot a claim that the asset is gone.", - "operationId": "list_dataset_asset_annotations", - "parameters": [ - { - "in": "path", - "name": "dataset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Dataset Id", - "type": "string" - } - }, - { - "in": "path", - "name": "asset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Asset Id", - "type": "string" - } - } - ], + "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": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnnotationPage" + "$ref": "#/components/schemas/FormatPage" } } }, @@ -7796,16 +8731,6 @@ }, "description": "Missing or invalid bearer token" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "No such resource" - }, "422": { "content": { "application/json": { @@ -7842,40 +8767,32 @@ "HTTPBearer": [] } ], - "summary": "List Dataset Asset Annotations", + "summary": "List Formats", "tags": [ - "datasets" + "formats" ] } }, - "/datasets/{dataset_id}/changes": { + "/health": { "get": { - "description": "The trunk's append-only mutation log, oldest entry first.\n\nEvery line is a change somebody can point at: a promote that added nothing\nwrites no entry, and neither does removing an asset that was not there.\nEntries are never updated or deleted.\n\n`subject_ids` is shaped by the operation \u2014 for `promote` it is the batch\nfollowed by the assets it contributed, and for `remove_asset` it is the one\nasset. `operation` is an open string rather than an enum, so an entry written\nby a later VisionSet naming an operation this build has not heard of is still\nreadable.", - "operationId": "list_dataset_changes", - "parameters": [ - { - "in": "path", - "name": "dataset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Dataset Id", - "type": "string" - } - } - ], + "description": "Liveness probe. Public \u2014 no token required.", + "operationId": "health", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DatasetChangePage" + "additionalProperties": { + "type": "string" + }, + "title": "Response Health", + "type": "object" } } }, "description": "Successful Response" }, - "401": { + "422": { "content": { "application/json": { "schema": { @@ -7883,9 +8800,9 @@ } } }, - "description": "Missing or invalid bearer token" + "description": "The request payload is not processable" }, - "404": { + "500": { "content": { "application/json": { "schema": { @@ -7893,7 +8810,46 @@ } } }, - "description": "No such resource" + "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" + } + }, + "summary": "Health" + } + }, + "/home": { + "get": { + "description": "Everything the workspace's front page shows, in one response.\n\n`totals` counts the whole workspace. `projects` is a short shortcut into the\nproject list, not a copy of it, and `activity` is capped \u2014 both have a screen\nthat owns them in full.\n\n`resume` is the batch to carry on with, **derived on every call and never\nstored**. Read its `kind` first: `annotate` means `next_asset_id` is a frame\nnobody has labeled, `review` means it is one awaiting a reviewer, and `open`\nmeans the batch is settled throughout and `next_asset_id` is null \u2014 open its\ngallery rather than the editor. The three are in priority order, decided\nhere, and a client renders what it is told rather than working it out again.\n`resume` itself is null when no batch is open for annotation.\n\nBatches are ranked by when somebody last worked them. Ones nobody has worked\nsince that became recordable rank last, ordered among themselves by how far\nthrough they are \u2014 which is every batch in a workspace created before the\nstamp existed, since it was added without a backfill.\n\n`attention` carries batches with frames awaiting review, and background jobs\nthat failed or are still running. A job row has no `project_id`: a job names\nan ingest run or a release, never a project.\n\nAn empty workspace answers zeros, nulls and empty lists. That is the\nfirst-run state, and `totals.projects` is how a client recognises it.", + "operationId": "get_home", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HomeOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" }, "422": { "content": { @@ -7931,34 +8887,22 @@ "HTTPBearer": [] } ], - "summary": "List Dataset Changes", + "summary": "Get Home", "tags": [ - "datasets" + "home" ] } }, - "/datasets/{dataset_id}/releases": { + "/inference/connections": { "get": { - "description": "Every release of that dataset, oldest first.", - "operationId": "list_releases", - "parameters": [ - { - "in": "path", - "name": "dataset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Dataset Id", - "type": "string" - } - } - ], + "description": "Every configured connection in this workspace, in the order they were made.\n\nEach row carries its most recent weight download **and its most recent\nintegrity check**, so a client sees a run it did not start \u2014 after a reload,\nin a second tab, on another machine, or from a terminal. This is therefore the\nread a screen polls while either is live, and the reason it can stop polling\nthe moment neither is.\n\nA set-up connection that has never been asked what kind of model it holds is\nasked here, once, from files already on this disk \u2014 see\n``visionset.inference.weights.with_families``. It is the backfill for rows\nwritten before a connection recorded that, and it is on the read path because\nthe kernel cannot reach a model cache and a migration runs in the kernel.", + "operationId": "list_inference_connections", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReleasePage" + "$ref": "#/components/schemas/ConnectionPage" } } }, @@ -7974,16 +8918,6 @@ }, "description": "Missing or invalid bearer token" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "No such resource" - }, "422": { "content": { "application/json": { @@ -8020,31 +8954,19 @@ "HTTPBearer": [] } ], - "summary": "List Releases", + "summary": "List Inference Connections", "tags": [ - "releases" + "inference" ] }, "post": { - "description": "Freeze the trunk as it stands into an immutable, named snapshot.\n\nWhat is frozen is the content: every member asset, every annotation on it as\nit was, and the schema version those labels were judged against. Deleting an\nannotation afterwards cannot reach backwards into a published release.\n\nPublishing twice from an unchanged dataset produces byte-identical manifests\nand therefore the same `manifest_hash`, because nothing time-, machine- or\nidentity-specific goes inside the document. The tag, the timestamp and the\nbuild live on the release row instead.\n\n`split` is stored as a recipe, not materialized. `GET\n/releases/{release_id}/assignment` cuts the folds on demand, deterministically\nand from the frozen asset set. Fractions must sum to 1.0.\n\nTags are unique per dataset and **case-sensitive**, like a git tag: `v1.0` and\n`V1.0` are two releases, and reusing one is 409 `RELEASE_TAG_TAKEN`. A dataset\nwith no assets is 409 `EMPTY_RELEASE`; zero *annotations* is fine, since\nunlabeled images are legitimate training data. A project with no schema is 404\n`SCHEMA_NOT_FOUND`, because there is no version to pin, and an unknown dataset\nis 404 `DATASET_NOT_FOUND`.\n\nOne refusal is about the labels rather than about the request: an annotation\ncarrying a coordinate canonical JSON cannot express \u2014 a NaN or an infinity \u2014\nis 409 `UNSERIALIZABLE_MANIFEST`, and the message names it. Nothing is\npublished, because writing that value as `null` would lose it silently and\nwriting it as `NaN` would produce a manifest no other tool can read. The\nremedy is to correct the annotation and publish again.\n\nThe active schema must also describe every annotation the release would\nfreeze. Otherwise publishing is 409\n`RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA`, with per-class blockers in `detail`.\nReconcile those annotations or restore a compatible active schema, then\npublish again.", - "operationId": "publish_release", - "parameters": [ - { - "in": "path", - "name": "dataset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Dataset Id", - "type": "string" - } - } - ], + "description": "Configure a connection. Nothing is downloaded and nothing is contacted.", + "operationId": "create_inference_connection", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReleaseCreate" + "$ref": "#/components/schemas/ConnectionCreate" } } }, @@ -8055,7 +8977,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ReleaseOut" + "$ref": "#/components/schemas/ConnectionOut" } } }, @@ -8071,16 +8993,6 @@ }, "description": "Missing or invalid bearer token" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "No such resource" - }, "409": { "content": { "application/json": { @@ -8127,37 +9039,30 @@ "HTTPBearer": [] } ], - "summary": "Publish Release", + "summary": "Create Inference Connection", "tags": [ - "releases" + "inference" ] } }, - "/datasets/{dataset_id}/stats": { - "get": { - "description": "What the trunk currently holds, counted overall and per label class.\n\nCounted on every call rather than cached, so it always describes the trunk as\nit stands. `classes` lists only classes that appear at least once \u2014 which\nclasses *exist* is a property of the schema, and `GET\n/projects/{project_id}/schema` is where to read that.\n\nPer class you get both numbers because they answer different questions: a\nthousand `sign` boxes over a thousand images and the same thousand over ten\nare the same `annotations` and a very different dataset.\n\n`asset_count` minus `annotated_asset_count` is how many members carry no\nlabels at all, which is legitimate \u2014 unlabeled images are training data, and\nonly a release of *zero* assets is refused.", - "operationId": "dataset_stats", + "/inference/connections/{connection_id}": { + "delete": { + "description": "Remove a connection. Annotations keep the model provenance they recorded.\n\nNo ``confirm`` gate, unlike deleting a project: nothing holds a key to this\nrow, because an annotation copies its model's identity at write time rather\nthan pointing here. What is destroyed is a configuration.", + "operationId": "delete_inference_connection", "parameters": [ { "in": "path", - "name": "dataset_id", + "name": "connection_id", "required": true, "schema": { "format": "uuid", - "title": "Dataset Id", + "title": "Connection Id", "type": "string" } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DatasetStatsOut" - } - } - }, + "204": { "description": "Successful Response" }, "401": { @@ -8216,22 +9121,32 @@ "HTTPBearer": [] } ], - "summary": "Dataset Stats", + "summary": "Delete Inference Connection", "tags": [ - "datasets" + "inference" ] - } - }, - "/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", + "description": "The connection with that id.\n\nCarries the same backfill the listing does, and the same runs, so that reading\none connection and reading the list never disagree about what it can be asked\nfor or about what is happening to it.", + "operationId": "get_inference_connection", + "parameters": [ + { + "in": "path", + "name": "connection_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connection Id", + "type": "string" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExportTargetPage" + "$ref": "#/components/schemas/ConnectionOut" } } }, @@ -8247,6 +9162,16 @@ }, "description": "Missing or invalid bearer token" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, "422": { "content": { "application/json": { @@ -8283,22 +9208,42 @@ "HTTPBearer": [] } ], - "summary": "List Export Targets", + "summary": "Get Inference Connection", "tags": [ - "formats" + "inference" ] - } - }, - "/formats": { - "get": { - "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", + }, + "patch": { + "description": "Edit a connection. Omitted fields are left alone; the kind cannot change.", + "operationId": "update_inference_connection", + "parameters": [ + { + "in": "path", + "name": "connection_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connection Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionUpdate" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FormatPage" + "$ref": "#/components/schemas/ConnectionOut" } } }, @@ -8314,6 +9259,26 @@ }, "description": "Missing or invalid bearer token" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { @@ -8350,32 +9315,40 @@ "HTTPBearer": [] } ], - "summary": "List Formats", + "summary": "Update Inference Connection", "tags": [ - "formats" + "inference" ] } }, - "/health": { - "get": { - "description": "Liveness probe. Public \u2014 no token required.", - "operationId": "health", + "/inference/connections/{connection_id}/check-integrity": { + "post": { + "description": "Re-read every cached file and compare it against what the hub published.\n\nThe `check_integrity` action. Distinct from `download_weights`\nover the same files, and the distinction is what each can prove: a download\nagainst a set-up connection establishes that nothing is **missing**, reading\nan index rather than the files; this establishes that nothing is\n**damaged**, and can only do so by reading every byte.\n\n**202, not 200.** A snapshot is gigabytes and this reads all of it, so it\nfollows the launch-and-poll contract the download route uses. The run is then\non the connection itself as `integrity_check`, which is what lets a client\nthat never made this request \u2014 after a reload, in another tab, or beside a\nterminal that started it \u2014 see one in flight and how it ended. `GET\n/background-jobs/{id}` answers the same run, and the `Location` header names\nit; a successful job's result carries how many files were read and how many\nbytes that came to.\n\n**Only for a local connection that is already set up.** An HTTP connection\nhas no files here and one whose weights never arrived has none to read;\nboth are 409 `INFERENCE_CONNECTION_NOT_CHECKABLE`, the same answer\n`allowed_actions` gave, from the same table. A deployment without the local\nruntime is refused here too, with the install command.\n\n**A failed check has already acted.** Damage means the offending files are\npurged and the connection is back to `not_set_up` by the time the job row\nsays so \u2014 purged first, because a cache hit is returned unread and a\ndownload over damaged bytes would otherwise hand them straight back. So the\nremedy is the `download_weights` the connection now declares, and it is a\nreal transfer. A check that could not reach the hub changes nothing and\npurges nothing: no digests to compare against is an absence of evidence, not\na verdict.\n\n**Asking twice joins the check already running rather than starting a second\none**, the download route's rule and its reason: a request arriving while\nthis connection has a check queued or running is answered with that run's id,\nso nobody pays to read a multi-gigabyte snapshot twice to reach the verdict\nalready being reached.\n\n**A download running against the same connection does not refuse this**, and\nthat is deliberate rather than an omission. What a connection declares stays\na function of its setup state and its kind, so no run of either kind changes\nwhat it will accept \u2014 see `connection_actions`. The refusal such a rule would\nneed could only see *jobs*, and this is the only one of the three surfaces\nthat makes one: the CLI and the MCP tools run the same two operations inline,\nwith no row to see. So it would bind one caller in three while claiming an\nexclusivity none could rely on, and a worker dying mid-job would strand the\nconnection behind it.", + "operationId": "check_connection_integrity", + "parameters": [ + { + "in": "path", + "name": "connection_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connection Id", + "type": "string" + } + } + ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "additionalProperties": { - "type": "string" - }, - "title": "Response Health", - "type": "object" + "$ref": "#/components/schemas/BackgroundJobOut" } } }, "description": "Successful Response" }, - "422": { + "401": { "content": { "application/json": { "schema": { @@ -8383,9 +9356,9 @@ } } }, - "description": "The request payload is not processable" + "description": "Missing or invalid bearer token" }, - "500": { + "404": { "content": { "application/json": { "schema": { @@ -8393,9 +9366,9 @@ } } }, - "description": "Unhandled server error, with an incident id" + "description": "No such resource" }, - "503": { + "409": { "content": { "application/json": { "schema": { @@ -8403,28 +9376,19 @@ } } }, - "description": "The workspace is busy; retry after the header says" - } - }, - "summary": "Health" - } - }, - "/home": { - "get": { - "description": "Everything the workspace's front page shows, in one response.\n\n`totals` counts the whole workspace. `projects` is a short shortcut into the\nproject list, not a copy of it, and `activity` is capped \u2014 both have a screen\nthat owns them in full.\n\n`resume` is the batch to carry on with, **derived on every call and never\nstored**. Read its `kind` first: `annotate` means `next_asset_id` is a frame\nnobody has labeled, `review` means it is one awaiting a reviewer, and `open`\nmeans the batch is settled throughout and `next_asset_id` is null \u2014 open its\ngallery rather than the editor. The three are in priority order, decided\nhere, and a client renders what it is told rather than working it out again.\n`resume` itself is null when no batch is open for annotation.\n\nBatches are ranked by when somebody last worked them. Ones nobody has worked\nsince that became recordable rank last, ordered among themselves by how far\nthrough they are \u2014 which is every batch in a workspace created before the\nstamp existed, since it was added without a backfill.\n\n`attention` carries batches with frames awaiting review, and background jobs\nthat failed or are still running. A job row has no `project_id`: a job names\nan ingest run or a release, never a project.\n\nAn empty workspace answers zeros, nulls and empty lists. That is the\nfirst-run state, and `totals.projects` is how a client recognises it.", - "operationId": "get_home", - "responses": { - "200": { + "description": "The resource's state refuses this request" + }, + "422": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HomeOut" + "$ref": "#/components/schemas/ErrorBody" } } }, - "description": "Successful Response" + "description": "The request payload is not processable" }, - "401": { + "500": { "content": { "application/json": { "schema": { @@ -8432,9 +9396,9 @@ } } }, - "description": "Missing or invalid bearer token" + "description": "Unhandled server error, with an incident id" }, - "422": { + "503": { "content": { "application/json": { "schema": { @@ -8442,19 +9406,48 @@ } } }, - "description": "The request payload is not processable" - }, - "500": { + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Check Connection Integrity", + "tags": [ + "inference" + ] + } + }, + "/inference/connections/{connection_id}/download": { + "post": { + "description": "Fetch this connection's weights, and answer at once with the job to poll.\n\nThe `download_weights` action, and the only thing in this product that\ndownloads a model at all. It runs because somebody asked: nothing fetches\nweights at install time, at startup, or on the way to anything else.\n\n**202, not 200.** Weights for a detector of this class are gigabytes, so this\nfollows the launch-and-poll contract the export route uses: poll `GET\n/background-jobs/{id}` \u2014 the `Location` header names it \u2014 until `state` is\n`succeeded`, then re-read the connection to see `setup_state` as `ready`.\n\n**Everything a caller can be told now is told now.** A connection that is\nalready set up, or one whose model runs elsewhere, is 409\n`INFERENCE_CONNECTION_NOT_DOWNLOADABLE` on this request \u2014 the same answer\n`allowed_actions` gave, from the same table. A deployment without the local\nruntime installed is refused here too, with the exact install command in the\nmessage. Neither refusal creates a job, so a caller holding a job id holds\none that will run.\n\nThe action is declared on a connection whose *state* permits it even where\nthe runtime is missing, deliberately: whether this machine has the extra is\nnot a fact about the connection, and hiding the control would leave the\ninstall command with nowhere to be shown.\n\nRe-running is safe. The job verifies a cache it already filled rather than\nre-fetching it, and a run that fails leaves the connection exactly as it was\n\u2014 there is no half-set-up state to recover from.\n\n**Asking twice joins the download already running rather than starting a\nsecond one.** A request that arrives while this connection has a download\nqueued or running is answered with *that* run's id, so a double-click, a\nsecond tab and a retried request all watch one transfer instead of paying\nfor the same gigabytes twice. Every answer is still 202 with a `Location`,\nand a client polls what it is given either way.", + "operationId": "download_connection_weights", + "parameters": [ + { + "in": "path", + "name": "connection_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connection Id", + "type": "string" + } + } + ], + "responses": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorBody" + "$ref": "#/components/schemas/BackgroundJobOut" } } }, - "description": "Unhandled server error, with an incident id" + "description": "Successful Response" }, - "503": { + "401": { "content": { "application/json": { "schema": { @@ -8462,36 +9455,19 @@ } } }, - "description": "The workspace is busy; retry after the header says" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Get Home", - "tags": [ - "home" - ] - } - }, - "/inference/connections": { - "get": { - "description": "Every configured connection in this workspace, in the order they were made.\n\nEach row carries its most recent weight download **and its most recent\nintegrity check**, so a client sees a run it did not start \u2014 after a reload,\nin a second tab, on another machine, or from a terminal. This is therefore the\nread a screen polls while either is live, and the reason it can stop polling\nthe moment neither is.\n\nA set-up connection that has never been asked what kind of model it holds is\nasked here, once, from files already on this disk \u2014 see\n``visionset.inference.weights.with_families``. It is the backfill for rows\nwritten before a connection recorded that, and it is on the read path because\nthe kernel cannot reach a model cache and a migration runs in the kernel.", - "operationId": "list_inference_connections", - "responses": { - "200": { + "description": "Missing or invalid bearer token" + }, + "404": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConnectionPage" + "$ref": "#/components/schemas/ErrorBody" } } }, - "description": "Successful Response" + "description": "No such resource" }, - "401": { + "409": { "content": { "application/json": { "schema": { @@ -8499,7 +9475,7 @@ } } }, - "description": "Missing or invalid bearer token" + "description": "The resource's state refuses this request" }, "422": { "content": { @@ -8537,26 +9513,30 @@ "HTTPBearer": [] } ], - "summary": "List Inference Connections", + "summary": "Download Connection Weights", "tags": [ "inference" ] - }, + } + }, + "/inference/connections/{connection_id}/test-endpoint": { "post": { - "description": "Configure a connection. Nothing is downloaded and nothing is contacted.", - "operationId": "create_inference_connection", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConnectionCreate" - } + "description": "Ask an `http` connection's endpoint what it answers, and record the answer.\n\nThe `test_endpoint` action. One `GET` to the connection's `endpoint_url`,\nwhich answers `{\"model_ref\": \u2026, \"capability\": \u2026}` \u2014 this project's endpoint\ncontract. The declared capability becomes the connection's `capabilities`,\nwhich is what lets the suggest tool and pre-labeling offer it. Asking again\nre-asks and overwrites, so an endpoint that now serves a different model\ndeclares that on its next test.\n\n**Only for an `http` connection.** A local one has no endpoint to ask: 409\n`INFERENCE_CONNECTION_NOT_TESTABLE`, the same answer `allowed_actions` gave.\nAn endpoint that cannot be reached, does not answer in time, answers outside\nthe contract, or declares a capability this build does not know is 502\n`INFERENCE_ENDPOINT_UNAVAILABLE`; the message names the endpoint and what\nhappened, and nothing is recorded.\n\n`200` with the connection rather than `202` with a job: one small request,\nanswered while you wait.", + "operationId": "test_connection_endpoint", + "parameters": [ + { + "in": "path", + "name": "connection_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Connection Id", + "type": "string" } - }, - "required": true - }, + } + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { @@ -8576,6 +9556,16 @@ }, "description": "Missing or invalid bearer token" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, "409": { "content": { "application/json": { @@ -8606,6 +9596,16 @@ }, "description": "Unhandled server error, with an incident id" }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "An http connection's endpoint did not answer" + }, "503": { "content": { "application/json": { @@ -8622,43 +9622,48 @@ "HTTPBearer": [] } ], - "summary": "Create Inference Connection", + "summary": "Test Connection Endpoint", "tags": [ "inference" ] } }, - "/inference/connections/{connection_id}": { - "delete": { - "description": "Remove a connection. Annotations keep the model provenance they recorded.\n\nNo ``confirm`` gate, unlike deleting a project: nothing holds a key to this\nrow, because an annotation copies its model's identity at write time rather\nthan pointing here. What is destroyed is a configuration.", - "operationId": "delete_inference_connection", + "/inference/download-size": { + "get": { + "description": "How big fetching that model's weights would be, before anybody fetches them.\n\nWhat the local-connection form shows beside its confirm control, so that\n\"VisionSet downloads nothing on its own\" is a decision somebody can actually\nmake.\n\n**This downloads nothing.** It reads the publishing hub's file listing, which\nis the one question answerable before the download it describes. The number\ncovers every file in the revision, because that is what the download fetches.\n\nQuery parameters rather than a path, because a model id contains a slash\n(`facebook/sam2-hiera-base-plus`) and a segment that has to be escaped to be\nwritten is a URL people get wrong by hand.\n\n**Not a connection route**, and it takes no connection id: the moment the\nnumber is needed is the moment before the connection exists. Asking about a\nconnection that already exists is the same pair of values, asked the same way.\n\nRefused with the install command when the local runtime is absent \u2014 the size\nis read with the same client that would do the fetching \u2014 and refused rather\nthan guessed when the hub cannot size every file in the revision.", + "operationId": "inference_download_size", "parameters": [ { - "in": "path", - "name": "connection_id", + "in": "query", + "name": "model_id", "required": true, "schema": { - "format": "uuid", - "title": "Connection Id", + "title": "Model Id", + "type": "string" + } + }, + { + "in": "query", + "name": "model_revision", + "required": true, + "schema": { + "title": "Model Revision", "type": "string" } } ], "responses": { - "204": { - "description": "Successful Response" - }, - "401": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorBody" + "$ref": "#/components/schemas/DownloadSizeOut" } } }, - "description": "Missing or invalid bearer token" + "description": "Successful Response" }, - "404": { + "401": { "content": { "application/json": { "schema": { @@ -8666,7 +9671,7 @@ } } }, - "description": "No such resource" + "description": "Missing or invalid bearer token" }, "422": { "content": { @@ -8704,32 +9709,22 @@ "HTTPBearer": [] } ], - "summary": "Delete Inference Connection", + "summary": "Inference Download Size", "tags": [ "inference" ] - }, + } + }, + "/inference/providers": { "get": { - "description": "The connection with that id.\n\nCarries the same backfill the listing does, and the same runs, so that reading\none connection and reading the list never disagree about what it can be asked\nfor or about what is happening to it.", - "operationId": "get_inference_connection", - "parameters": [ - { - "in": "path", - "name": "connection_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Connection Id", - "type": "string" - } - } - ], + "description": "Every inference driver installed on this server, and what each offers.\n\n`families` maps a model type \u2014 the `model_type` a checkpoint's own config\ndeclares \u2014 onto what a model of that type can be asked for. It is the same\nvocabulary `capabilities` uses on a connection, and it answers a different\nquestion: this says what *could* run here, that says what one configured\nconnection's weights turned out to be.\n\n`curated` is the checkpoints a driver offers by name, in the order it\ndeclared them, and each entry's `capability` is a member of that same\nvocabulary \u2014 the one its family resolves to, through the driver that\ndeclared both. Filter on it rather than switching on it: the vocabulary is\nopen, so an entry may name an ability this client was never compiled\nagainst. Curation guides and never restricts: any model id remains typeable\nat any revision, and an empty list is an ordinary answer from a driver that\nruns whatever it is pointed at.\n\nA curated entry carries **no size**. What a download costs is\n`GET /inference/download-size`, read live for the exact pair, because a\nnumber frozen into a catalog would be a second answer to a question already\nanswered accurately.\n\nEmpty when nothing is installed, which is an answer rather than a failure.", + "operationId": "list_providers", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConnectionOut" + "$ref": "#/components/schemas/ProviderPage" } } }, @@ -8745,16 +9740,6 @@ }, "description": "Missing or invalid bearer token" }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "No such resource" - }, "422": { "content": { "application/json": { @@ -8791,31 +9776,21 @@ "HTTPBearer": [] } ], - "summary": "Get Inference Connection", + "summary": "List Providers", "tags": [ "inference" ] - }, - "patch": { - "description": "Edit a connection. Omitted fields are left alone; the kind cannot change.", - "operationId": "update_inference_connection", - "parameters": [ - { - "in": "path", - "name": "connection_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Connection Id", - "type": "string" - } - } - ], + } + }, + "/inference/suggest": { + "post": { + "description": "Propose a shape for the thing under those points.\n\nThe server side of the editor's suggest gesture. One asset, one prompt set,\none answer \u2014 batch prediction is a separate path and is not this one.\n\n**Nothing is written and nothing is remembered.** A suggestion is a proposal:\naccepting it is a later, ordinary annotation write carrying `provenance:\nmodel`, this response's `model_ref`, and its `confidence`. Discarding it\ncosts a request that already finished. The only thing that outlives the call\nis a cached image embedding, which is an optimisation rather than a record \u2014\nso the same points sent twice answer the same way, and a restart changes\nnothing but the latency of the first click.\n\n**The first click on an asset is the slow one.** A segmenter reads the whole\nimage once and then answers any number of clicks from that reading almost for\nfree, which is what makes refining by adding points practical. Sending the\naccumulated points \u2014 rather than a diff \u2014 is what keeps this stateless.\n\n**`allowed_geometries` is bounded by the caller's schema, and chosen within\nit.** The answer is produced in one of the kinds named or not at all: naming\npolygon gets the outline of the piece under the click, naming only box gets\none box over every piece the mask kept, and naming neither gets no regions.\nAnswering in a kind the schema would refuse would produce a suggestion that\ncannot be accepted, so every kind sent must be one the active class admits.\n\nWhich of them to send is the caller's decision, and it matters because **this\nroute prefers the polygon whenever both are named**. A client whose user is\nholding a box tool over a class that also accepts polygons sends `[\"bbox\"]`\nalone; sending both would answer past the tool they are holding, and nothing\non their screen would have said so.\n\n**`tolerance` is the one setting, and it does not reach the model.** It is a\ndistance in the asset's pixels: every point of the traced outline lies within\nit of the polygon returned. Optional, defaulting to `1.0`; refused outside\n`[0.25, 16]` rather than clamped. Closing the small gaps in a mask and\ndropping its noise specks still happen, at fixed defaults nobody asks for.\n\n**`parameters` says which settings apply here**, for the kind of shape this\nrequest will come back in. It is empty for a box class \u2014 the tolerance shapes an\noutline and a box has none \u2014 which is how a client is told to render no\nadjustments at all. It is present even when there is nothing to propose, so\nsomebody who adjusted their way into an empty answer can adjust their way\nback out. A client renders what this names and works none of it out itself.\n\n**`contour` on each region is the outline the shape was reduced from.** It is\nwhat lets a client re-run the tolerance locally rather than asking again, and it\nis the *same* points this route reduced \u2014 simplification is not nested, so a\nclient starting from anything else could not be held to the same answer. A\nbox carries none, because there is nothing it was reduced from.\n\n**Every point must be on the asset**, positive and negative alike \u2014 `x` in\n`[0, width]` and `y` in `[0, height]`, both ends included, in the asset's own\npixel frame. One point off the picture refuses the whole request with 422\n`PROMPT_POINT_OUT_OF_BOUNDS` rather than being dropped, because a gesture\nwith a point removed is a different gesture. Nothing is clamped: a\ncoordinate outside the frame is not a place on the image, and answering\nabout the nearest edge instead would return a mask, and a confidence, for a\nquestion nobody asked.\n\nAn empty `regions` is a successful answer with nothing to propose. These\nrefusals are about the request, and the caller can act on each: an unknown\nproject, asset or connection is 404 \u2014 `PROJECT_NOT_FOUND`, `ASSET_NOT_FOUND`\nor `INFERENCE_CONNECTION_NOT_FOUND`; a connection whose weights are not here\nyet is 409 `INFERENCE_CONNECTION_NOT_SET_UP` and names what to do; a\nconnection whose model answers words rather than places is 422\n`UNSUPPORTED_PROMPT`, as is a prompt point off the asset; an `http`\nconnection whose endpoint does not answer the contract is 502\n`INFERENCE_ENDPOINT_UNAVAILABLE`.\n\nThree failures are about this installation rather than about the request,\nand answer 500 carrying the message that says which: a connection of a kind\nthis build ships no adapter for is `INFERENCE_CONNECTION_NOT_RUNNABLE`, a\nmachine without the optional local runtime is `LOCAL_INFERENCE_UNAVAILABLE`\nand carries the command that installs it, and a model that will not fit the\ndevice it was asked to run on is `INFERENCE_OUT_OF_MEMORY`. None of the\nthree is worth resending unchanged: there is no state here to change, so the\nremedy is the one the message names \u2014 an install, a different device, a\nsmaller model, or a build that ships the adapter.", + "operationId": "suggest_region", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConnectionUpdate" + "$ref": "#/components/schemas/SuggestRequest" } } }, @@ -8826,7 +9801,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConnectionOut" + "$ref": "#/components/schemas/SuggestionOut" } } }, @@ -8882,6 +9857,16 @@ }, "description": "Unhandled server error, with an incident id" }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "An http connection's endpoint did not answer" + }, "503": { "content": { "application/json": { @@ -8898,34 +9883,34 @@ "HTTPBearer": [] } ], - "summary": "Update Inference Connection", + "summary": "Suggest Region", "tags": [ "inference" ] } }, - "/inference/connections/{connection_id}/check-integrity": { - "post": { - "description": "Re-read every cached file and compare it against what the hub published.\n\nThe `check_integrity` action. Distinct from `download_weights`\nover the same files, and the distinction is what each can prove: a download\nagainst a set-up connection establishes that nothing is **missing**, reading\nan index rather than the files; this establishes that nothing is\n**damaged**, and can only do so by reading every byte.\n\n**202, not 200.** A snapshot is gigabytes and this reads all of it, so it\nfollows the launch-and-poll contract the download route uses. The run is then\non the connection itself as `integrity_check`, which is what lets a client\nthat never made this request \u2014 after a reload, in another tab, or beside a\nterminal that started it \u2014 see one in flight and how it ended. `GET\n/background-jobs/{id}` answers the same run, and the `Location` header names\nit; a successful job's result carries how many files were read and how many\nbytes that came to.\n\n**Only for a local connection that is already set up.** An HTTP connection\nhas no files here and one whose weights never arrived has none to read;\nboth are 409 `INFERENCE_CONNECTION_NOT_CHECKABLE`, the same answer\n`allowed_actions` gave, from the same table. A deployment without the local\nruntime is refused here too, with the install command.\n\n**A failed check has already acted.** Damage means the offending files are\npurged and the connection is back to `not_set_up` by the time the job row\nsays so \u2014 purged first, because a cache hit is returned unread and a\ndownload over damaged bytes would otherwise hand them straight back. So the\nremedy is the `download_weights` the connection now declares, and it is a\nreal transfer. A check that could not reach the hub changes nothing and\npurges nothing: no digests to compare against is an absence of evidence, not\na verdict.\n\n**Asking twice joins the check already running rather than starting a second\none**, the download route's rule and its reason: a request arriving while\nthis connection has a check queued or running is answered with that run's id,\nso nobody pays to read a multi-gigabyte snapshot twice to reach the verdict\nalready being reached.\n\n**A download running against the same connection does not refuse this**, and\nthat is deliberate rather than an omission. What a connection declares stays\na function of its setup state and its kind, so no run of either kind changes\nwhat it will accept \u2014 see `connection_actions`. The refusal such a rule would\nneed could only see *jobs*, and this is the only one of the three surfaces\nthat makes one: the CLI and the MCP tools run the same two operations inline,\nwith no row to see. So it would bind one caller in three while claiming an\nexclusivity none could rely on, and a worker dying mid-job would strand the\nconnection behind it.", - "operationId": "check_connection_integrity", + "/ingest-jobs/{job_id}": { + "get": { + "description": "Where a run is now.\n\n`processed` and `total` are written as the run goes, so this answers \"where\nis it\" rather than \"where did it end\". `total` is null for a clip \u2014 a video's\nframe count is a guess before extraction, so it is not reported.\n\nTerminal states are `completed` and `failed`. A `failed` job keeps its\ncounters exactly where they stopped, and `error` says why; unreadable\nindividual items are in `failures` and never fail a run on their own.", + "operationId": "get_ingest_job", "parameters": [ { "in": "path", - "name": "connection_id", + "name": "job_id", "required": true, "schema": { "format": "uuid", - "title": "Connection Id", + "title": "Job Id", "type": "string" } } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackgroundJobOut" + "$ref": "#/components/schemas/IngestJobOut" } } }, @@ -8951,16 +9936,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -8997,24 +9972,24 @@ "HTTPBearer": [] } ], - "summary": "Check Connection Integrity", + "summary": "Get Ingest Job", "tags": [ - "inference" + "ingest" ] } }, - "/inference/connections/{connection_id}/download": { + "/ingest-jobs/{job_id}/resume": { "post": { - "description": "Fetch this connection's weights, and answer at once with the job to poll.\n\nThe `download_weights` action, and the only thing in this product that\ndownloads a model at all. It runs because somebody asked: nothing fetches\nweights at install time, at startup, or on the way to anything else.\n\n**202, not 200.** Weights for a detector of this class are gigabytes, so this\nfollows the launch-and-poll contract the export route uses: poll `GET\n/background-jobs/{id}` \u2014 the `Location` header names it \u2014 until `state` is\n`succeeded`, then re-read the connection to see `setup_state` as `ready`.\n\n**Everything a caller can be told now is told now.** A connection that is\nalready set up, or one whose model runs elsewhere, is 409\n`INFERENCE_CONNECTION_NOT_DOWNLOADABLE` on this request \u2014 the same answer\n`allowed_actions` gave, from the same table. A deployment without the local\nruntime installed is refused here too, with the exact install command in the\nmessage. Neither refusal creates a job, so a caller holding a job id holds\none that will run.\n\nThe action is declared on a connection whose *state* permits it even where\nthe runtime is missing, deliberately: whether this machine has the extra is\nnot a fact about the connection, and hiding the control would leave the\ninstall command with nowhere to be shown.\n\nRe-running is safe. The job verifies a cache it already filled rather than\nre-fetching it, and a run that fails leaves the connection exactly as it was\n\u2014 there is no half-set-up state to recover from.\n\n**Asking twice joins the download already running rather than starting a\nsecond one.** A request that arrives while this connection has a download\nqueued or running is answered with *that* run's id, so a double-click, a\nsecond tab and a retried request all watch one transfer instead of paying\nfor the same gigabytes twice. Every answer is still 202 with a `Location`,\nand a client polls what it is given either way.", - "operationId": "download_connection_weights", + "description": "Run a failed job again, on the same row and into the same batch.\n\nA redo, not a skip: the whole source is read again. That creates nothing it\ncreated before \u2014 content is addressed by hash and assets are deduplicated \u2014\nso the cost is re-reading and the gain is that resume has no second code path.\n\nA `completed` job cannot be resumed, and neither can one stuck at `running`:\nthat is a process that died without reporting, so ingest the source again\ninstead, which creates nothing and leaves the stuck row as the record it is.\nBoth answer 409 `INVALID_TRANSITION`.", + "operationId": "resume_ingest", "parameters": [ { "in": "path", - "name": "connection_id", + "name": "job_id", "required": true, "schema": { "format": "uuid", - "title": "Connection Id", + "title": "Job Id", "type": "string" } } @@ -9024,7 +9999,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackgroundJobOut" + "$ref": "#/components/schemas/IngestJobOut" } } }, @@ -9096,24 +10071,24 @@ "HTTPBearer": [] } ], - "summary": "Download Connection Weights", + "summary": "Resume Ingest", "tags": [ - "inference" + "ingest" ] } }, - "/inference/connections/{connection_id}/test-endpoint": { - "post": { - "description": "Ask an `http` connection's endpoint what it answers, and record the answer.\n\nThe `test_endpoint` action. One `GET` to the connection's `endpoint_url`,\nwhich answers `{\"model_ref\": \u2026, \"capability\": \u2026}` \u2014 this project's endpoint\ncontract. The declared capability becomes the connection's `capabilities`,\nwhich is what lets the suggest tool and pre-labeling offer it. Asking again\nre-asks and overwrites, so an endpoint that now serves a different model\ndeclares that on its next test.\n\n**Only for an `http` connection.** A local one has no endpoint to ask: 409\n`INFERENCE_CONNECTION_NOT_TESTABLE`, the same answer `allowed_actions` gave.\nAn endpoint that cannot be reached, does not answer in time, answers outside\nthe contract, or declares a capability this build does not know is 502\n`INFERENCE_ENDPOINT_UNAVAILABLE`; the message names the endpoint and what\nhappened, and nothing is recorded.\n\n`200` with the connection rather than `202` with a job: one small request,\nanswered while you wait.", - "operationId": "test_connection_endpoint", + "/jobs/{job_id}": { + "get": { + "description": "The job, and the batch it is a segment of.\n\n`batch_id` is the handle worth having: it leads to the schema version this\njob's work is judged against, which a job id alone does not.", + "operationId": "get_job", "parameters": [ { "in": "path", - "name": "connection_id", + "name": "job_id", "required": true, "schema": { "format": "uuid", - "title": "Connection Id", + "title": "Job Id", "type": "string" } } @@ -9123,7 +10098,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConnectionOut" + "$ref": "#/components/schemas/JobOut" } } }, @@ -9149,16 +10124,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -9179,16 +10144,6 @@ }, "description": "Unhandled server error, with an incident id" }, - "502": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "An http connection's endpoint did not answer" - }, "503": { "content": { "application/json": { @@ -9205,48 +10160,58 @@ "HTTPBearer": [] } ], - "summary": "Test Connection Endpoint", + "summary": "Get Job", "tags": [ - "inference" + "jobs" ] } }, - "/inference/download-size": { - "get": { - "description": "How big fetching that model's weights would be, before anybody fetches them.\n\nWhat the local-connection form shows beside its confirm control, so that\n\"VisionSet downloads nothing on its own\" is a decision somebody can actually\nmake.\n\n**This downloads nothing.** It reads the publishing hub's file listing, which\nis the one question answerable before the download it describes. The number\ncovers every file in the revision, because that is what the download fetches.\n\nQuery parameters rather than a path, because a model id contains a slash\n(`facebook/sam2-hiera-base-plus`) and a segment that has to be escaped to be\nwritten is a URL people get wrong by hand.\n\n**Not a connection route**, and it takes no connection id: the moment the\nnumber is needed is the moment before the connection exists. Asking about a\nconnection that already exists is the same pair of values, asked the same way.\n\nRefused with the install command when the local runtime is absent \u2014 the size\nis read with the same client that would do the fetching \u2014 and refused rather\nthan guessed when the hub cannot size every file in the revision.", - "operationId": "inference_download_size", + "/jobs/{job_id}/annotations": { + "delete": { + "description": "Remove annotations. One transaction, however many ids you pass.\n\nRepeating an id is not two deletions. An id that is not stored refuses the\nwhole call with 404 `ANNOTATION_NOT_FOUND` and removes nothing \u2014 there is no\npartial delete, for the reason there is no partial write. Removing a label is\nstill a write, so an asset that was skipped, submitted or accepted is 409\n`ASSET_NOT_WRITABLE` here too, a batch that is not open for annotation is 409\n`BATCH_NOT_IN_ANNOTATION`, and a job that was completed is 409 `JOB_FINISHED`.\nAn unknown job is 404 `JOB_NOT_FOUND`, and an id naming an annotation that\nsits outside this job is 422 `ASSET_NOT_IN_JOB`.\n\nNo confirmation gate: taking a box off is the ordinary annotator edit loop,\nnot the destruction of a lifecycle entity. The batch gate is the guard, so\nonce the work closes nothing here can touch it at all.", + "operationId": "delete_annotations", "parameters": [ { - "in": "query", - "name": "model_id", + "in": "path", + "name": "job_id", "required": true, "schema": { - "title": "Model Id", + "format": "uuid", + "title": "Job Id", "type": "string" } }, { + "description": "An annotation to delete. Repeat the parameter per id.", "in": "query", - "name": "model_revision", + "name": "id", "required": true, "schema": { - "title": "Model Revision", - "type": "string" + "description": "An annotation to delete. Repeat the parameter per id.", + "items": { + "format": "uuid", + "type": "string" + }, + "title": "Id", + "type": "array" } } ], "responses": { - "200": { + "204": { + "description": "Successful Response" + }, + "401": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DownloadSizeOut" + "$ref": "#/components/schemas/ErrorBody" } } }, - "description": "Successful Response" + "description": "Missing or invalid bearer token" }, - "401": { + "404": { "content": { "application/json": { "schema": { @@ -9254,7 +10219,17 @@ } } }, - "description": "Missing or invalid bearer token" + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" }, "422": { "content": { @@ -9292,22 +10267,46 @@ "HTTPBearer": [] } ], - "summary": "Inference Download Size", + "summary": "Delete Annotations", "tags": [ - "inference" + "annotations" ] - } - }, - "/inference/providers": { - "get": { - "description": "Every inference driver installed on this server, and what each offers.\n\n`families` maps a model type \u2014 the `model_type` a checkpoint's own config\ndeclares \u2014 onto what a model of that type can be asked for. It is the same\nvocabulary `capabilities` uses on a connection, and it answers a different\nquestion: this says what *could* run here, that says what one configured\nconnection's weights turned out to be.\n\n`curated` is the checkpoints a driver offers by name, in the order it\ndeclared them, and each entry's `capability` is a member of that same\nvocabulary \u2014 the one its family resolves to, through the driver that\ndeclared both. Filter on it rather than switching on it: the vocabulary is\nopen, so an entry may name an ability this client was never compiled\nagainst. Curation guides and never restricts: any model id remains typeable\nat any revision, and an empty list is an ordinary answer from a driver that\nruns whatever it is pointed at.\n\nA curated entry carries **no size**. What a download costs is\n`GET /inference/download-size`, read live for the exact pair, because a\nnumber frozen into a catalog would be a second answer to a question already\nanswered accurately.\n\nEmpty when nothing is installed, which is an answer rather than a failure.", - "operationId": "list_providers", + }, + "patch": { + "description": "Replace stored annotations whole, judged against the same pinned version.\n\nAddressed by `id` and by nothing else \u2014 annotations are never reached by\nindex or position. There is no `asset_id` on the body because the stored one\nwins: moving a label from one asset to another is a delete and an add, not an\nedit, and doing it silently would take an asset's last annotation away\nwithout anything saying so.\n\nAll-or-nothing, and `detail.index` names the culprit, exactly as on the POST.\nAn asset whose labeling is over is 409 `ASSET_NOT_WRITABLE`, as on the POST,\nand so are the two gates around it: a batch that is not open for annotation\nis 409 `BATCH_NOT_IN_ANNOTATION` and a job that was completed is 409\n`JOB_FINISHED`. An edit is a write, and every gate that stops a new label\nstops a replacement too.", + "operationId": "update_annotations", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Job Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AnnotationUpdate" + }, + "title": "Body", + "type": "array" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderPage" + "$ref": "#/components/schemas/AnnotationPage" } } }, @@ -9323,6 +10322,26 @@ }, "description": "Missing or invalid bearer token" }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { @@ -9359,32 +10378,46 @@ "HTTPBearer": [] } ], - "summary": "List Providers", + "summary": "Update Annotations", "tags": [ - "inference" + "annotations" ] - } - }, - "/inference/suggest": { + }, "post": { - "description": "Propose a shape for the thing under those points.\n\nThe server side of the editor's suggest gesture. One asset, one prompt set,\none answer \u2014 batch prediction is a separate path and is not this one.\n\n**Nothing is written and nothing is remembered.** A suggestion is a proposal:\naccepting it is a later, ordinary annotation write carrying `provenance:\nmodel`, this response's `model_ref`, and its `confidence`. Discarding it\ncosts a request that already finished. The only thing that outlives the call\nis a cached image embedding, which is an optimisation rather than a record \u2014\nso the same points sent twice answer the same way, and a restart changes\nnothing but the latency of the first click.\n\n**The first click on an asset is the slow one.** A segmenter reads the whole\nimage once and then answers any number of clicks from that reading almost for\nfree, which is what makes refining by adding points practical. Sending the\naccumulated points \u2014 rather than a diff \u2014 is what keeps this stateless.\n\n**`allowed_geometries` is bounded by the caller's schema, and chosen within\nit.** The answer is produced in one of the kinds named or not at all: naming\npolygon gets the outline of the piece under the click, naming only box gets\none box over every piece the mask kept, and naming neither gets no regions.\nAnswering in a kind the schema would refuse would produce a suggestion that\ncannot be accepted, so every kind sent must be one the active class admits.\n\nWhich of them to send is the caller's decision, and it matters because **this\nroute prefers the polygon whenever both are named**. A client whose user is\nholding a box tool over a class that also accepts polygons sends `[\"bbox\"]`\nalone; sending both would answer past the tool they are holding, and nothing\non their screen would have said so.\n\n**`tolerance` is the one setting, and it does not reach the model.** It is a\ndistance in the asset's pixels: every point of the traced outline lies within\nit of the polygon returned. Optional, defaulting to `1.0`; refused outside\n`[0.25, 16]` rather than clamped. Closing the small gaps in a mask and\ndropping its noise specks still happen, at fixed defaults nobody asks for.\n\n**`parameters` says which settings apply here**, for the kind of shape this\nrequest will come back in. It is empty for a box class \u2014 the tolerance shapes an\noutline and a box has none \u2014 which is how a client is told to render no\nadjustments at all. It is present even when there is nothing to propose, so\nsomebody who adjusted their way into an empty answer can adjust their way\nback out. A client renders what this names and works none of it out itself.\n\n**`contour` on each region is the outline the shape was reduced from.** It is\nwhat lets a client re-run the tolerance locally rather than asking again, and it\nis the *same* points this route reduced \u2014 simplification is not nested, so a\nclient starting from anything else could not be held to the same answer. A\nbox carries none, because there is nothing it was reduced from.\n\n**Every point must be on the asset**, positive and negative alike \u2014 `x` in\n`[0, width]` and `y` in `[0, height]`, both ends included, in the asset's own\npixel frame. One point off the picture refuses the whole request with 422\n`PROMPT_POINT_OUT_OF_BOUNDS` rather than being dropped, because a gesture\nwith a point removed is a different gesture. Nothing is clamped: a\ncoordinate outside the frame is not a place on the image, and answering\nabout the nearest edge instead would return a mask, and a confidence, for a\nquestion nobody asked.\n\nAn empty `regions` is a successful answer with nothing to propose. These\nrefusals are about the request, and the caller can act on each: an unknown\nproject, asset or connection is 404 \u2014 `PROJECT_NOT_FOUND`, `ASSET_NOT_FOUND`\nor `INFERENCE_CONNECTION_NOT_FOUND`; a connection whose weights are not here\nyet is 409 `INFERENCE_CONNECTION_NOT_SET_UP` and names what to do; a\nconnection whose model answers words rather than places is 422\n`UNSUPPORTED_PROMPT`, as is a prompt point off the asset; an `http`\nconnection whose endpoint does not answer the contract is 502\n`INFERENCE_ENDPOINT_UNAVAILABLE`.\n\nThree failures are about this installation rather than about the request,\nand answer 500 carrying the message that says which: a connection of a kind\nthis build ships no adapter for is `INFERENCE_CONNECTION_NOT_RUNNABLE`, a\nmachine without the optional local runtime is `LOCAL_INFERENCE_UNAVAILABLE`\nand carries the command that installs it, and a model that will not fit the\ndevice it was asked to run on is `INFERENCE_OUT_OF_MEMORY`. None of the\nthree is worth resending unchanged: there is no state here to change, so the\nremedy is the one the message names \u2014 an install, a different device, a\nsmaller model, or a build that ships the adapter.", - "operationId": "suggest_region", + "description": "Store annotations, judged against the version this job's batch pinned.\n\nAll-or-nothing: every annotation is validated before any of them is written,\nso a payload with one bad box stores nothing at all. A half-labeled asset is\nnot a state a client can reach.\n\nA refusal that is about one item carries `detail.index` \u2014 the position in the\narray you sent \u2014 because nothing was written and the message alone cannot say\nwhich one it was. `schema_version` is not yours to set: the pinned version is\nstamped onto whatever you send, and comes back on the response.\n\nAn unknown job is 404 `JOB_NOT_FOUND`. The batch must be `in_annotation`, or\nthis is 409 `BATCH_NOT_IN_ANNOTATION`, and the job itself must still be open:\none that was completed is 409 `JOB_FINISHED`, and a completed job has no way\nback, so the remedy is a new job over those assets rather than a retry. An\nasset the job does not carry is 422 `ASSET_NOT_IN_JOB`.\n\nAn annotation the pinned version does not describe is 422\n`INVALID_ANNOTATION`, which is the general answer; the specific ones carry\ntheir own codes \u2014 `LABEL_CLASS_NOT_IN_SCHEMA`, `DISALLOWED_GEOMETRY`,\n`MISSING_REQUIRED_ATTRIBUTE` and their kin \u2014 so a client that wants to say\nwhat is wrong reads the code rather than the status.\n\nThe asset must also still be open for labeling \u2014 `unannotated` or\n`annotated`. One that was skipped, submitted for review or accepted is 409\n`ASSET_NOT_WRITABLE`, and the message names the state it is in. The remedy is\na progress move where the table allows one (`skipped` back to `unannotated`);\n`accepted` has no exit, so correcting it means a new batch. Read\n`allowed_actions` on the batch's asset listing rather than guessing: it\ndeclares `annotate` exactly when this will be accepted.", + "operationId": "add_annotations", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Job Id", + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuggestRequest" + "items": { + "$ref": "#/components/schemas/AnnotationCreate" + }, + "title": "Body", + "type": "array" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SuggestionOut" + "$ref": "#/components/schemas/AnnotationPage" } } }, @@ -9440,16 +10473,6 @@ }, "description": "Unhandled server error, with an incident id" }, - "502": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "An http connection's endpoint did not answer" - }, "503": { "content": { "application/json": { @@ -9466,16 +10489,16 @@ "HTTPBearer": [] } ], - "summary": "Suggest Region", + "summary": "Add Annotations", "tags": [ - "inference" + "annotations" ] } }, - "/ingest-jobs/{job_id}": { + "/jobs/{job_id}/assets/{asset_id}/annotations": { "get": { - "description": "Where a run is now.\n\n`processed` and `total` are written as the run goes, so this answers \"where\nis it\" rather than \"where did it end\". `total` is null for a clip \u2014 a video's\nframe count is a guess before extraction, so it is not reported.\n\nTerminal states are `completed` and `failed`. A `failed` job keeps its\ncounters exactly where they stopped, and `error` says why; unreadable\nindividual items are in `failures` and never fail a run on their own.", - "operationId": "get_ingest_job", + "description": "Every annotation on one asset of this job, in the order they were added.\n\nEmpty for an asset nobody has labeled yet \u2014 the ordinary starting state, not\nan error. Reading is not gated on job or batch state: a label outlives the\nwork that produced it.", + "operationId": "list_asset_annotations", "parameters": [ { "in": "path", @@ -9486,6 +10509,16 @@ "title": "Job Id", "type": "string" } + }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset Id", + "type": "string" + } } ], "responses": { @@ -9493,7 +10526,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IngestJobOut" + "$ref": "#/components/schemas/AnnotationPage" } } }, @@ -9555,16 +10588,16 @@ "HTTPBearer": [] } ], - "summary": "Get Ingest Job", + "summary": "List Asset Annotations", "tags": [ - "ingest" + "annotations" ] } }, - "/ingest-jobs/{job_id}/resume": { - "post": { - "description": "Run a failed job again, on the same row and into the same batch.\n\nA redo, not a skip: the whole source is read again. That creates nothing it\ncreated before \u2014 content is addressed by hash and assets are deduplicated \u2014\nso the cost is re-reading and the gain is that resume has no second code path.\n\nA `completed` job cannot be resumed, and neither can one stuck at `running`:\nthat is a process that died without reporting, so ingest the source again\ninstead, which creates nothing and leaves the stuck row as the record it is.\nBoth answer 409 `INVALID_TRANSITION`.", - "operationId": "resume_ingest", + "/jobs/{job_id}/assets/{asset_id}/progress": { + "put": { + "description": "Record where one asset of this job has got to.\n\nOne route rather than five verbs, because the legal moves are a table in the\nkernel and a second spelling of it would drift: `unannotated` to `annotated`,\n`pre_labeled` or `skipped`; `pre_labeled` to `annotated`, `unannotated` or\n`skipped`; `annotated` to `review_pending` or back; `review_pending` to\n`accepted` or back to `annotated`; and `accepted` nowhere at all. Anything\nelse is 409 `INVALID_TRANSITION`.\n\nSetting the state an asset is already in is a no-op rather than a refusal \u2014\nbut the batch gate fires first, so writing into a closed batch is refused\nwhether or not the value would have changed: 409 `BATCH_NOT_IN_ANNOTATION`.\n\n409 `STALE_WRITE` is the other one, and it is not the same complaint: the\nmove was legal from the state the caller read, and somebody else moved the\nasset in between. Re-read the progress and decide again \u2014 resending this\nrequest unchanged would land a decision made about a state nobody is in any\nmore.\n\nLabels move `unannotated`, `pre_labeled` and `annotated` on their own as\nannotations are written, edited or deleted. This route is for the decisions\nthat are nobody's consequence: skipping, submitting for review, accepting.", + "operationId": "set_asset_progress", "parameters": [ { "in": "path", @@ -9575,14 +10608,34 @@ "title": "Job Id", "type": "string" } + }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset Id", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetProgressSet" + } + } + }, + "required": true + }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IngestJobOut" + "$ref": "#/components/schemas/AssetProgressOut" } } }, @@ -9654,16 +10707,16 @@ "HTTPBearer": [] } ], - "summary": "Resume Ingest", + "summary": "Set Asset Progress", "tags": [ - "ingest" + "jobs" ] } }, - "/jobs/{job_id}": { - "get": { - "description": "The job, and the batch it is a segment of.\n\n`batch_id` is the handle worth having: it leads to the schema version this\njob's work is judged against, which a job id alone does not.", - "operationId": "get_job", + "/jobs/{job_id}/assignee": { + "put": { + "description": "Name who is working this job, or clear it with `null`.\n\nInformational only \u2014 a name, not an account. Legal in any job or batch\nstate: naming who did a finished job is attribution, not a reopening.", + "operationId": "assign_job", "parameters": [ { "in": "path", @@ -9674,8 +10727,18 @@ "title": "Job Id", "type": "string" } - } - ], + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobAssign" + } + } + }, + "required": true + }, "responses": { "200": { "content": { @@ -9743,16 +10806,16 @@ "HTTPBearer": [] } ], - "summary": "Get Job", + "summary": "Assign Job", "tags": [ "jobs" ] } }, - "/jobs/{job_id}/annotations": { - "delete": { - "description": "Remove annotations. One transaction, however many ids you pass.\n\nRepeating an id is not two deletions. An id that is not stored refuses the\nwhole call with 404 `ANNOTATION_NOT_FOUND` and removes nothing \u2014 there is no\npartial delete, for the reason there is no partial write. Removing a label is\nstill a write, so an asset that was skipped, submitted or accepted is 409\n`ASSET_NOT_WRITABLE` here too, a batch that is not open for annotation is 409\n`BATCH_NOT_IN_ANNOTATION`, and a job that was completed is 409 `JOB_FINISHED`.\nAn unknown job is 404 `JOB_NOT_FOUND`, and an id naming an annotation that\nsits outside this job is 422 `ASSET_NOT_IN_JOB`.\n\nNo confirmation gate: taking a box off is the ordinary annotator edit loop,\nnot the destruction of a lifecycle entity. The batch gate is the guard, so\nonce the work closes nothing here can touch it at all.", - "operationId": "delete_annotations", + "/jobs/{job_id}/complete": { + "post": { + "description": "Close the job, if every asset in it has been dealt with.\n\nDealt with means `annotated`, `skipped` or `accepted`. An `unannotated` asset\nmeans the labeling has not happened, a `pre_labeled` one means a model's\nguess is still unjudged, and a `review_pending` one means the review has\nnot; any of the three answers 409 `JOB_NOT_COMPLETE` and says how many are\noutstanding.\n\nA job that is not `in_progress` is 409 `INVALID_TRANSITION`, and a batch that\nis not open for annotation is 409 `BATCH_NOT_IN_ANNOTATION`. Neither has a\nremedy on this route: `completed` is where the table ends, so settled work is\ncorrected through a new batch rather than reopened.\n\nCompleting a job does not complete its batch \u2014 `POST /batches/{id}/complete`\nderives that from all of them.", + "operationId": "complete_job", "parameters": [ { "in": "path", @@ -9763,25 +10826,17 @@ "title": "Job Id", "type": "string" } - }, - { - "description": "An annotation to delete. Repeat the parameter per id.", - "in": "query", - "name": "id", - "required": true, - "schema": { - "description": "An annotation to delete. Repeat the parameter per id.", - "items": { - "format": "uuid", - "type": "string" - }, - "title": "Id", - "type": "array" - } } ], "responses": { - "204": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobOut" + } + } + }, "description": "Successful Response" }, "401": { @@ -9850,14 +10905,16 @@ "HTTPBearer": [] } ], - "summary": "Delete Annotations", + "summary": "Complete Job", "tags": [ - "annotations" + "jobs" ] - }, - "patch": { - "description": "Replace stored annotations whole, judged against the same pinned version.\n\nAddressed by `id` and by nothing else \u2014 annotations are never reached by\nindex or position. There is no `asset_id` on the body because the stored one\nwins: moving a label from one asset to another is a delete and an add, not an\nedit, and doing it silently would take an asset's last annotation away\nwithout anything saying so.\n\nAll-or-nothing, and `detail.index` names the culprit, exactly as on the POST.\nAn asset whose labeling is over is 409 `ASSET_NOT_WRITABLE`, as on the POST,\nand so are the two gates around it: a batch that is not open for annotation\nis 409 `BATCH_NOT_IN_ANNOTATION` and a job that was completed is 409\n`JOB_FINISHED`. An edit is a write, and every gate that stops a new label\nstops a replacement too.", - "operationId": "update_annotations", + } + }, + "/jobs/{job_id}/next": { + "get": { + "description": "The next assets waiting to be labeled, in the batch's own order.\n\nOnly `unannotated` ones: this answers the annotator's question, and an asset\nin `review_pending` is waiting on a reviewer rather than on labeling. The\norder is stored, so the same call twice returns the same assets \u2014 and marking\nan unrelated one does not reshuffle what is left.\n\nFewer than `n` come back when fewer remain, and nothing at all once the job\nis done. `total` is the size of this answer, not of the job; the job's own\ntally is at `GET /jobs/{job_id}/progress`.", + "operationId": "next_pending_assets", "parameters": [ { "in": "path", @@ -9868,28 +10925,27 @@ "title": "Job Id", "type": "string" } + }, + { + "description": "How many waiting assets to hand out. Fewer if fewer remain.", + "in": "query", + "name": "n", + "required": false, + "schema": { + "default": 1, + "description": "How many waiting assets to hand out. Fewer if fewer remain.", + "minimum": 1, + "title": "N", + "type": "integer" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AnnotationUpdate" - }, - "title": "Body", - "type": "array" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnnotationPage" + "$ref": "#/components/schemas/AssetPage" } } }, @@ -9915,16 +10971,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -9961,14 +11007,16 @@ "HTTPBearer": [] } ], - "summary": "Update Annotations", + "summary": "Next Pending Assets", "tags": [ - "annotations" + "jobs" ] - }, + } + }, + "/jobs/{job_id}/pre-label": { "post": { - "description": "Store annotations, judged against the version this job's batch pinned.\n\nAll-or-nothing: every annotation is validated before any of them is written,\nso a payload with one bad box stores nothing at all. A half-labeled asset is\nnot a state a client can reach.\n\nA refusal that is about one item carries `detail.index` \u2014 the position in the\narray you sent \u2014 because nothing was written and the message alone cannot say\nwhich one it was. `schema_version` is not yours to set: the pinned version is\nstamped onto whatever you send, and comes back on the response.\n\nAn unknown job is 404 `JOB_NOT_FOUND`. The batch must be `in_annotation`, or\nthis is 409 `BATCH_NOT_IN_ANNOTATION`, and the job itself must still be open:\none that was completed is 409 `JOB_FINISHED`, and a completed job has no way\nback, so the remedy is a new job over those assets rather than a retry. An\nasset the job does not carry is 422 `ASSET_NOT_IN_JOB`.\n\nAn annotation the pinned version does not describe is 422\n`INVALID_ANNOTATION`, which is the general answer; the specific ones carry\ntheir own codes \u2014 `LABEL_CLASS_NOT_IN_SCHEMA`, `DISALLOWED_GEOMETRY`,\n`MISSING_REQUIRED_ATTRIBUTE` and their kin \u2014 so a client that wants to say\nwhat is wrong reads the code rather than the status.\n\nThe asset must also still be open for labeling \u2014 `unannotated` or\n`annotated`. One that was skipped, submitted for review or accepted is 409\n`ASSET_NOT_WRITABLE`, and the message names the state it is in. The remedy is\na progress move where the table allows one (`skipped` back to `unannotated`);\n`accepted` has no exit, so correcting it means a new batch. Read\n`allowed_actions` on the batch's asset listing rather than guessing: it\ndeclares `annotate` exactly when this will be accepted.", - "operationId": "add_annotations", + "description": "Ask a model to label every untouched asset in this job, and answer at once.\n\nThe `pre_label` action. Labels land at `pre_labeled`, never at `annotated`:\nnobody judged them, so they arrive editable and correctable rather than\nclaiming to be somebody's work \u2014 and, being unjudged, they never reach the\nDataset until a person has taken them over.\n\n**Only assets nothing has touched \u2014 which is stronger than reading\n`unannotated`.** An asset already `pre_labeled`, annotated, skipped,\nawaiting review or accepted is passed over, and so is an `unannotated` one\nthat still carries annotations from an earlier round that was skipped and\nthen restored: that sequence deletes no labels, so progress alone does not\nprove an asset untouched. A run never writes over what a person did in this\njob, and never writes twice over what a model did \u2014 a plain second run\nextends an earlier one onto whatever is still untouched.\n`replace_model_labels` widens it to every frame still `pre_labeled` and\nsupersedes those labels with this run's answer, one frame per transaction;\na frame anyone edited, confirmed or skipped in this job is never touched,\nand a frame the model now finds nothing on returns to `unannotated`. A\nreplacing request arriving while a run is in flight joins that run,\nwhichever flag it carries.\n\n**The batch's pinned schema is the prompt, narrowed to what this run\nwrites.** The model is asked for each class the schema declares that admits\none of the shapes the run writes and demands no attribute a prediction\ncannot supply; an answer naming one of those classes, matched\ncase-insensitively, is written under the schema's own spelling, and an\nanswer naming none of them is discarded. A schema with no such class has\nnowhere for a prediction to land and is refused \u2014 so the same schema is\naskable of a model that answers polygons and refused for one that answers\nboxes. `GET /batches/{batch_id}/pre-label` with the same `connection_id`\n(and the same `geometries`) reads the narrowing before launching.\n\n**What the run writes is every shape the model produces, unless\n`geometries` says which.** A model declaring both a box and a polygon\nwrites both for every region it answers with \u2014 the kernel writes one\nannotation per emitted region and pairs nothing \u2014 and `geometries` filters\nthat to the shapes named: a region in any other shape is discarded and\ncounted in `regions_discarded`. The selection is per run, not per class,\nand it is kept on the queued row, so a run claimed later executes what was\nasked.\n\n**202, not 200.** A job is hundreds of forward passes, so this follows the\nlaunch-and-poll contract the export and weight-download routes use: poll `GET\n/background-jobs/{id}` \u2014 the `Location` header names it \u2014 until `state` is\n`succeeded`, then re-read the job's assets. Progress on the row is counted\nin assets, and `JobOut.pre_label_run` remembers the same row afterwards.\n\n**Everything a caller can be told now is told now**, and no refusal creates a\njob \u2014 so a caller holding a job id holds one that will run. These refusals\nare about the request, and the caller can act on each. They are checked in\nthis order, and it is the order `pre_label` itself checks in, so a request\nwrong about the connection and the job both always names the connection:\nan unknown connection is 404 `INFERENCE_CONNECTION_NOT_FOUND`; a\nconnection not set up yet is 409 `INFERENCE_CONNECTION_NOT_SET_UP` \u2014 its\nweights not here, or its endpoint not yet asked what it answers; a\nconnection whose model answers places rather than words is 422\n`UNSUPPORTED_PROMPT`; a `geometries` naming a shape the model does not\nproduce is 422 `GEOMETRY_NOT_PRODUCED`. An unknown job is 404\n`JOB_NOT_FOUND`; a job whose batch is not `in_annotation` is 409\n`BATCH_NOT_IN_ANNOTATION`; a job already `completed` is 409 `JOB_FINISHED`,\nand there is no remedy on this route \u2014 settled work is corrected through a\nnew batch rather than reopened. A pinned schema with no class the selected\nshapes can be written as is 409 `SCHEMA_HAS_NO_DETECTABLE_CLASS`.\n\nTwo failures are about this installation rather than about the request, and\nanswer 500 carrying the message that says which: a machine without the\noptional local runtime is `LOCAL_INFERENCE_UNAVAILABLE` and carries the\nexact command that installs it, and a workspace whose records no longer\nhold together \u2014 a batch pinned to a schema version that is not stored \u2014 is\n`WORKSPACE_CORRUPT`. Neither is worth resending unchanged: there is no\nstate here a caller can change, so the remedy is the one the message names.\n\n**Asking twice joins the run already in flight rather than starting a second\none.** A request arriving while this job has a pre-labeling run queued or\nrunning is answered with that run's id, so a double-click and a second tab\nwatch one run instead of paying for the same inference twice \u2014 and so does\n`POST /batches/{batch_id}/pre-label`, whose fan-out reaches this same job.", + "operationId": "pre_label_job", "parameters": [ { "in": "path", @@ -9985,22 +11033,18 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/AnnotationCreate" - }, - "title": "Body", - "type": "array" + "$ref": "#/components/schemas/PreLabelRequest" } } }, "required": true }, "responses": { - "201": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnnotationPage" + "$ref": "#/components/schemas/BackgroundJobOut" } } }, @@ -10072,16 +11116,16 @@ "HTTPBearer": [] } ], - "summary": "Add Annotations", + "summary": "Pre Label Job", "tags": [ - "annotations" + "jobs" ] } }, - "/jobs/{job_id}/assets/{asset_id}/annotations": { + "/jobs/{job_id}/progress": { "get": { - "description": "Every annotation on one asset of this job, in the order they were added.\n\nEmpty for an asset nobody has labeled yet \u2014 the ordinary starting state, not\nan error. Reading is not gated on job or batch state: a label outlives the\nwork that produced it.", - "operationId": "list_asset_annotations", + "description": "How many of this job's assets sit in each state.\n\nEvery state is a field, including the ones nobody is in, so a client charting\nprogress never has to guard a lookup.", + "operationId": "get_job_progress", "parameters": [ { "in": "path", @@ -10092,16 +11136,6 @@ "title": "Job Id", "type": "string" } - }, - { - "in": "path", - "name": "asset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Asset Id", - "type": "string" - } } ], "responses": { @@ -10109,7 +11143,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AnnotationPage" + "$ref": "#/components/schemas/ProgressCounts" } } }, @@ -10171,16 +11205,16 @@ "HTTPBearer": [] } ], - "summary": "List Asset Annotations", + "summary": "Get Job Progress", "tags": [ - "annotations" + "jobs" ] } }, - "/jobs/{job_id}/assets/{asset_id}/progress": { - "put": { - "description": "Record where one asset of this job has got to.\n\nOne route rather than five verbs, because the legal moves are a table in the\nkernel and a second spelling of it would drift: `unannotated` to `annotated`,\n`pre_labeled` or `skipped`; `pre_labeled` to `annotated`, `unannotated` or\n`skipped`; `annotated` to `review_pending` or back; `review_pending` to\n`accepted` or back to `annotated`; and `accepted` nowhere at all. Anything\nelse is 409 `INVALID_TRANSITION`.\n\nSetting the state an asset is already in is a no-op rather than a refusal \u2014\nbut the batch gate fires first, so writing into a closed batch is refused\nwhether or not the value would have changed: 409 `BATCH_NOT_IN_ANNOTATION`.\n\n409 `STALE_WRITE` is the other one, and it is not the same complaint: the\nmove was legal from the state the caller read, and somebody else moved the\nasset in between. Re-read the progress and decide again \u2014 resending this\nrequest unchanged would land a decision made about a state nobody is in any\nmore.\n\nLabels move `unannotated`, `pre_labeled` and `annotated` on their own as\nannotations are written, edited or deleted. This route is for the decisions\nthat are nobody's consequence: skipping, submitting for review, accepting.", - "operationId": "set_asset_progress", + "/jobs/{job_id}/start": { + "post": { + "description": "Take the job from `pending` to `in_progress`.\n\nThe batch has to be open first: a job in a batch nobody started is 409\n`BATCH_NOT_IN_ANNOTATION`. A job that is not `pending` has no such move to\nmake and is 409 `INVALID_TRANSITION` \u2014 the table runs one way, so a job that\nis already in progress or finished never starts again.", + "operationId": "start_job", "parameters": [ { "in": "path", @@ -10191,34 +11225,14 @@ "title": "Job Id", "type": "string" } - }, - { - "in": "path", - "name": "asset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Asset Id", - "type": "string" - } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssetProgressSet" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetProgressOut" + "$ref": "#/components/schemas/JobOut" } } }, @@ -10290,44 +11304,97 @@ "HTTPBearer": [] } ], - "summary": "Set Asset Progress", + "summary": "Start Job", "tags": [ "jobs" ] } }, - "/jobs/{job_id}/assignee": { - "put": { - "description": "Name who is working this job, or clear it with `null`.\n\nInformational only \u2014 a name, not an account. Legal in any job or batch\nstate: naming who did a finished job is attribution, not a reopening.", - "operationId": "assign_job", - "parameters": [ + "/projects": { + "get": { + "description": "Every project in this workspace, in the order they were created.", + "operationId": "list_projects", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectPage" + } + } + }, + "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": [ { - "in": "path", - "name": "job_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Job Id", - "type": "string" - } + "HTTPBearer": [] } ], + "summary": "List Projects", + "tags": [ + "projects" + ] + }, + "post": { + "description": "Add a project and its empty dataset, both or neither.", + "operationId": "create_project", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobAssign" + "$ref": "#/components/schemas/ProjectCreate" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobOut" + "$ref": "#/components/schemas/ProjectOut" } } }, @@ -10343,7 +11410,7 @@ }, "description": "Missing or invalid bearer token" }, - "404": { + "409": { "content": { "application/json": { "schema": { @@ -10351,7 +11418,7 @@ } } }, - "description": "No such resource" + "description": "The resource's state refuses this request" }, "422": { "content": { @@ -10389,37 +11456,42 @@ "HTTPBearer": [] } ], - "summary": "Assign Job", + "summary": "Create Project", "tags": [ - "jobs" + "projects" ] } }, - "/jobs/{job_id}/complete": { - "post": { - "description": "Close the job, if every asset in it has been dealt with.\n\nDealt with means `annotated`, `skipped` or `accepted`. An `unannotated` asset\nmeans the labeling has not happened, a `pre_labeled` one means a model's\nguess is still unjudged, and a `review_pending` one means the review has\nnot; any of the three answers 409 `JOB_NOT_COMPLETE` and says how many are\noutstanding.\n\nA job that is not `in_progress` is 409 `INVALID_TRANSITION`, and a batch that\nis not open for annotation is 409 `BATCH_NOT_IN_ANNOTATION`. Neither has a\nremedy on this route: `completed` is where the table ends, so settled work is\ncorrected through a new batch rather than reopened.\n\nCompleting a job does not complete its batch \u2014 `POST /batches/{id}/complete`\nderives that from all of them.", - "operationId": "complete_job", + "/projects/{project_id}": { + "delete": { + "description": "Remove a project and everything under it.\n\nMetadata only: content blobs are shared and are never deleted. Without\n`confirm=true` this answers 409 `CONFIRMATION_REQUIRED` and destroys nothing.", + "operationId": "delete_project", "parameters": [ { "in": "path", - "name": "job_id", + "name": "project_id", "required": true, "schema": { "format": "uuid", - "title": "Job Id", + "title": "Project Id", "type": "string" } + }, + { + "description": "Required to destroy data. The kernel refuses the request without it.", + "in": "query", + "name": "confirm", + "required": false, + "schema": { + "default": false, + "description": "Required to destroy data. The kernel refuses the request without it.", + "title": "Confirm", + "type": "boolean" + } } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JobOut" - } - } - }, + "204": { "description": "Successful Response" }, "401": { @@ -10488,39 +11560,24 @@ "HTTPBearer": [] } ], - "summary": "Complete Job", + "summary": "Delete Project", "tags": [ - "jobs" + "projects" ] - } - }, - "/jobs/{job_id}/next": { + }, "get": { - "description": "The next assets waiting to be labeled, in the batch's own order.\n\nOnly `unannotated` ones: this answers the annotator's question, and an asset\nin `review_pending` is waiting on a reviewer rather than on labeling. The\norder is stored, so the same call twice returns the same assets \u2014 and marking\nan unrelated one does not reshuffle what is left.\n\nFewer than `n` come back when fewer remain, and nothing at all once the job\nis done. `total` is the size of this answer, not of the job; the job's own\ntally is at `GET /jobs/{job_id}/progress`.", - "operationId": "next_pending_assets", + "description": "The project with that id.", + "operationId": "get_project", "parameters": [ { "in": "path", - "name": "job_id", + "name": "project_id", "required": true, "schema": { "format": "uuid", - "title": "Job Id", + "title": "Project Id", "type": "string" } - }, - { - "description": "How many waiting assets to hand out. Fewer if fewer remain.", - "in": "query", - "name": "n", - "required": false, - "schema": { - "default": 1, - "description": "How many waiting assets to hand out. Fewer if fewer remain.", - "minimum": 1, - "title": "N", - "type": "integer" - } } ], "responses": { @@ -10528,7 +11585,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetPage" + "$ref": "#/components/schemas/ProjectOut" } } }, @@ -10590,24 +11647,22 @@ "HTTPBearer": [] } ], - "summary": "Next Pending Assets", + "summary": "Get Project", "tags": [ - "jobs" + "projects" ] - } - }, - "/jobs/{job_id}/pre-label": { - "post": { - "description": "Ask a model to label every untouched asset in this job, and answer at once.\n\nThe `pre_label` action. Labels land at `pre_labeled`, never at `annotated`:\nnobody judged them, so they arrive editable and correctable rather than\nclaiming to be somebody's work \u2014 and, being unjudged, they never reach the\nDataset until a person has taken them over.\n\n**Only assets nothing has touched \u2014 which is stronger than reading\n`unannotated`.** An asset already `pre_labeled`, annotated, skipped,\nawaiting review or accepted is passed over, and so is an `unannotated` one\nthat still carries annotations from an earlier round that was skipped and\nthen restored: that sequence deletes no labels, so progress alone does not\nprove an asset untouched. A run never writes over what a person did in this\njob, and never writes twice over what a model did \u2014 a plain second run\nextends an earlier one onto whatever is still untouched.\n`replace_model_labels` widens it to every frame still `pre_labeled` and\nsupersedes those labels with this run's answer, one frame per transaction;\na frame anyone edited, confirmed or skipped in this job is never touched,\nand a frame the model now finds nothing on returns to `unannotated`. A\nreplacing request arriving while a run is in flight joins that run,\nwhichever flag it carries.\n\n**The batch's pinned schema is the prompt, narrowed to what this run\nwrites.** The model is asked for each class the schema declares that admits\none of the shapes the run writes and demands no attribute a prediction\ncannot supply; an answer naming one of those classes, matched\ncase-insensitively, is written under the schema's own spelling, and an\nanswer naming none of them is discarded. A schema with no such class has\nnowhere for a prediction to land and is refused \u2014 so the same schema is\naskable of a model that answers polygons and refused for one that answers\nboxes. `GET /batches/{batch_id}/pre-label` with the same `connection_id`\n(and the same `geometries`) reads the narrowing before launching.\n\n**What the run writes is every shape the model produces, unless\n`geometries` says which.** A model declaring both a box and a polygon\nwrites both for every region it answers with \u2014 the kernel writes one\nannotation per emitted region and pairs nothing \u2014 and `geometries` filters\nthat to the shapes named: a region in any other shape is discarded and\ncounted in `regions_discarded`. The selection is per run, not per class,\nand it is kept on the queued row, so a run claimed later executes what was\nasked.\n\n**202, not 200.** A job is hundreds of forward passes, so this follows the\nlaunch-and-poll contract the export and weight-download routes use: poll `GET\n/background-jobs/{id}` \u2014 the `Location` header names it \u2014 until `state` is\n`succeeded`, then re-read the job's assets. Progress on the row is counted\nin assets, and `JobOut.pre_label_run` remembers the same row afterwards.\n\n**Everything a caller can be told now is told now**, and no refusal creates a\njob \u2014 so a caller holding a job id holds one that will run. These refusals\nare about the request, and the caller can act on each. They are checked in\nthis order, and it is the order `pre_label` itself checks in, so a request\nwrong about the connection and the job both always names the connection:\nan unknown connection is 404 `INFERENCE_CONNECTION_NOT_FOUND`; a\nconnection not set up yet is 409 `INFERENCE_CONNECTION_NOT_SET_UP` \u2014 its\nweights not here, or its endpoint not yet asked what it answers; a\nconnection whose model answers places rather than words is 422\n`UNSUPPORTED_PROMPT`; a `geometries` naming a shape the model does not\nproduce is 422 `GEOMETRY_NOT_PRODUCED`. An unknown job is 404\n`JOB_NOT_FOUND`; a job whose batch is not `in_annotation` is 409\n`BATCH_NOT_IN_ANNOTATION`; a job already `completed` is 409 `JOB_FINISHED`,\nand there is no remedy on this route \u2014 settled work is corrected through a\nnew batch rather than reopened. A pinned schema with no class the selected\nshapes can be written as is 409 `SCHEMA_HAS_NO_DETECTABLE_CLASS`.\n\nTwo failures are about this installation rather than about the request, and\nanswer 500 carrying the message that says which: a machine without the\noptional local runtime is `LOCAL_INFERENCE_UNAVAILABLE` and carries the\nexact command that installs it, and a workspace whose records no longer\nhold together \u2014 a batch pinned to a schema version that is not stored \u2014 is\n`WORKSPACE_CORRUPT`. Neither is worth resending unchanged: there is no\nstate here a caller can change, so the remedy is the one the message names.\n\n**Asking twice joins the run already in flight rather than starting a second\none.** A request arriving while this job has a pre-labeling run queued or\nrunning is answered with that run's id, so a double-click and a second tab\nwatch one run instead of paying for the same inference twice \u2014 and so does\n`POST /batches/{batch_id}/pre-label`, whose fan-out reaches this same job.", - "operationId": "pre_label_job", + }, + "patch": { + "description": "Rename a project, and its dataset with it. The only field that moves.", + "operationId": "rename_project", "parameters": [ { "in": "path", - "name": "job_id", + "name": "project_id", "required": true, "schema": { "format": "uuid", - "title": "Job Id", + "title": "Project Id", "type": "string" } } @@ -10616,18 +11671,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreLabelRequest" + "$ref": "#/components/schemas/ProjectRename" } } }, "required": true }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BackgroundJobOut" + "$ref": "#/components/schemas/ProjectOut" } } }, @@ -10699,26 +11754,58 @@ "HTTPBearer": [] } ], - "summary": "Pre Label Job", + "summary": "Rename Project", "tags": [ - "jobs" + "projects" ] } }, - "/jobs/{job_id}/progress": { + "/projects/{project_id}/assets": { "get": { - "description": "How many of this job's assets sit in each state.\n\nEvery state is a field, including the ones nobody is in, so a client charting\nprogress never has to guard a lookup.", - "operationId": "get_job_progress", + "description": "Every asset ingested into the project, in a stable order.\n\nThe third asset listing, and the one that had been missing: the other two\nwindow a *batch* and the curated *trunk*, and neither answers \"show me this\nproject\". A project page asking for six sample tiles passes `limit=6` and\nreads `total` for the rest.\n\n**The order is deterministic and it is not chronological.** Nothing records\nwhen an asset arrived, so assets are grouped by source, then by frame index\nfor a clip, then by path for a directory, then by id. The practical effect is\nthat a clip's frames come back in order and a directory's stills in filename\norder; the practical limit is that \"the six most recent\" cannot be asked for\nyet.\n\n`total` is every asset in the project, never the size of this page, so a\nclient showing six tiles computes its own overflow from `total - 6`.", + "operationId": "list_project_assets", "parameters": [ { "in": "path", - "name": "job_id", + "name": "project_id", "required": true, "schema": { "format": "uuid", - "title": "Job Id", + "title": "Project Id", "type": "string" } + }, + { + "description": "How many items to return. Everything from `offset` on by default.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "How many items to return. Everything from `offset` on by default.", + "title": "Limit" + } + }, + { + "description": "How many items to skip. Counts from the start of the collection.", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "description": "How many items to skip. Counts from the start of the collection.", + "minimum": 0, + "title": "Offset", + "type": "integer" + } } ], "responses": { @@ -10726,7 +11813,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProgressCounts" + "$ref": "#/components/schemas/AssetPage" } } }, @@ -10788,24 +11875,34 @@ "HTTPBearer": [] } ], - "summary": "Get Job Progress", + "summary": "List Project Assets", "tags": [ - "jobs" + "assets" ] } }, - "/jobs/{job_id}/start": { - "post": { - "description": "Take the job from `pending` to `in_progress`.\n\nThe batch has to be open first: a job in a batch nobody started is 409\n`BATCH_NOT_IN_ANNOTATION`. A job that is not `pending` has no such move to\nmake and is 409 `INVALID_TRANSITION` \u2014 the table runs one way, so a job that\nis already in progress or finished never starts again.", - "operationId": "start_job", + "/projects/{project_id}/assets/{asset_id}": { + "get": { + "description": "One ingested item, by id.\n\nAn unknown project is 404 `PROJECT_NOT_FOUND` and an unknown asset is 404\n`ASSET_NOT_FOUND`. An asset belonging to a different project answers the\nsecond of those rather than 403, like every cross-scope reference here.\n\n`content_hash` identifies the bytes and `thumbnail_hash` the cached preview,\nbut neither is a URL \u2014 the two routes below are, and they take this asset's\nid.", + "operationId": "get_asset", "parameters": [ { "in": "path", - "name": "job_id", + "name": "project_id", "required": true, "schema": { "format": "uuid", - "title": "Job Id", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset Id", "type": "string" } } @@ -10815,7 +11912,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/JobOut" + "$ref": "#/components/schemas/AssetOut" } } }, @@ -10841,83 +11938,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, - "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": "Start Job", - "tags": [ - "jobs" - ] - } - }, - "/projects": { - "get": { - "description": "Every project in this workspace, in the order they were created.", - "operationId": "list_projects", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectPage" - } - } - }, - "description": "Successful Response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "Missing or invalid bearer token" - }, "422": { "content": { "application/json": { @@ -10954,30 +11974,44 @@ "HTTPBearer": [] } ], - "summary": "List Projects", + "summary": "Get Asset", "tags": [ - "projects" + "assets" ] - }, - "post": { - "description": "Add a project and its empty dataset, both or neither.", - "operationId": "create_project", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectCreate" - } + } + }, + "/projects/{project_id}/assets/{asset_id}/batches": { + "get": { + "description": "Every batch that carries this asset, oldest membership first.\n\n**The membership edge walked backwards.** Every other read goes from a batch\nto its assets; this asks which rounds of work an asset has been through, and\nit is what a correction batch's lineage looks like from the asset's side \u2014\nthe original and its corrections, in the order they were cut.\n\nA dedicated route rather than a field on `AssetOut`, and the reason is cost:\na listing of fifty thousand assets would pay one join per row for a fact\nalmost no reader of that listing wants. This is asked about one asset, by\nsomebody looking at that asset.\n\nAn asset in no batch answers `{\"items\": [], \"total\": 0}` \u2014 the ordinary state\nof anything ingested without a target, and not a 404. The 404 here is for the\nasset or the project, which is resolved first: 404 `PROJECT_NOT_FOUND` or 404\n`ASSET_NOT_FOUND`. A batch deleted between that read and its progress is 404\n`BATCH_NOT_FOUND`, and asking again answers without it.", + "operationId": "list_asset_batches", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" } }, - "required": true - }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset Id", + "type": "string" + } + } + ], "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectOut" + "$ref": "#/components/schemas/BatchPage" } } }, @@ -10993,7 +12027,7 @@ }, "description": "Missing or invalid bearer token" }, - "409": { + "404": { "content": { "application/json": { "schema": { @@ -11001,7 +12035,7 @@ } } }, - "description": "The resource's state refuses this request" + "description": "No such resource" }, "422": { "content": { @@ -11039,16 +12073,16 @@ "HTTPBearer": [] } ], - "summary": "Create Project", + "summary": "List Asset Batches", "tags": [ - "projects" + "assets" ] } }, - "/projects/{project_id}": { - "delete": { - "description": "Remove a project and everything under it.\n\nMetadata only: content blobs are shared and are never deleted. Without\n`confirm=true` this answers 409 `CONFIRMATION_REQUIRED` and destroys nothing.", - "operationId": "delete_project", + "/projects/{project_id}/assets/{asset_id}/content": { + "get": { + "description": "The asset's own bytes, streamed.\n\nThe original that was ingested, not a re-encode \u2014 for a video frame that is\nthe PNG extraction wrote, which is the picture an annotator drew on and the\npicture an exporter ships.\n\n`Content-Type` comes from what the ingest actually probed. An asset written\nbefore the pipeline recorded a format is served as\n`application/octet-stream`, because inventing one would be worse than\nadmitting it.\n\nCached forever and never revalidated: identity is content, so these bytes\ncannot change. The `ETag` is the content hash.\n\nAn unknown project or asset is 404 \u2014 `PROJECT_NOT_FOUND` and\n`ASSET_NOT_FOUND` \u2014 and those are the only two. 404 `WORKSPACE_CORRUPT` is\nnot among the answers: a recorded hash with no blob behind it is a guarantee\nfailing, and is 500.", + "operationId": "get_asset_content", "parameters": [ { "in": "path", @@ -11061,21 +12095,30 @@ } }, { - "description": "Required to destroy data. The kernel refuses the request without it.", - "in": "query", - "name": "confirm", - "required": false, + "in": "path", + "name": "asset_id", + "required": true, "schema": { - "default": false, - "description": "Required to destroy data. The kernel refuses the request without it.", - "title": "Confirm", - "type": "boolean" + "format": "uuid", + "title": "Asset Id", + "type": "string" } } ], "responses": { - "204": { - "description": "Successful Response" + "200": { + "content": { + "application/octet-stream": { + "schema": {} + }, + "image/jpeg": { + "schema": {} + }, + "image/png": { + "schema": {} + } + }, + "description": "The bytes, streamed." }, "401": { "content": { @@ -11097,16 +12140,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -11143,14 +12176,16 @@ "HTTPBearer": [] } ], - "summary": "Delete Project", + "summary": "Get Asset Content", "tags": [ - "projects" + "assets" ] - }, + } + }, + "/projects/{project_id}/assets/{asset_id}/thumbnail": { "get": { - "description": "The project with that id.", - "operationId": "get_project", + "description": "The asset's cached preview, streamed. Always JPEG.\n\nA preview is a cache, so this reads one and never renders one. An asset with\nno preview is 404 `THUMBNAIL_NOT_CACHED` \u2014 which has three causes with one\nremedy: the asset predates the cache, its bytes would not render, or no run\nhas reached it yet. A backfill fills what it can. The other two 404s are the\nordinary ones, resolved before the cache is consulted: 404 `PROJECT_NOT_FOUND`\nand 404 `ASSET_NOT_FOUND`, which say the thing itself is not here rather than\nthat its preview is missing.\n\nCached the same way `content` is, and for the same reason. The `ETag` is the\nthumbnail hash, which is a cache key and not an identity: two machines may\nhold different preview bytes for one image, so never compare these across\nworkspaces.", + "operationId": "get_asset_thumbnail", "parameters": [ { "in": "path", @@ -11161,18 +12196,26 @@ "title": "Project Id", "type": "string" } + }, + { + "in": "path", + "name": "asset_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Asset Id", + "type": "string" + } } ], "responses": { "200": { "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectOut" - } + "image/jpeg": { + "schema": {} } }, - "description": "Successful Response" + "description": "The cached preview, streamed." }, "401": { "content": { @@ -11230,14 +12273,16 @@ "HTTPBearer": [] } ], - "summary": "Get Project", + "summary": "Get Asset Thumbnail", "tags": [ - "projects" + "assets" ] - }, - "patch": { - "description": "Rename a project, and its dataset with it. The only field that moves.", - "operationId": "rename_project", + } + }, + "/projects/{project_id}/batches": { + "get": { + "description": "Every batch of that project, in the order they were created.", + "operationId": "list_batches", "parameters": [ { "in": "path", @@ -11250,22 +12295,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectRename" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectOut" + "$ref": "#/components/schemas/BatchPage" } } }, @@ -11291,16 +12326,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -11337,16 +12362,14 @@ "HTTPBearer": [] } ], - "summary": "Rename Project", + "summary": "List Batches", "tags": [ - "projects" + "batches" ] - } - }, - "/projects/{project_id}/assets": { - "get": { - "description": "Every asset ingested into the project, in a stable order.\n\nThe third asset listing, and the one that had been missing: the other two\nwindow a *batch* and the curated *trunk*, and neither answers \"show me this\nproject\". A project page asking for six sample tiles passes `limit=6` and\nreads `total` for the rest.\n\n**The order is deterministic and it is not chronological.** Nothing records\nwhen an asset arrived, so assets are grouped by source, then by frame index\nfor a clip, then by path for a directory, then by id. The practical effect is\nthat a clip's frames come back in order and a directory's stills in filename\norder; the practical limit is that \"the six most recent\" cannot be asked for\nyet.\n\n`total` is every asset in the project, never the size of this page, so a\nclient showing six tiles computes its own overflow from `total - 6`.", - "operationId": "list_project_assets", + }, + "post": { + "description": "Start a draft batch over a chosen set of the project's assets.\n\n**A batch is still born from an ingest in the ordinary case**, and this does\nnot change that: an ingest run puts what it gathered into one, which is where\nalmost every batch comes from. What had no surface at all was curating one\nout of an arbitrary subset \u2014 the shape a correction batch is, and the shape\nanybody re-cutting work by hand needs.\n\nThe batch is a `draft`, so its membership stays editable and approval is what\nfreezes it and pins the schema. `asset_ids` may be empty: a batch nobody has\nfilled yet is a legitimate intermediate state, and approving one is what\n`EmptyBatch` refuses.", + "operationId": "create_batch", "parameters": [ { "in": "path", @@ -11357,46 +12380,24 @@ "title": "Project Id", "type": "string" } - }, - { - "description": "How many items to return. Everything from `offset` on by default.", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "anyOf": [ - { - "minimum": 1, - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "How many items to return. Everything from `offset` on by default.", - "title": "Limit" - } - }, - { - "description": "How many items to skip. Counts from the start of the collection.", - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "description": "How many items to skip. Counts from the start of the collection.", - "minimum": 0, - "title": "Offset", - "type": "integer" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchCreate" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetPage" + "$ref": "#/components/schemas/BatchOut" } } }, @@ -11458,16 +12459,16 @@ "HTTPBearer": [] } ], - "summary": "List Project Assets", + "summary": "Create Batch", "tags": [ - "assets" + "batches" ] } }, - "/projects/{project_id}/assets/{asset_id}": { - "get": { - "description": "One ingested item, by id.\n\nAn unknown project is 404 `PROJECT_NOT_FOUND` and an unknown asset is 404\n`ASSET_NOT_FOUND`. An asset belonging to a different project answers the\nsecond of those rather than 403, like every cross-scope reference here.\n\n`content_hash` identifies the bytes and `thumbnail_hash` the cached preview,\nbut neither is a URL \u2014 the two routes below are, and they take this asset's\nid.", - "operationId": "get_asset", + "/projects/{project_id}/batches/pre-label": { + "post": { + "description": "Ask a model to label every untouched asset across this project's open batches.\n\n**One row per open job of each selected batch, and the job is the unit.**\nThis launch fans out over the project's batches that are open for\nannotation \u2014 every one of them, or exactly the `batch_ids` named \u2014 and\nwithin each over the jobs still open, queueing for each the same\n`annotation.pre_label` row `POST /jobs/{job_id}/pre-label` queues, or\njoining the one already queued or running for that job (`joined`). A\nfinished job is passed over, so a selected batch whose every job is\ncomplete contributes no row. Each row is polled, cancelled and remembered\nper job, exactly as a single-job launch is: `GET /background-jobs/{id}`\nfor progress counted in that job's assets, `JobOut.pre_label_run`\nafterwards. Nothing here reports one total across jobs, because nothing\nhere is one run.\n\n**Refused whole, up front, and no refusal creates a row.** The connection\nis checked first, as the single-batch launch checks it: an unknown\nconnection is 404 `INFERENCE_CONNECTION_NOT_FOUND`, one not set up yet is\n409 `INFERENCE_CONNECTION_NOT_SET_UP`, a model that answers places rather\nthan words is 422 `UNSUPPORTED_PROMPT`, and a `geometries` naming a shape\nthe model does not produce is 422 `GEOMETRY_NOT_PRODUCED`. Then the\nselection: an unknown project is 404 `PROJECT_NOT_FOUND`; a named batch\noutside this project is 404 `BATCH_NOT_FOUND`; a named batch not\n`in_annotation`, a project with no open batch at all, or an empty\n`batch_ids`, is 409 `BATCH_NOT_IN_ANNOTATION`; any selected batch whose\npinned schema has no class the selected shapes can be written as is 409\n`SCHEMA_HAS_NO_DETECTABLE_CLASS`, and the message names the batch so the\ncaller can leave it out by name and ask again. A partly launched project\nwould leave rows the caller was never told about, which is why the whole\nrequest is refused instead.\n\nWhat each run writes, passes over and counts is the single-batch launch's\ncontract, `geometries` included; read `POST /batches/{batch_id}/pre-label`.", + "operationId": "pre_label_project_batches", "parameters": [ { "in": "path", @@ -11478,24 +12479,24 @@ "title": "Project Id", "type": "string" } - }, - { - "in": "path", - "name": "asset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Asset Id", - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectPreLabelRequest" + } + } + }, + "required": true + }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetOut" + "$ref": "#/components/schemas/PreLabelFanOutOut" } } }, @@ -11521,6 +12522,16 @@ }, "description": "No such resource" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { @@ -11557,16 +12568,16 @@ "HTTPBearer": [] } ], - "summary": "Get Asset", + "summary": "Pre Label Project Batches", "tags": [ - "assets" + "batches" ] } }, - "/projects/{project_id}/assets/{asset_id}/batches": { + "/projects/{project_id}/dataset": { "get": { - "description": "Every batch that carries this asset, oldest membership first.\n\n**The membership edge walked backwards.** Every other read goes from a batch\nto its assets; this asks which rounds of work an asset has been through, and\nit is what a correction batch's lineage looks like from the asset's side \u2014\nthe original and its corrections, in the order they were cut.\n\nA dedicated route rather than a field on `AssetOut`, and the reason is cost:\na listing of fifty thousand assets would pay one join per row for a fact\nalmost no reader of that listing wants. This is asked about one asset, by\nsomebody looking at that asset.\n\nAn asset in no batch answers `{\"items\": [], \"total\": 0}` \u2014 the ordinary state\nof anything ingested without a target, and not a 404. The 404 here is for the\nasset or the project, which is resolved first: 404 `PROJECT_NOT_FOUND` or 404\n`ASSET_NOT_FOUND`. A batch deleted between that read and its progress is 404\n`BATCH_NOT_FOUND`, and asking again answers without it.", - "operationId": "list_asset_batches", + "description": "The project's dataset.\n\nSingular, and there is never a second one: the dataset is created with the\nproject and its name moves with it. This is the route that turns a project id\ninto the dataset id everything under `/datasets` needs.", + "operationId": "get_project_dataset", "parameters": [ { "in": "path", @@ -11577,16 +12588,6 @@ "title": "Project Id", "type": "string" } - }, - { - "in": "path", - "name": "asset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Asset Id", - "type": "string" - } } ], "responses": { @@ -11594,7 +12595,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchPage" + "$ref": "#/components/schemas/DatasetOut" } } }, @@ -11656,16 +12657,16 @@ "HTTPBearer": [] } ], - "summary": "List Asset Batches", + "summary": "Get Project Dataset", "tags": [ - "assets" + "datasets" ] } }, - "/projects/{project_id}/assets/{asset_id}/content": { - "get": { - "description": "The asset's own bytes, streamed.\n\nThe original that was ingested, not a re-encode \u2014 for a video frame that is\nthe PNG extraction wrote, which is the picture an annotator drew on and the\npicture an exporter ships.\n\n`Content-Type` comes from what the ingest actually probed. An asset written\nbefore the pipeline recorded a format is served as\n`application/octet-stream`, because inventing one would be worse than\nadmitting it.\n\nCached forever and never revalidated: identity is content, so these bytes\ncannot change. The `ETag` is the content hash.\n\nAn unknown project or asset is 404 \u2014 `PROJECT_NOT_FOUND` and\n`ASSET_NOT_FOUND` \u2014 and those are the only two. 404 `WORKSPACE_CORRUPT` is\nnot among the answers: a recorded hash with no blob behind it is a guarantee\nfailing, and is 500.", - "operationId": "get_asset_content", + "/projects/{project_id}/preprocessing-preview": { + "post": { + "description": "Render one asset through a spec, the way an export would write it.\n\nThe same kernel path as an export, over the one asset as if it were in the\ntrain fold, so every variant the spec declares can be seen whether or not a\nrelease exists. The image is capped to 512 pixels on its longer side, with\nthe annotations scaled to match, and comes back base64-encoded beside its\n`media_type`. Never cached: the spec is the request's own.\n\nAn unknown project is 404 `PROJECT_NOT_FOUND` and an asset outside it 404\n`ASSET_NOT_FOUND`. A step that cannot transform a geometry the asset carries\nis 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`, and a step needing a source\nsize the asset never recorded, or an asset whose bytes are gone, is 409\n`EXPORT_SOURCE_UNREADABLE`. A rendered image in an encoding this server\ncannot name is 422 `UNSUPPORTED_MEDIA`, and a step kind no installed driver\napplies is 500 `PREPROCESSING_DRIVER_NOT_FOUND`.", + "operationId": "preview_preprocessing", "parameters": [ { "in": "path", @@ -11676,32 +12677,28 @@ "title": "Project Id", "type": "string" } - }, - { - "in": "path", - "name": "asset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Asset Id", - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreprocessingPreviewBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { - "application/octet-stream": { - "schema": {} - }, - "image/jpeg": { - "schema": {} - }, - "image/png": { - "schema": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreprocessingPreviewOut" + } } }, - "description": "The bytes, streamed." + "description": "Successful Response" }, "401": { "content": { @@ -11723,6 +12720,16 @@ }, "description": "No such resource" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { @@ -11759,16 +12766,16 @@ "HTTPBearer": [] } ], - "summary": "Get Asset Content", + "summary": "Preview Preprocessing", "tags": [ - "assets" + "preprocessing" ] } }, - "/projects/{project_id}/assets/{asset_id}/thumbnail": { + "/projects/{project_id}/preprocessing-recipes": { "get": { - "description": "The asset's cached preview, streamed. Always JPEG.\n\nA preview is a cache, so this reads one and never renders one. An asset with\nno preview is 404 `THUMBNAIL_NOT_CACHED` \u2014 which has three causes with one\nremedy: the asset predates the cache, its bytes would not render, or no run\nhas reached it yet. A backfill fills what it can. The other two 404s are the\nordinary ones, resolved before the cache is consulted: 404 `PROJECT_NOT_FOUND`\nand 404 `ASSET_NOT_FOUND`, which say the thing itself is not here rather than\nthat its preview is missing.\n\nCached the same way `content` is, and for the same reason. The `ETag` is the\nthumbnail hash, which is a cache key and not an identity: two machines may\nhold different preview bytes for one image, so never compare these across\nworkspaces.", - "operationId": "get_asset_thumbnail", + "description": "Every recipe of the project, oldest first. An unknown project is 404 `PROJECT_NOT_FOUND`.", + "operationId": "list_preprocessing_recipes", "parameters": [ { "in": "path", @@ -11779,26 +12786,18 @@ "title": "Project Id", "type": "string" } - }, - { - "in": "path", - "name": "asset_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Asset Id", - "type": "string" - } } ], "responses": { "200": { "content": { - "image/jpeg": { - "schema": {} + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreprocessingRecipePage" + } } }, - "description": "The cached preview, streamed." + "description": "Successful Response" }, "401": { "content": { @@ -11856,16 +12855,14 @@ "HTTPBearer": [] } ], - "summary": "Get Asset Thumbnail", + "summary": "List Preprocessing Recipes", "tags": [ - "assets" + "preprocessing" ] - } - }, - "/projects/{project_id}/batches": { - "get": { - "description": "Every batch of that project, in the order they were created.", - "operationId": "list_batches", + }, + "post": { + "description": "Store a new recipe under a name.\n\nA recipe binds at export time, by name, and the export keeps the spec by\nvalue. An unknown project is 404 `PROJECT_NOT_FOUND`; a name the project\nalready uses is 409 `PREPROCESSING_RECIPE_NAME_TAKEN`; a name that is not a\nslug is 422 `INVALID_NAME`, and a spec that breaks the recipe grammar is a\n422 `VALIDATION_ERROR` naming the rule.", + "operationId": "create_preprocessing_recipe", "parameters": [ { "in": "path", @@ -11878,12 +12875,22 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreprocessingRecipeCreate" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchPage" + "$ref": "#/components/schemas/PreprocessingRecipeOut" } } }, @@ -11909,6 +12916,16 @@ }, "description": "No such resource" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { @@ -11945,14 +12962,16 @@ "HTTPBearer": [] } ], - "summary": "List Batches", + "summary": "Create Preprocessing Recipe", "tags": [ - "batches" + "preprocessing" ] - }, - "post": { - "description": "Start a draft batch over a chosen set of the project's assets.\n\n**A batch is still born from an ingest in the ordinary case**, and this does\nnot change that: an ingest run puts what it gathered into one, which is where\nalmost every batch comes from. What had no surface at all was curating one\nout of an arbitrary subset \u2014 the shape a correction batch is, and the shape\nanybody re-cutting work by hand needs.\n\nThe batch is a `draft`, so its membership stays editable and approval is what\nfreezes it and pins the schema. `asset_ids` may be empty: a batch nobody has\nfilled yet is a legitimate intermediate state, and approving one is what\n`EmptyBatch` refuses.", - "operationId": "create_batch", + } + }, + "/projects/{project_id}/preprocessing-recipes/{name}": { + "delete": { + "description": "Remove the recipe.\n\nNo confirmation: every export that used it kept its own copy, so nothing\nthat exists is lost. An unknown project is 404 `PROJECT_NOT_FOUND` and an\nunknown name 404 `PREPROCESSING_RECIPE_NOT_FOUND`.", + "operationId": "delete_preprocessing_recipe", "parameters": [ { "in": "path", @@ -11963,27 +12982,21 @@ "title": "Project Id", "type": "string" } + }, + { + "description": "The recipe's name, unique within the project.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "The recipe's name, unique within the project.", + "title": "Name", + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchCreate" - } - } - }, - "required": true - }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchOut" - } - } - }, + "204": { "description": "Successful Response" }, "401": { @@ -12042,16 +13055,14 @@ "HTTPBearer": [] } ], - "summary": "Create Batch", + "summary": "Delete Preprocessing Recipe", "tags": [ - "batches" + "preprocessing" ] - } - }, - "/projects/{project_id}/batches/pre-label": { - "post": { - "description": "Ask a model to label every untouched asset across this project's open batches.\n\n**One row per open job of each selected batch, and the job is the unit.**\nThis launch fans out over the project's batches that are open for\nannotation \u2014 every one of them, or exactly the `batch_ids` named \u2014 and\nwithin each over the jobs still open, queueing for each the same\n`annotation.pre_label` row `POST /jobs/{job_id}/pre-label` queues, or\njoining the one already queued or running for that job (`joined`). A\nfinished job is passed over, so a selected batch whose every job is\ncomplete contributes no row. Each row is polled, cancelled and remembered\nper job, exactly as a single-job launch is: `GET /background-jobs/{id}`\nfor progress counted in that job's assets, `JobOut.pre_label_run`\nafterwards. Nothing here reports one total across jobs, because nothing\nhere is one run.\n\n**Refused whole, up front, and no refusal creates a row.** The connection\nis checked first, as the single-batch launch checks it: an unknown\nconnection is 404 `INFERENCE_CONNECTION_NOT_FOUND`, one not set up yet is\n409 `INFERENCE_CONNECTION_NOT_SET_UP`, a model that answers places rather\nthan words is 422 `UNSUPPORTED_PROMPT`, and a `geometries` naming a shape\nthe model does not produce is 422 `GEOMETRY_NOT_PRODUCED`. Then the\nselection: an unknown project is 404 `PROJECT_NOT_FOUND`; a named batch\noutside this project is 404 `BATCH_NOT_FOUND`; a named batch not\n`in_annotation`, a project with no open batch at all, or an empty\n`batch_ids`, is 409 `BATCH_NOT_IN_ANNOTATION`; any selected batch whose\npinned schema has no class the selected shapes can be written as is 409\n`SCHEMA_HAS_NO_DETECTABLE_CLASS`, and the message names the batch so the\ncaller can leave it out by name and ask again. A partly launched project\nwould leave rows the caller was never told about, which is why the whole\nrequest is refused instead.\n\nWhat each run writes, passes over and counts is the single-batch launch's\ncontract, `geometries` included; read `POST /batches/{batch_id}/pre-label`.", - "operationId": "pre_label_project_batches", + }, + "get": { + "description": "The recipe under that name.\n\nAn unknown project is 404 `PROJECT_NOT_FOUND` and an unknown name 404\n`PREPROCESSING_RECIPE_NOT_FOUND`.", + "operationId": "get_preprocessing_recipe", "parameters": [ { "in": "path", @@ -12062,24 +13073,25 @@ "title": "Project Id", "type": "string" } + }, + { + "description": "The recipe's name, unique within the project.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "The recipe's name, unique within the project.", + "title": "Name", + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectPreLabelRequest" - } - } - }, - "required": true - }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PreLabelFanOutOut" + "$ref": "#/components/schemas/PreprocessingRecipeOut" } } }, @@ -12105,16 +13117,6 @@ }, "description": "No such resource" }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorBody" - } - } - }, - "description": "The resource's state refuses this request" - }, "422": { "content": { "application/json": { @@ -12151,16 +13153,14 @@ "HTTPBearer": [] } ], - "summary": "Pre Label Project Batches", + "summary": "Get Preprocessing Recipe", "tags": [ - "batches" + "preprocessing" ] - } - }, - "/projects/{project_id}/dataset": { - "get": { - "description": "The project's dataset.\n\nSingular, and there is never a second one: the dataset is created with the\nproject and its name moves with it. This is the route that turns a project id\ninto the dataset id everything under `/datasets` needs.", - "operationId": "get_project_dataset", + }, + "put": { + "description": "Replace the recipe whole, and rename it when the body's `name` differs.\n\nWhole-value: the spec is one value with cross-field rules, so there is no\nfield-at-a-time edit. Nothing downstream depends on the stored value \u2014 an\nexport keeps its own copy \u2014 so no revision is asked for. An unknown project\nis 404 `PROJECT_NOT_FOUND`, an unknown name 404\n`PREPROCESSING_RECIPE_NOT_FOUND`; a rename onto a name the project already\nuses is 409 `PREPROCESSING_RECIPE_NAME_TAKEN`, and a new name that is not\na slug is 422 `INVALID_NAME`.", + "operationId": "update_preprocessing_recipe", "parameters": [ { "in": "path", @@ -12171,14 +13171,35 @@ "title": "Project Id", "type": "string" } + }, + { + "description": "The recipe's name, unique within the project.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "The recipe's name, unique within the project.", + "title": "Name", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreprocessingRecipeUpdate" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DatasetOut" + "$ref": "#/components/schemas/PreprocessingRecipeOut" } } }, @@ -12204,6 +13225,16 @@ }, "description": "No such resource" }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, "422": { "content": { "application/json": { @@ -12240,9 +13271,9 @@ "HTTPBearer": [] } ], - "summary": "Get Project Dataset", + "summary": "Update Preprocessing Recipe", "tags": [ - "datasets" + "preprocessing" ] } }, diff --git a/src/visionset/kernel/domain/__init__.py b/src/visionset/kernel/domain/__init__.py index df4e8363..ae4bbd75 100644 --- a/src/visionset/kernel/domain/__init__.py +++ b/src/visionset/kernel/domain/__init__.py @@ -192,6 +192,7 @@ VARIANT_ID_NAMESPACE, Fold, LetterboxFit, + PreprocessingPreview, TransformedAnnotation, TransformedFile, TransformedView, @@ -430,6 +431,7 @@ "AugmentStep", "Fold", "LetterboxFit", + "PreprocessingPreview", "PreprocessingRecipe", "RecipeSpec", "ResizeStep", diff --git a/src/visionset/kernel/domain/preprocessing_transform.py b/src/visionset/kernel/domain/preprocessing_transform.py index ee26bc61..f134335d 100644 --- a/src/visionset/kernel/domain/preprocessing_transform.py +++ b/src/visionset/kernel/domain/preprocessing_transform.py @@ -167,6 +167,25 @@ def augmented_file_count(self) -> int: return sum(1 for file in self.files if file.variant > 0) +class PreprocessingPreview(BaseModel): + """One asset through a recipe, rendered for a person to look at. + + ``image`` is the transformed bytes — the same bytes an export would write + for that variant, capped to a preview size — and ``annotations`` are placed + on them. ``media_type`` says what the bytes are encoded as. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + asset_id: UUID + variant: int = Field(ge=0) + width: int | None + height: int | None + annotations: tuple[TransformedAnnotation, ...] = () + image: bytes + media_type: str + + def transform_manifest( manifest: Manifest, spec: RecipeSpec, folds: SplitAssignment | None ) -> TransformedView: diff --git a/src/visionset/kernel/services/preprocessing_recipe_service.py b/src/visionset/kernel/services/preprocessing_recipe_service.py index cca9706b..9172e40c 100644 --- a/src/visionset/kernel/services/preprocessing_recipe_service.py +++ b/src/visionset/kernel/services/preprocessing_recipe_service.py @@ -21,27 +21,58 @@ from __future__ import annotations import re +from collections.abc import Mapping from datetime import UTC, datetime from typing import Final from uuid import UUID -from visionset.kernel.domain import PreprocessingRecipe, Project, RecipeSpec, normalize_name +from visionset.kernel.domain import ( + Annotation, + Manifest, + ManifestAnnotation, + ManifestAsset, + PreprocessingPreview, + PreprocessingRecipe, + Project, + RecipeSpec, + ResizeStep, + ResizeStrategy, + SplitAssignment, + fit_within, + normalize_name, + transform_manifest, +) from visionset.kernel.errors import ( + AssetNotFound, ConstraintViolated, + ExportSourceUnreadable, InvalidName, PreprocessingRecipeNameTaken, PreprocessingRecipeNotFound, ProjectNotFound, + UnsupportedMedia, ) -from visionset.kernel.ports import UnitOfWork +from visionset.kernel.ports import PreprocessingDriver, UnitOfWork, driver_for from visionset.kernel.services.dataset_service import DatasetService -from visionset.kernel.services.release_service import ReleaseService +from visionset.kernel.services.release_service import ReleaseService, transformed_bytes from visionset.kernel.services.workspace_service import WorkspaceService #: What a recipe name may look like: a slug, because it travels as a path #: segment and a command-line argument and is compared exactly. _SLUG: Final = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +#: The longest edge a preview is rendered at. A preview is for looking, and a +#: 4K frame pushed through base64 for a thumbnail-sized cell is bandwidth +#: spent on pixels nobody sees. +PREVIEW_MAX_EDGE: Final = 512 + +#: What the first bytes of a rendered preview say it is, for ``media_type``. +_MEDIA_TYPES: Final[tuple[tuple[bytes, str], ...]] = ( + (b"\xff\xd8\xff", "image/jpeg"), + (b"\x89PNG\r\n\x1a\n", "image/png"), + (b"RIFF", "image/webp"), +) + #: How SQLite words the name index's refusal — matched exactly, the #: ``ReleaseService`` precedent, so another constraint is never mistaken for it. _NAME_INDEX_MESSAGE: Final = "preprocessing_recipes.project_id, preprocessing_recipes.name" @@ -162,6 +193,84 @@ def delete(self, project_id: UUID, name: str) -> PreprocessingRecipe: uow.preprocessing_recipes.delete(stored.id) return stored + def preview( + self, + project_id: UUID, + spec: RecipeSpec, + asset_id: UUID, + *, + variant: int, + drivers: Mapping[str, PreprocessingDriver], + max_edge: int = PREVIEW_MAX_EDGE, + ) -> PreprocessingPreview: + """One asset through ``spec``, as the export would write it, sized for a screen. + + The same kernel path as an export — ``transform_manifest`` for the + geometry, ``transformed_bytes`` for the pixels — over a one-asset + manifest with the asset in the train fold, so every variant the spec + declares can be looked at whether or not any release exists. The + result is then capped to ``max_edge`` on its longer side, labels and + pixels scaled together, which is the one step an export does not take. + + ``variant`` 0 is the base image; ``1..variants_per_asset`` are the + augmented outputs. Asking for a variant the spec does not make is a + caller's error and is refused by the surface before it reaches here. + + Raises: + ProjectNotFound: no such project in this workspace. + AssetNotFound: the asset is not in this project. + PreprocessingStepUnsupportedGeometry: a step cannot transform a + geometry the asset carries. + ExportSourceUnreadable: a step needs a source size the asset never + recorded, or the asset's bytes are not in the blob store. + PreprocessingDriverNotFound: no driver in ``drivers`` applies a step. + UnsupportedMedia: the rendered bytes are not a JPEG, PNG or WebP. + """ + with self._workspace.unit_of_work() as uow: + self._require_project(uow, project_id) + asset = uow.assets.get(asset_id) + if asset is None or asset.project_id != project_id: + raise AssetNotFound(f"no asset {asset_id} in project {project_id}") + manifest_asset = ManifestAsset( + asset_id=asset.id, + content_hash=asset.content_hash, + uri=asset.uri, + width=asset.width, + height=asset.height, + annotations=tuple( + _manifest_annotation(one) for one in uow.annotations.list(asset.id) + ), + ) + manifest = Manifest(schema_version=1, assets=(manifest_asset,)) + view = transform_manifest(manifest, spec, SplitAssignment(train=(asset.id,))) + file = next(one for one in view.files if one.variant == variant) + try: + with self._workspace.blob_store.get(asset.content_hash) as stream: + source = stream.read() + except FileNotFoundError as exc: + raise ExportSourceUnreadable( + f"asset {asset.id} ({asset.content_hash}) is not in the blob store" + ) from exc + image = transformed_bytes( + spec, drivers, source, content_hash=asset.content_hash, variant=variant + ) + fitted = fit_within(file, max_edge) + if (fitted.width, fitted.height) != (file.width, file.height): + assert fitted.width is not None and fitted.height is not None + cap = ResizeStep( + strategy=ResizeStrategy.STRETCH, width=fitted.width, height=fitted.height + ) + image = driver_for(drivers, cap.kind).apply(cap, image, seed=b"", variant=variant) + return PreprocessingPreview( + asset_id=asset.id, + variant=variant, + width=fitted.width, + height=fitted.height, + annotations=fitted.annotations, + image=image, + media_type=_media_type(image), + ) + def list(self, project_id: UUID) -> list[PreprocessingRecipe]: """Every recipe of the project, oldest first. @@ -219,3 +328,23 @@ def _as_name_collision( f"another writer created a recipe named {name!r} first; choose another name" ) return exc + + +def _manifest_annotation(annotation: Annotation) -> ManifestAnnotation: + return ManifestAnnotation( + id=annotation.id, + label_class=annotation.label_class, + schema_version=annotation.schema_version, + geometry=annotation.geometry, + attributes=dict(annotation.attributes), + provenance=annotation.provenance, + model_ref=annotation.model_ref, + confidence=annotation.confidence, + ) + + +def _media_type(image: bytes) -> str: + for signature, media_type in _MEDIA_TYPES: + if image.startswith(signature): + return media_type + raise UnsupportedMedia("the rendered preview is not a JPEG, PNG or WebP") diff --git a/src/visionset/server/dependencies.py b/src/visionset/server/dependencies.py index 5b4e93fa..d49b0aae 100644 --- a/src/visionset/server/dependencies.py +++ b/src/visionset/server/dependencies.py @@ -33,7 +33,7 @@ from visionset.inference.registry import registered from visionset.jobs import JobRunner from visionset.kernel.errors import VisionSetError -from visionset.kernel.ports import AuthProvider, Exporter, Provider +from visionset.kernel.ports import AuthProvider, Exporter, PreprocessingDriver, Provider from visionset.kernel.services import ( WORKSPACE_ENV_VAR as WORKSPACE_ENV_VAR, ) @@ -43,6 +43,7 @@ from visionset.kernel.services import ( resolve_workspace_root as resolve_workspace_root, ) +from visionset.preprocessing.registry import drivers from visionset.server import session from visionset.server.errors import ERROR_RESPONSES, code_for from visionset.server.settings import job_settings @@ -273,6 +274,16 @@ def get_exporters() -> dict[str, Exporter]: return exporters() +def get_drivers() -> Mapping[str, PreprocessingDriver]: + """Every installed pre-processing driver, keyed by the step kind it applies. + + :func:`get_exporters`'s reason, one port over: the kernel takes driver + instances and may not scan the entry-point group itself, and a dependency + is what lets a test substitute a driver. + """ + return drivers() + + def get_providers() -> Mapping[str, Provider]: """Every inference driver installed alongside this server, by provider id. @@ -367,6 +378,8 @@ def require_token( """The dispatcher, for a route that launches work rather than doing it.""" ExportersDep = Annotated[dict[str, Exporter], Depends(get_exporters)] + +DriversDep = Annotated[Mapping[str, PreprocessingDriver], Depends(get_drivers)] """The installed export formats, for a route that lists or runs one.""" ProvidersDep = Annotated[Mapping[str, Provider], Depends(get_providers)] diff --git a/src/visionset/server/models.py b/src/visionset/server/models.py index f16b7d06..2045dda7 100644 --- a/src/visionset/server/models.py +++ b/src/visionset/server/models.py @@ -37,6 +37,7 @@ from __future__ import annotations +import base64 from collections.abc import Iterable from collections.abc import Set as AbstractSet from datetime import datetime @@ -71,6 +72,8 @@ AttentionItem, AttentionKind, Attribute, + AugmentOp, + AugmentStep, BackgroundJob, BackgroundJobState, Batch, @@ -118,12 +121,17 @@ Precision, PreLabelRun, PreprocessingHints, + PreprocessingPreview, + PreprocessingRecipe, Project, ProjectPreview, ProjectStats, ProjectSummary, + RecipeSpec, Release, ReleaseVerification, + ResizeStep, + ResizeStrategy, ResumeKind, ResumeTarget, SchemaChange, @@ -137,8 +145,10 @@ SourceKind, SplitAssignment, SplitRecipe, + Step, SuggestParameter, Task, + TransformedAnnotation, VideoProvenance, WeightDownload, WorkspaceSummary, @@ -2277,6 +2287,241 @@ class ExportTargetPage(Page[ExportTargetOut]): """A page of export targets.""" +# --- pre-processing recipes --------------------------------------------------- + + +class ResizeStepBody(BaseModel): + """Bring every exported image to one size, by one strategy. + + `pad_value` is the grey a letterbox pads with and means nothing to + `stretch`. Sizes are 32 to 8192 pixels a side. + """ + + model_config = ConfigDict(extra="forbid") + + kind: Literal["resize"] = "resize" + strategy: ResizeStrategy + width: int + height: int + pad_value: int = 114 + + @classmethod + def of(cls, step: ResizeStep) -> Self: + return cls( + strategy=step.strategy, width=step.width, height=step.height, pad_value=step.pad_value + ) + + def to_domain(self) -> ResizeStep: + return ResizeStep( + strategy=self.strategy, width=self.width, height=self.height, pad_value=self.pad_value + ) + + +class AugmentStepBody(BaseModel): + """One augmentation applied when generating variants. + + `amount` bounds the brightness and contrast factors, drawn uniformly from + `[1 - amount, 1 + amount]`, and means nothing to `hflip` or `rot90`. + """ + + model_config = ConfigDict(extra="forbid") + + kind: Literal["augment"] = "augment" + op: AugmentOp + amount: float = 0.2 + + @classmethod + def of(cls, step: AugmentStep) -> Self: + return cls(op=step.op, amount=step.amount) + + def to_domain(self) -> AugmentStep: + return AugmentStep(op=self.op, amount=self.amount) + + +StepBody = Annotated[ResizeStepBody | AugmentStepBody, Field(discriminator="kind")] + + +def step_of(step: Step) -> ResizeStepBody | AugmentStepBody: + if isinstance(step, ResizeStep): + return ResizeStepBody.of(step) + return AugmentStepBody.of(step) + + +class RecipeSpecBody(BaseModel): + """What a recipe does to every exported image, and how many variants it makes. + + At most one `resize` step, and it comes first. `variants_per_asset` counts + augmented outputs — 0 to 8 — and requires at least one `augment` step, + which in turn requires at least one variant; each augmentation appears at + most once. `target` records which export target's hints the recipe was + written from and is informational: a recipe applies to any export. + """ + + model_config = ConfigDict(extra="forbid") + + target: str | None = None + steps: list[StepBody] = [] + variants_per_asset: int = 0 + + @model_validator(mode="after") + def _the_domain_accepts_it(self) -> Self: + self.to_domain() + return self + + @classmethod + def of(cls, spec: RecipeSpec) -> Self: + return cls( + target=spec.target, + steps=[step_of(step) for step in spec.steps], + variants_per_asset=spec.variants_per_asset, + ) + + def to_domain(self) -> RecipeSpec: + return RecipeSpec( + target=self.target, + steps=tuple(step.to_domain() for step in self.steps), + variants_per_asset=self.variants_per_asset, + ) + + +class PreprocessingRecipeCreate(BaseModel): + """A new recipe: its name and what it does. + + `name` is a slug — lowercase letters, digits, dots, hyphens and + underscores, starting with a letter or digit, at most 64 characters — + unique within the project. + """ + + model_config = ConfigDict(extra="forbid") + + name: str + spec: RecipeSpecBody + + +class PreprocessingRecipeUpdate(BaseModel): + """The whole recipe, replaced. `name` renames it when it differs from the path.""" + + model_config = ConfigDict(extra="forbid") + + name: str + spec: RecipeSpecBody + + +class PreprocessingRecipeOut(BaseModel): + """A named pre-processing recipe of a project. + + A recipe binds at export time — `POST /releases/{release_id}/export?recipe=` + takes `name` — and the export keeps the spec by value, so editing or + deleting a recipe never alters a past export. There is no state and no + `allowed_actions`: every operation is always offered. + """ + + id: UUID + project_id: UUID + name: str + spec: RecipeSpecBody + created_at: datetime + updated_at: datetime + + @classmethod + def of(cls, recipe: PreprocessingRecipe) -> Self: + return cls( + id=recipe.id, + project_id=recipe.project_id, + name=recipe.name, + spec=RecipeSpecBody.of(recipe.spec), + created_at=recipe.created_at, + updated_at=recipe.updated_at, + ) + + +class PreprocessingRecipePage(Page[PreprocessingRecipeOut]): + """A page of pre-processing recipes.""" + + +class PreprocessingPreviewBody(BaseModel): + """One asset to render through a spec, and which variant of it. + + `variant` 0 is the base image; `1` to `spec.variants_per_asset` are the + augmented outputs. A variant the spec does not make is refused. + """ + + model_config = ConfigDict(extra="forbid") + + spec: RecipeSpecBody + asset_id: UUID + variant: int = 0 + + @model_validator(mode="after") + def _the_variant_exists(self) -> Self: + if self.variant < 0 or self.variant > self.spec.variants_per_asset: + raise ValueError( + f"variant {self.variant} is not one this spec makes; it makes 0 to " + f"{self.spec.variants_per_asset}" + ) + return self + + +class PreviewAnnotationOut(BaseModel): + """One label as the export would write it, placed on the transformed image. + + `id` is the source annotation's id for the base image and `"{id}-aug{k}"` + for variant `k`, so a rendered label traces to the label it came from. + """ + + id: str + label_class: str + schema_version: int + geometry: GeometryBody + attributes: dict[str, bool | float | str] + provenance: Literal["human", "model", "import"] + model_ref: str | None + confidence: float | None + + @classmethod + def of(cls, annotation: TransformedAnnotation) -> Self: + return cls( + id=annotation.id, + label_class=annotation.label_class, + schema_version=annotation.schema_version, + geometry=geometry_of(annotation.geometry), + attributes=dict(annotation.attributes), + provenance=annotation.provenance, + model_ref=annotation.model_ref, + confidence=annotation.confidence, + ) + + +class PreprocessingPreviewOut(BaseModel): + """One asset through a recipe, rendered for a screen. + + `image_base64` is the transformed image, encoded as `media_type`, capped to + 512 pixels on its longer side with `annotations` scaled to match. `width` + and `height` are the rendered size; they are null only when the asset never + recorded a size and no resize step decided one. + """ + + asset_id: UUID + variant: int + width: int | None + height: int | None + annotations: list[PreviewAnnotationOut] + image_base64: str + media_type: str + + @classmethod + def of(cls, preview: PreprocessingPreview) -> Self: + return cls( + asset_id=preview.asset_id, + variant=preview.variant, + width=preview.width, + height=preview.height, + annotations=[PreviewAnnotationOut.of(one) for one in preview.annotations], + image_base64=base64.b64encode(preview.image).decode("ascii"), + media_type=preview.media_type, + ) + + # --- inference providers ------------------------------------------------------ diff --git a/src/visionset/server/routes/__init__.py b/src/visionset/server/routes/__init__.py index df9cb6ec..d878cbd4 100644 --- a/src/visionset/server/routes/__init__.py +++ b/src/visionset/server/routes/__init__.py @@ -33,6 +33,7 @@ inference, ingest, jobs, + preprocessing_recipes, projects, providers, releases, @@ -65,6 +66,8 @@ releases.router, formats.router, formats.targets_router, + preprocessing_recipes.router, + preprocessing_recipes.preview_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/preprocessing_recipes.py b/src/visionset/server/routes/preprocessing_recipes.py new file mode 100644 index 00000000..8d2c4a05 --- /dev/null +++ b/src/visionset/server/routes/preprocessing_recipes.py @@ -0,0 +1,147 @@ +# usage: from visionset.server.routes import preprocessing_recipes +"""Pre-processing recipes: a project's named resize-and-augmentation specs, and a preview. + +Two routers, the ``sources.py`` split: the recipes hang off the project and are +addressed by name, and the preview is a project operation that takes a spec +rather than a stored recipe — a person tuning a recipe wants to see it before +saving it, and a preview of a saved one is the same call with the saved spec. + +Nothing here is state-gated. A recipe has no lifecycle and nothing depends on +the stored value — an export keeps its own copy — so there is no +``allowed_actions`` vocabulary to publish and every operation is always offered. + +Handlers are ``def``, not ``async def``, for the reason ``projects.py`` gives. +""" + +from __future__ import annotations + +from typing import Annotated, Final +from uuid import UUID + +from fastapi import Path, Response, status + +from visionset.kernel.services import PreprocessingRecipeService +from visionset.server.dependencies import DriversDep, WorkspaceDep, protected_router +from visionset.server.errors import documented +from visionset.server.models import ( + PreprocessingPreviewBody, + PreprocessingPreviewOut, + PreprocessingRecipeCreate, + PreprocessingRecipeOut, + PreprocessingRecipePage, + PreprocessingRecipeUpdate, +) + +router = protected_router( + prefix="/projects/{project_id}/preprocessing-recipes", tags=["preprocessing"] +) +preview_router = protected_router(prefix="/projects/{project_id}", tags=["preprocessing"]) + +NamePath = Annotated[str, Path(description="The recipe's name, unique within the project.")] + +#: A preview is derived from a spec that is still being edited; a cached +#: answer would show yesterday's recipe under today's name. +_NO_STORE: Final = "no-store" + + +@router.post("", status_code=status.HTTP_201_CREATED, responses=documented(404, 409)) +def create_preprocessing_recipe( + workspace: WorkspaceDep, project_id: UUID, body: PreprocessingRecipeCreate +) -> PreprocessingRecipeOut: + """Store a new recipe under a name. + + A recipe binds at export time, by name, and the export keeps the spec by + value. An unknown project is 404 `PROJECT_NOT_FOUND`; a name the project + already uses is 409 `PREPROCESSING_RECIPE_NAME_TAKEN`; a name that is not a + slug is 422 `INVALID_NAME`, and a spec that breaks the recipe grammar is a + 422 `VALIDATION_ERROR` naming the rule. + """ + created = PreprocessingRecipeService(workspace).create( + project_id, body.name, body.spec.to_domain() + ) + return PreprocessingRecipeOut.of(created) + + +@router.get("", responses=documented(404)) +def list_preprocessing_recipes( + workspace: WorkspaceDep, project_id: UUID +) -> PreprocessingRecipePage: + """Every recipe of the project, oldest first. An unknown project is 404 `PROJECT_NOT_FOUND`.""" + found = PreprocessingRecipeService(workspace).list(project_id) + return PreprocessingRecipePage( + items=[PreprocessingRecipeOut.of(recipe) for recipe in found], total=len(found) + ) + + +@router.get("/{name}", responses=documented(404)) +def get_preprocessing_recipe( + workspace: WorkspaceDep, project_id: UUID, name: NamePath +) -> PreprocessingRecipeOut: + """The recipe under that name. + + An unknown project is 404 `PROJECT_NOT_FOUND` and an unknown name 404 + `PREPROCESSING_RECIPE_NOT_FOUND`. + """ + return PreprocessingRecipeOut.of(PreprocessingRecipeService(workspace).get(project_id, name)) + + +@router.put("/{name}", responses=documented(404, 409)) +def update_preprocessing_recipe( + workspace: WorkspaceDep, project_id: UUID, name: NamePath, body: PreprocessingRecipeUpdate +) -> PreprocessingRecipeOut: + """Replace the recipe whole, and rename it when the body's `name` differs. + + Whole-value: the spec is one value with cross-field rules, so there is no + field-at-a-time edit. Nothing downstream depends on the stored value — an + export keeps its own copy — so no revision is asked for. An unknown project + is 404 `PROJECT_NOT_FOUND`, an unknown name 404 + `PREPROCESSING_RECIPE_NOT_FOUND`; a rename onto a name the project already + uses is 409 `PREPROCESSING_RECIPE_NAME_TAKEN`, and a new name that is not + a slug is 422 `INVALID_NAME`. + """ + updated = PreprocessingRecipeService(workspace).update( + project_id, name, spec=body.spec.to_domain(), new_name=body.name + ) + return PreprocessingRecipeOut.of(updated) + + +@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, responses=documented(404)) +def delete_preprocessing_recipe(workspace: WorkspaceDep, project_id: UUID, name: NamePath) -> None: + """Remove the recipe. + + No confirmation: every export that used it kept its own copy, so nothing + that exists is lost. An unknown project is 404 `PROJECT_NOT_FOUND` and an + unknown name 404 `PREPROCESSING_RECIPE_NOT_FOUND`. + """ + PreprocessingRecipeService(workspace).delete(project_id, name) + + +@preview_router.post("/preprocessing-preview", responses=documented(404, 409)) +def preview_preprocessing( + workspace: WorkspaceDep, + drivers: DriversDep, + response: Response, + project_id: UUID, + body: PreprocessingPreviewBody, +) -> PreprocessingPreviewOut: + """Render one asset through a spec, the way an export would write it. + + The same kernel path as an export, over the one asset as if it were in the + train fold, so every variant the spec declares can be seen whether or not a + release exists. The image is capped to 512 pixels on its longer side, with + the annotations scaled to match, and comes back base64-encoded beside its + `media_type`. Never cached: the spec is the request's own. + + An unknown project is 404 `PROJECT_NOT_FOUND` and an asset outside it 404 + `ASSET_NOT_FOUND`. A step that cannot transform a geometry the asset carries + is 409 `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`, and a step needing a source + size the asset never recorded, or an asset whose bytes are gone, is 409 + `EXPORT_SOURCE_UNREADABLE`. A rendered image in an encoding this server + cannot name is 422 `UNSUPPORTED_MEDIA`, and a step kind no installed driver + applies is 500 `PREPROCESSING_DRIVER_NOT_FOUND`. + """ + preview = PreprocessingRecipeService(workspace).preview( + project_id, body.spec.to_domain(), body.asset_id, variant=body.variant, drivers=drivers + ) + response.headers["Cache-Control"] = _NO_STORE + return PreprocessingPreviewOut.of(preview) diff --git a/src/visionset/wire/__init__.py b/src/visionset/wire/__init__.py index 94c99ef3..ee885f15 100644 --- a/src/visionset/wire/__init__.py +++ b/src/visionset/wire/__init__.py @@ -46,6 +46,7 @@ from __future__ import annotations +import base64 from collections.abc import Mapping, Sequence from collections.abc import Set as AbstractSet from datetime import UTC, datetime @@ -96,6 +97,8 @@ PolylineGeometry, PreLabelRun, PreprocessingHints, + PreprocessingPreview, + PreprocessingRecipe, Project, ProjectPreview, RecipeSpec, @@ -111,6 +114,7 @@ SplitRecipe, Step, ThumbnailBackfill, + TransformedAnnotation, VideoProvenance, WeightDownload, asset_actions, @@ -812,6 +816,45 @@ def recipe_spec(value: RecipeSpec) -> dict[str, Any]: } +def preprocessing_recipe(value: PreprocessingRecipe) -> dict[str, Any]: + """A named recipe of a project. Binds at export by ``name``; the export keeps the spec.""" + return { + "id": str(value.id), + "project_id": str(value.project_id), + "name": value.name, + "spec": recipe_spec(value.spec), + "created_at": _moment(value.created_at), + "updated_at": _moment(value.updated_at), + } + + +def transformed_annotation(value: TransformedAnnotation) -> dict[str, Any]: + """One label as an export writes it. ``id`` is a string: a variant's carries ``-aug{k}``.""" + return { + "id": value.id, + "label_class": value.label_class, + "schema_version": value.schema_version, + "geometry": geometry(value.geometry), + "attributes": dict(value.attributes), + "provenance": value.provenance, + "model_ref": value.model_ref, + "confidence": value.confidence, + } + + +def preprocessing_preview(value: PreprocessingPreview) -> dict[str, Any]: + """One asset through a recipe, rendered. **Surface-defined**: only the API answers it.""" + return { + "asset_id": str(value.asset_id), + "variant": value.variant, + "width": value.width, + "height": value.height, + "annotations": [transformed_annotation(one) for one in value.annotations], + "image_base64": base64.b64encode(value.image).decode("ascii"), + "media_type": value.media_type, + } + + def export_file_mapping(value: ExportFileMapping) -> dict[str, Any]: """One written image traced to the manifest asset it came from.""" return { diff --git a/tests/cli/test_json_contract.py b/tests/cli/test_json_contract.py index 4b881ae4..81b90c8f 100644 --- a/tests/cli/test_json_contract.py +++ b/tests/cli/test_json_contract.py @@ -39,12 +39,15 @@ DATASET_STATS, EXPORT_COMPATIBILITY, EXPORT_RESULT, + EXPORT_RESULT_WITH_RECIPE, INFERENCE_CONNECTION, INGEST_FAILURE, INGEST_JOB, JOB, PARTIAL_EXTRACTION, POLYGON, + PREPROCESSING_PREVIEW, + PREPROCESSING_RECIPE, PROJECT, PROJECT_PREVIEW, RELEASE, @@ -122,6 +125,16 @@ models.AttributeBody, ), ("schema_draft", wire.schema_draft(SCHEMA_DRAFT), models.SchemaDraftOut), + ( + "preprocessing_recipe", + wire.preprocessing_recipe(PREPROCESSING_RECIPE), + models.PreprocessingRecipeOut, + ), + ( + "preprocessing_preview", + wire.preprocessing_preview(PREPROCESSING_PREVIEW), + models.PreprocessingPreviewOut, + ), ( "draft_label_class", wire.draft_label_class(SCHEMA_DRAFT.classes[0]), @@ -299,10 +312,11 @@ def test_a_timestamp_keeps_its_microseconds_and_ends_in_z() -> None: "payload", [ wire.export_result(EXPORT_RESULT), + wire.export_result(EXPORT_RESULT_WITH_RECIPE), wire.thumbnail_backfill(THUMBNAIL_BACKFILL), wire.schema_diff(SCHEMA_DIFF), ], - ids=["export_result", "thumbnail_backfill", "schema_diff"], + ids=["export_result", "export_result_with_recipe", "thumbnail_backfill", "schema_diff"], ) def test_a_surface_defined_shape_still_serializes(payload: dict[str, Any]) -> None: # No route publishes any of the three, so there is nothing to be parity-gated diff --git a/tests/fixtures/samples.py b/tests/fixtures/samples.py index 3de672a9..00410831 100644 --- a/tests/fixtures/samples.py +++ b/tests/fixtures/samples.py @@ -25,6 +25,8 @@ Asset, AssetProgress, Attribute, + AugmentOp, + AugmentStep, Batch, BatchState, BboxGeometry, @@ -40,6 +42,8 @@ DraftAttribute, DraftLabelClass, ExportCompatibility, + ExportFileMapping, + ExportPreprocessing, ExportResult, GeometryType, ImageFormat, @@ -52,10 +56,15 @@ OrphanGuard, PolygonGeometry, PolylineGeometry, + PreprocessingPreview, + PreprocessingRecipe, Project, ProjectPreview, + RecipeSpec, Release, ReleaseVerification, + ResizeStep, + ResizeStrategy, SchemaChange, SchemaChangePreview, SchemaDiff, @@ -67,6 +76,7 @@ SplitRecipe, ThumbnailBackfill, TimeRange, + TransformedAnnotation, VideoMetadata, VideoProvenance, ) @@ -403,3 +413,74 @@ endpoint_url="https://example.invalid/predict", setup_state=ConnectionSetupState.READY, ) + + +PREPROCESSING_SPEC = RecipeSpec( + target="yolo11", + steps=( + ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=640, height=640, pad_value=114), + AugmentStep(op=AugmentOp.HFLIP), + AugmentStep(op=AugmentOp.BRIGHTNESS_CONTRAST, amount=0.3), + ), + variants_per_asset=2, +) + +PREPROCESSING_RECIPE = PreprocessingRecipe( + project_id=PROJECT.id, + name="yolo-640", + spec=PREPROCESSING_SPEC, + created_at=_WHEN, + updated_at=_WHEN, +) + +PREPROCESSING_PREVIEW = PreprocessingPreview( + asset_id=ASSET.id, + variant=1, + width=512, + height=512, + annotations=( + TransformedAnnotation( + id=f"{ANNOTATION.id}-aug1", + label_class=ANNOTATION.label_class, + schema_version=ANNOTATION.schema_version, + geometry=BBOX, + attributes=dict(ANNOTATION.attributes), + provenance=ANNOTATION.provenance, + model_ref=ANNOTATION.model_ref, + confidence=ANNOTATION.confidence, + ), + ), + image=b"\x89PNG\r\n\x1a\n" + b"\x00" * 8, + media_type="image/png", +) + +EXPORT_PREPROCESSING = ExportPreprocessing( + recipe_name="yolo-640", + spec=PREPROCESSING_SPEC, + recipe_hash=_HASH, + pillow_version="12.0.0", + mapping=( + ExportFileMapping( + file="images/train/" + _HASH + ".jpg", + source_content_hash=_HASH, + exported_sha256="2" * 64, + variant=0, + ), + ExportFileMapping( + file="images/train/" + _HASH + "-aug1.jpg", + source_content_hash=_HASH, + exported_sha256="3" * 64, + variant=1, + ), + ), +) + +EXPORT_RESULT_WITH_RECIPE = EXPORT_RESULT.model_copy( + update={ + "source_file_count": 2, + "augmented_file_count": 1, + "source_annotation_count": 5, + "augmented_annotation_count": 3, + "preprocessing": EXPORT_PREPROCESSING, + } +) diff --git a/tests/server/test_preprocessing_recipes.py b/tests/server/test_preprocessing_recipes.py new file mode 100644 index 00000000..af04f3dc --- /dev/null +++ b/tests/server/test_preprocessing_recipes.py @@ -0,0 +1,238 @@ +"""Pre-processing recipes over HTTP: a named resource with no state, and a preview.""" + +from __future__ import annotations + +import base64 +import io +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from PIL import Image +from tests.server._api import api_client +from tests.server._flow import asset_ids, batch_from_ingest, project_with_schema +from tests.server._jobs import InlineDispatcher + + +@pytest.fixture() +def runner() -> InlineDispatcher: + return InlineDispatcher() + + +@pytest.fixture() +def client(tmp_path: Path, runner: InlineDispatcher) -> Iterator[TestClient]: + with api_client(tmp_path / "ws", dispatcher=runner) as made: + yield made + + +@pytest.fixture() +def project(client: TestClient) -> str: + response = client.post("/projects", json={"name": "road-signs"}) + assert response.status_code == 201, response.text + project_id: str = response.json()["id"] + return project_id + + +LETTERBOX: dict[str, Any] = { + "target": "yolo11", + "steps": [{"kind": "resize", "strategy": "letterbox", "width": 640, "height": 640}], + "variants_per_asset": 0, +} +AUGMENTED: dict[str, Any] = { + "target": None, + "steps": [ + {"kind": "resize", "strategy": "stretch", "width": 64, "height": 64}, + {"kind": "augment", "op": "hflip"}, + {"kind": "augment", "op": "brightness_contrast", "amount": 0.3}, + ], + "variants_per_asset": 2, +} + + +def _base(client: TestClient, project: str) -> str: + return f"/projects/{project}/preprocessing-recipes" + + +# --- the resource --------------------------------------------------------------- + + +def test_a_recipe_is_created_listed_and_read_back_whole(client: TestClient, project: str) -> None: + created = client.post(_base(client, project), json={"name": "lb", "spec": LETTERBOX}) + assert created.status_code == 201, created.text + body = created.json() + assert body["name"] == "lb" + assert body["project_id"] == project + assert body["spec"] == { + "target": "yolo11", + "steps": [ + { + "kind": "resize", + "strategy": "letterbox", + "width": 640, + "height": 640, + "pad_value": 114, + } + ], + "variants_per_asset": 0, + } + assert body["created_at"] == body["updated_at"] + + listed = client.get(_base(client, project)).json() + assert listed == {"items": [body], "total": 1} + assert client.get(f"{_base(client, project)}/lb").json() == body + + +def test_a_taken_name_is_409_and_a_bad_name_or_spec_is_422( + client: TestClient, project: str +) -> None: + assert ( + client.post(_base(client, project), json={"name": "lb", "spec": LETTERBOX}).status_code + == 201 + ) + + taken = client.post(_base(client, project), json={"name": "lb", "spec": AUGMENTED}) + assert taken.status_code == 409, taken.text + assert taken.json()["code"] == "PREPROCESSING_RECIPE_NAME_TAKEN" + + bad_name = client.post(_base(client, project), json={"name": "Not A Slug", "spec": LETTERBOX}) + assert bad_name.status_code == 422, bad_name.text + assert bad_name.json()["code"] == "INVALID_NAME" + + two_resizes = client.post( + _base(client, project), + json={ + "name": "two", + "spec": {"target": None, "steps": [LETTERBOX["steps"][0]] * 2, "variants_per_asset": 0}, + }, + ) + assert two_resizes.status_code == 422, two_resizes.text + assert two_resizes.json()["code"] == "VALIDATION_ERROR" + assert "at most one resize" in two_resizes.text + + variants_without_augment = client.post( + _base(client, project), + json={"name": "v", "spec": {**LETTERBOX, "variants_per_asset": 2}}, + ) + assert variants_without_augment.status_code == 422, variants_without_augment.text + + +def test_put_replaces_the_spec_and_can_rename(client: TestClient, project: str) -> None: + client.post(_base(client, project), json={"name": "lb", "spec": LETTERBOX}) + client.post(_base(client, project), json={"name": "other", "spec": LETTERBOX}) + + updated = client.put(f"{_base(client, project)}/lb", json={"name": "aug", "spec": AUGMENTED}) + assert updated.status_code == 200, updated.text + assert updated.json()["name"] == "aug" + assert updated.json()["spec"]["variants_per_asset"] == 2 + assert client.get(f"{_base(client, project)}/lb").status_code == 404 + assert client.get(f"{_base(client, project)}/aug").json() == updated.json() + + collision = client.put( + f"{_base(client, project)}/aug", json={"name": "other", "spec": AUGMENTED} + ) + assert collision.status_code == 409, collision.text + assert collision.json()["code"] == "PREPROCESSING_RECIPE_NAME_TAKEN" + + +def test_delete_removes_it_and_an_unknown_name_is_404(client: TestClient, project: str) -> None: + client.post(_base(client, project), json={"name": "lb", "spec": LETTERBOX}) + + assert client.delete(f"{_base(client, project)}/lb").status_code == 204 + assert client.get(_base(client, project)).json()["total"] == 0 + for response in ( + client.get(f"{_base(client, project)}/lb"), + client.delete(f"{_base(client, project)}/lb"), + client.put(f"{_base(client, project)}/lb", json={"name": "lb", "spec": LETTERBOX}), + ): + assert response.status_code == 404, response.text + assert response.json()["code"] == "PREPROCESSING_RECIPE_NOT_FOUND" + + +def test_an_unknown_project_is_404_on_every_route(client: TestClient) -> None: + base = f"/projects/{uuid4()}/preprocessing-recipes" + for response in ( + client.get(base), + client.post(base, json={"name": "lb", "spec": LETTERBOX}), + client.get(f"{base}/lb"), + client.put(f"{base}/lb", json={"name": "lb", "spec": LETTERBOX}), + client.delete(f"{base}/lb"), + ): + assert response.status_code == 404, response.text + assert response.json()["code"] == "PROJECT_NOT_FOUND" + + +# --- the preview ------------------------------------------------------------------- + + +@pytest.fixture() +def asset(client: TestClient, tmp_path: Path, runner: InlineDispatcher) -> tuple[str, str]: + """A project with one ingested asset, and that asset's id.""" + project_id = project_with_schema(client) + batch_id = batch_from_ingest(client, runner, tmp_path, project_id, images=1) + (asset_id,) = asset_ids(client, batch_id) + return project_id, asset_id + + +def _preview(client: TestClient, project_id: str, body: dict[str, Any]) -> Any: + return client.post(f"/projects/{project_id}/preprocessing-preview", json=body) + + +def test_the_preview_renders_the_asset_through_the_spec( + client: TestClient, asset: tuple[str, str] +) -> None: + project_id, asset_id = asset + + response = _preview(client, project_id, {"spec": AUGMENTED, "asset_id": asset_id, "variant": 1}) + + assert response.status_code == 200, response.text + assert response.headers["cache-control"] == "no-store" + body = response.json() + assert (body["asset_id"], body["variant"]) == (asset_id, 1) + assert (body["width"], body["height"]) == (64, 64) + assert body["media_type"] in {"image/png", "image/jpeg"} + with Image.open(io.BytesIO(base64.b64decode(body["image_base64"]))) as image: + assert image.size == (64, 64) + assert body["annotations"] == [] + + +def test_the_preview_is_capped_to_512_on_its_longer_side( + client: TestClient, asset: tuple[str, str] +) -> None: + project_id, asset_id = asset + wide = { + "target": None, + "steps": [{"kind": "resize", "strategy": "stretch", "width": 2048, "height": 1024}], + "variants_per_asset": 0, + } + + body = _preview(client, project_id, {"spec": wide, "asset_id": asset_id}).json() + + assert (body["width"], body["height"]) == (512, 256) + with Image.open(io.BytesIO(base64.b64decode(body["image_base64"]))) as image: + assert image.size == (512, 256) + + +def test_a_variant_the_spec_does_not_make_is_422( + client: TestClient, asset: tuple[str, str] +) -> None: + project_id, asset_id = asset + + response = _preview(client, project_id, {"spec": LETTERBOX, "asset_id": asset_id, "variant": 1}) + + assert response.status_code == 422, response.text + assert response.json()["code"] == "VALIDATION_ERROR" + + +def test_an_asset_outside_the_project_is_404(client: TestClient, asset: tuple[str, str]) -> None: + project_id, asset_id = asset + other = client.post("/projects", json={"name": "other"}).json()["id"] + + response = _preview(client, other, {"spec": LETTERBOX, "asset_id": asset_id}) + + assert response.status_code == 404, response.text + assert response.json()["code"] == "ASSET_NOT_FOUND" + missing = _preview(client, project_id, {"spec": LETTERBOX, "asset_id": str(uuid4())}) + assert missing.json()["code"] == "ASSET_NOT_FOUND" From 52a122033688fcb827662a6ac93701002c2207c1 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:44:56 -0700 Subject: [PATCH 4/9] feat(cli): visionset recipe, and export --recipe A recipe group with create, list, show, update and delete, taking either a JSON spec file or the flag form (--resize, --augment, --variants, --target), and --recipe on export, which resolves the recipe through the release's project and applies it. Part of the pre-processing epic (#785). --- src/visionset/cli/export.py | 46 ++++- src/visionset/cli/main.py | 2 + src/visionset/cli/preprocessing.py | 321 +++++++++++++++++++++++++++++ tests/cli/test_recipe_commands.py | 257 +++++++++++++++++++++++ 4 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 src/visionset/cli/preprocessing.py create mode 100644 tests/cli/test_recipe_commands.py diff --git a/src/visionset/cli/export.py b/src/visionset/cli/export.py index 8ce93a8f..bee29a64 100644 --- a/src/visionset/cli/export.py +++ b/src/visionset/cli/export.py @@ -59,7 +59,12 @@ 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 +from visionset.kernel.services import ( + EXPORT_REPORT_FILENAME, + PreprocessingRecipeService, + ReleaseService, +) +from visionset.preprocessing import registry as preprocessing_registry def export( @@ -107,6 +112,16 @@ def export( help="Accept a format that cannot carry everything the release holds.", ), ] = False, + recipe: Annotated[ + str | None, + typer.Option( + "--recipe", + help=( + "A pre-processing recipe of the project, by name. " + "`visionset recipe list` says which." + ), + ), + ] = None, json_out: JsonOption = False, workspace: WorkspaceOption = None, ) -> None: @@ -121,6 +136,12 @@ def export( report — what is carried, what arrives coarser, what is dropped and why — and exits 1 if the format would lose anything, so `visionset export --check ... && visionset export ...` means something. + + `--recipe` applies one of the project's pre-processing recipes: every image + is resized as the recipe says, and augmented variants are written for the + train fold. `--check` with a recipe also refuses now what the export would + refuse — augmentation over a release published without a split, or a step + that cannot move a geometry the release carries. """ # A usage error rather than a domain one, so Click formats it and it exits 2: # nothing has been resolved yet, no workspace has been opened, and the mistake @@ -138,8 +159,15 @@ def export( # 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) + spec = ( + None + if recipe is None + else PreprocessingRecipeService(service).for_release(found.id, recipe).spec + ) if check: - report = ReleaseService(service).check_export(found.id, plugin, target=addressed) + report = ReleaseService(service).check_export( + found.id, plugin, target=addressed, recipe=spec + ) _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 @@ -156,7 +184,14 @@ def export( # 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, target=addressed + found.id, + plugin, + out, + allow_lossy=allow_lossy, + target=addressed, + recipe=spec, + recipe_name=recipe, + drivers=None if spec is None else preprocessing_registry.drivers(), ) if json_out: document(wire.export_result(result)) @@ -165,6 +200,11 @@ def export( f"Exported {found.tag!r} as {result.format_name}: " f"{result.file_count} file(s), {result.total_bytes} byte(s)." ) + if result.preprocessing is not None: + note( + f"Recipe {recipe!r} applied: {result.source_file_count} source image(s), " + f"{result.augmented_file_count} augmented variant(s)." + ) # On stderr with the rest of the prose, so `visionset export ... | xargs` # still gets exactly the directory. Named classes with a count rather than a # total, because "polygon, 1204" is what somebody acts on and a bare total is diff --git a/src/visionset/cli/main.py b/src/visionset/cli/main.py index 3eedb767..20f08936 100644 --- a/src/visionset/cli/main.py +++ b/src/visionset/cli/main.py @@ -15,6 +15,7 @@ from visionset.cli.init import init from visionset.cli.jobs import job_app from visionset.cli.mcp import mcp +from visionset.cli.preprocessing import recipe_app from visionset.cli.projects import project_app from visionset.cli.releases import release_app from visionset.cli.schemas import schema_app @@ -52,6 +53,7 @@ app.command("export")(export) app.add_typer(format_app, name="format") app.add_typer(target_app, name="target") +app.add_typer(recipe_app, name="recipe") 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/preprocessing.py b/src/visionset/cli/preprocessing.py new file mode 100644 index 00000000..51476186 --- /dev/null +++ b/src/visionset/cli/preprocessing.py @@ -0,0 +1,321 @@ +# usage: from visionset.cli.preprocessing import recipe_app, spec_options +"""``visionset recipe`` — a project's pre-processing recipes, by name. + +A recipe is a named ``RecipeSpec``: what an export does to every image and how +many augmented variants it makes. Nothing here transforms a pixel; a recipe is +applied by ``visionset export --recipe``, and the export keeps the spec by value, +so editing or deleting a recipe afterwards changes no export that already ran. + +**Two ways to say what a recipe does, never both.** ``--spec FILE`` reads the +whole spec as JSON in the wire's own shape — what ``recipe show --json`` prints +— and is the one a script uses. The flag form builds the same value at the +prompt: ``--resize letterbox:640x640``, ``--augment hflip,brightness_contrast``, +``--variants 2``, ``--target yolo11``. Mixing the file with a flag is a usage +error at exit 2, because the file already says everything and a flag beside it +would either repeat it or contradict it. + +The recipe grammar is the kernel's — at most one resize and it comes first, +augmentation needs variants and variants need augmentation — and a spec that +breaks it is refused with the rule, at exit 2: the mistake is on the command +line, and nothing has been opened yet. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Annotated, Final + +import typer +from pydantic import ValidationError + +from visionset import wire +from visionset.cli._output import JsonOption, document, moment, note, table +from visionset.cli._resolve import ProjectOption, resolve_project +from visionset.cli._workspace import WorkspaceOption, opened_workspace +from visionset.kernel.domain import ( + AugmentOp, + AugmentStep, + PreprocessingRecipe, + RecipeSpec, + ResizeStep, + ResizeStrategy, + Step, +) +from visionset.kernel.services import PreprocessingRecipeService + +recipe_app = typer.Typer(help="Manage a project's pre-processing recipes.", no_args_is_help=True) + +_COLUMNS: Final = ("NAME", "TARGET", "STEPS", "VARIANTS", "UPDATED") + +NameArgument = Annotated[str, typer.Argument(help="The recipe's name, unique within the project.")] + +SpecOption = Annotated[ + Path | None, + typer.Option( + "--spec", + exists=True, + dir_okay=False, + help="A JSON file holding the whole spec, in the shape `recipe show --json` prints.", + ), +] +ResizeOption = Annotated[ + str | None, + typer.Option( + "--resize", + metavar="STRATEGY:WxH", + help="Resize every image: `letterbox:640x640` or `stretch:640x480`.", + ), +] +PadOption = Annotated[ + int, typer.Option("--pad", help="The grey a letterbox pads with, 0 to 255.", min=0, max=255) +] +AugmentOption = Annotated[ + str | None, + typer.Option( + "--augment", + metavar="OPS", + help="Comma-separated augmentations: hflip, brightness_contrast, rot90.", + ), +] +AmountOption = Annotated[ + float, + typer.Option("--amount", help="How far brightness and contrast may move, 0 to 0.5."), +] +VariantsOption = Annotated[ + int | None, + typer.Option("--variants", help="Augmented variants per train-fold image, 0 to 8."), +] +TargetOption = Annotated[ + str | None, + typer.Option("--target", help="The export target whose hints this recipe was written from."), +] + + +def spec_of( + spec: Path | None, + *, + resize: str | None, + pad: int, + augment: str | None, + amount: float, + variants: int | None, + target: str | None, +) -> RecipeSpec: + """The spec the command line describes, or a usage error saying what is wrong. + + ``RecipeSpec`` refuses a spec that breaks the grammar with a pydantic + ``ValidationError`` — not a ``VisionSetError``, so it would print a traceback + rather than a sentence. Caught here and re-raised as Click's own refusal, + which is what exit 2 is for. + """ + flagged = any(one is not None for one in (resize, augment, variants, target)) + if spec is not None and flagged: + raise typer.BadParameter( + "Give either --spec or the flag form (--resize, --augment, --variants, --target)." + ) + if spec is None and not flagged: + raise typer.BadParameter("Say what the recipe does: --spec FILE, or --resize/--augment.") + try: + if spec is not None: + return RecipeSpec.model_validate(json.loads(spec.read_text(encoding="utf-8"))) + steps: list[Step] = [] + if resize is not None: + steps.append(_resize_of(resize, pad)) + for name in _augment_names(augment): + steps.append(AugmentStep(op=AugmentOp(name), amount=amount)) + return RecipeSpec(target=target, steps=tuple(steps), variants_per_asset=variants or 0) + except (ValidationError, ValueError) as exc: + raise typer.BadParameter(_one_line(exc)) from exc + + +def _resize_of(value: str, pad: int) -> ResizeStep: + strategy, _, size = value.partition(":") + width, _, height = size.partition("x") + if not (strategy and width.isdigit() and height.isdigit()): + raise typer.BadParameter( + f"--resize takes STRATEGY:WxH, e.g. letterbox:640x640, not {value!r}" + ) + try: + chosen = ResizeStrategy(strategy) + except ValueError as exc: + known = ", ".join(one.value for one in ResizeStrategy) + raise typer.BadParameter( + f"--resize strategy must be one of {known}, not {strategy!r}" + ) from exc + return ResizeStep(strategy=chosen, width=int(width), height=int(height), pad_value=pad) + + +def _augment_names(value: str | None) -> list[str]: + if value is None: + return [] + names = [part.strip() for part in value.split(",") if part.strip()] + known = {one.value for one in AugmentOp} + for name in names: + if name not in known: + raise typer.BadParameter( + f"--augment names must be among {', '.join(sorted(known))}, not {name!r}" + ) + return names + + +def _one_line(exc: Exception) -> str: + if isinstance(exc, ValidationError): + return "; ".join(str(error["msg"]).removeprefix("Value error, ") for error in exc.errors()) + return str(exc) + + +def _steps_summary(spec: RecipeSpec) -> str: + parts = [] + for step in spec.steps: + if isinstance(step, ResizeStep): + parts.append(f"{step.strategy.value} {step.width}x{step.height}") + else: + parts.append(step.op.value) + return ", ".join(parts) or "none" + + +def _row(recipe: PreprocessingRecipe) -> tuple[str, ...]: + return ( + recipe.name, + recipe.spec.target or "", + _steps_summary(recipe.spec), + str(recipe.spec.variants_per_asset), + moment(recipe.updated_at), + ) + + +def _print(recipe: PreprocessingRecipe, *, json_out: bool, verb: str) -> None: + if json_out: + document(wire.preprocessing_recipe(recipe)) + return + note( + f"{verb} recipe {recipe.name!r}: {_steps_summary(recipe.spec)}, " + f"{recipe.spec.variants_per_asset} variant(s) per train image." + ) + typer.echo(recipe.name) + + +@recipe_app.command("create") +def recipe_create( + name: NameArgument, + project: ProjectOption, + spec: SpecOption = None, + resize: ResizeOption = None, + pad: PadOption = 114, + augment: AugmentOption = None, + amount: AmountOption = 0.2, + variants: VariantsOption = None, + target: TargetOption = None, + json_out: JsonOption = False, + workspace: WorkspaceOption = None, +) -> None: + """Store a new recipe under a name. + + Either `--spec FILE` or the flag form. Augmentation runs on the train fold + only, so `--variants` above 0 needs a release published with a split. + """ + built = spec_of( + spec, + resize=resize, + pad=pad, + augment=augment, + amount=amount, + variants=variants, + target=target, + ) + with opened_workspace(workspace) as service: + resolved = resolve_project(service, project) + created = PreprocessingRecipeService(service).create(resolved.id, name, built) + _print(created, json_out=json_out, verb="Created") + + +@recipe_app.command("list") +def recipe_list( + project: ProjectOption, + json_out: JsonOption = False, + workspace: WorkspaceOption = None, +) -> None: + """List a project's recipes, oldest first.""" + with opened_workspace(workspace) as service: + resolved = resolve_project(service, project) + recipes = PreprocessingRecipeService(service).list(resolved.id) + if json_out: + document(wire.page([wire.preprocessing_recipe(one) for one in recipes])) + return + table(_COLUMNS, [_row(one) for one in recipes]) + if not recipes: + note(f"Project {resolved.name!r} has no pre-processing recipes.") + + +@recipe_app.command("show") +def recipe_show( + name: NameArgument, + project: ProjectOption, + json_out: JsonOption = False, + workspace: WorkspaceOption = None, +) -> None: + """Print one recipe. `--json` is the shape `--spec` reads back, under `spec`.""" + with opened_workspace(workspace) as service: + resolved = resolve_project(service, project) + recipe = PreprocessingRecipeService(service).get(resolved.id, name) + if json_out: + document(wire.preprocessing_recipe(recipe)) + return + table(_COLUMNS, [_row(recipe)]) + + +@recipe_app.command("update") +def recipe_update( + name: NameArgument, + project: ProjectOption, + spec: SpecOption = None, + resize: ResizeOption = None, + pad: PadOption = 114, + augment: AugmentOption = None, + amount: AmountOption = 0.2, + variants: VariantsOption = None, + target: TargetOption = None, + rename: Annotated[ + str | None, typer.Option("--rename", help="A new name for the recipe.") + ] = None, + json_out: JsonOption = False, + workspace: WorkspaceOption = None, +) -> None: + """Replace a recipe whole, and rename it with `--rename`. + + The spec is one value, so there is no field-at-a-time edit: say all of it + again. Exports that already ran keep the spec they ran with. + """ + built = spec_of( + spec, + resize=resize, + pad=pad, + augment=augment, + amount=amount, + variants=variants, + target=target, + ) + with opened_workspace(workspace) as service: + resolved = resolve_project(service, project) + updated = PreprocessingRecipeService(service).update( + resolved.id, name, spec=built, new_name=rename + ) + _print(updated, json_out=json_out, verb="Updated") + + +@recipe_app.command("delete") +def recipe_delete( + name: NameArgument, + project: ProjectOption, + json_out: JsonOption = False, + workspace: WorkspaceOption = None, +) -> None: + """Remove a recipe. No prompt: every export that used it kept its own copy.""" + with opened_workspace(workspace) as service: + resolved = resolve_project(service, project) + removed = PreprocessingRecipeService(service).delete(resolved.id, name) + if json_out: + document({"deleted": wire.preprocessing_recipe(removed)}) + return + note(f"Deleted recipe {removed.name!r}.") diff --git a/tests/cli/test_recipe_commands.py b/tests/cli/test_recipe_commands.py new file mode 100644 index 00000000..86c3c9fc --- /dev/null +++ b/tests/cli/test_recipe_commands.py @@ -0,0 +1,257 @@ +"""``visionset recipe``: a project's pre-processing recipes at a terminal.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from tests.cli._flow import ok, payload, plain, project, run, usage_error, workspace +from tests.cli.test_export_commands import _labeled_release + +from visionset.kernel.services import EXPORT_REPORT_FILENAME + + +@pytest.fixture() +def root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.delenv("VISIONSET_WORKSPACE", raising=False) + return workspace(tmp_path) + + +def test_create_from_flags_prints_the_name_and_list_shows_it(root: Path) -> None: + name = project(root) + + created = ok( + root, + "recipe", + "create", + "lb", + "-p", + name, + "--resize", + "letterbox:640x640", + "--augment", + "hflip,brightness_contrast", + "--variants", + "2", + "--target", + "yolo11", + ) + assert created.strip() == "lb" + + listed = ok(root, "recipe", "list", "-p", name) + rows = [line.split() for line in plain(listed).splitlines()] + assert rows[0][:4] == ["NAME", "TARGET", "STEPS", "VARIANTS"] + assert rows[1][:2] == ["lb", "yolo11"] + shown = payload(root, "recipe", "show", "lb", "-p", name) + assert shown["spec"] == { + "target": "yolo11", + "steps": [ + { + "kind": "resize", + "strategy": "letterbox", + "width": 640, + "height": 640, + "pad_value": 114, + }, + {"kind": "augment", "op": "hflip", "amount": 0.2}, + {"kind": "augment", "op": "brightness_contrast", "amount": 0.2}, + ], + "variants_per_asset": 2, + } + + +def test_create_from_a_spec_file_reads_the_shape_show_json_prints( + root: Path, tmp_path: Path +) -> None: + name = project(root) + ok(root, "recipe", "create", "a", "-p", name, "--resize", "stretch:320x240") + shown = payload(root, "recipe", "show", "a", "-p", name) + spec_file = tmp_path / "spec.json" + spec_file.write_text(json.dumps(shown["spec"]), encoding="utf-8") + + ok(root, "recipe", "create", "b", "-p", name, "--spec", str(spec_file)) + + assert payload(root, "recipe", "show", "b", "-p", name)["spec"] == shown["spec"] + listed = payload(root, "recipe", "list", "-p", name) + assert [row["name"] for row in listed["items"]] == ["a", "b"] + assert listed["total"] == 2 + + +def test_the_spec_file_and_the_flag_form_are_one_choice_at_exit_two( + root: Path, tmp_path: Path +) -> None: + name = project(root) + spec_file = tmp_path / "spec.json" + spec_file.write_text("{}", encoding="utf-8") + + both = run( + root, "recipe", "create", "x", "-p", name, "--spec", str(spec_file), "--variants", "1" + ) + neither = run(root, "recipe", "create", "x", "-p", name) + + assert both.exit_code == 2 + assert "either --spec or the flag form" in usage_error(both) + assert neither.exit_code == 2 + assert "Say what the recipe does" in usage_error(neither) + + +def test_a_spec_that_breaks_the_grammar_is_refused_with_the_rule_at_exit_two(root: Path) -> None: + name = project(root) + + result = run(root, "recipe", "create", "x", "-p", name, "--augment", "hflip") + + assert result.exit_code == 2 + assert "at least one variant" in usage_error(result) + bad_resize = run(root, "recipe", "create", "x", "-p", name, "--resize", "crop:640x640") + assert bad_resize.exit_code == 2 + assert "stretch, letterbox" in usage_error(bad_resize) + bad_op = run(root, "recipe", "create", "x", "-p", name, "--augment", "blur", "--variants", "1") + assert bad_op.exit_code == 2 + assert "blur" in usage_error(bad_op) + + +def test_a_taken_name_and_an_unknown_name_exit_one(root: Path) -> None: + name = project(root) + ok(root, "recipe", "create", "lb", "-p", name, "--resize", "letterbox:640x640") + + taken = run(root, "recipe", "create", "lb", "-p", name, "--resize", "letterbox:640x640") + assert taken.exit_code == 1 + assert "already has a pre-processing recipe named 'lb'" in taken.output + missing = run(root, "recipe", "show", "nope", "-p", name) + assert missing.exit_code == 1 + assert "no pre-processing recipe named 'nope'" in missing.output + + +def test_update_replaces_the_spec_and_can_rename(root: Path) -> None: + name = project(root) + ok(root, "recipe", "create", "lb", "-p", name, "--resize", "letterbox:640x640") + + updated = payload( + root, + "recipe", + "update", + "lb", + "-p", + name, + "--augment", + "rot90", + "--variants", + "1", + "--rename", + "turns", + "--json", + ) + + assert updated["name"] == "turns" + assert updated["spec"]["steps"] == [{"kind": "augment", "op": "rot90", "amount": 0.2}] + assert [row["name"] for row in payload(root, "recipe", "list", "-p", name)["items"]] == [ + "turns" + ] + + +def test_delete_removes_it_and_says_so(root: Path) -> None: + name = project(root) + ok(root, "recipe", "create", "lb", "-p", name, "--resize", "letterbox:640x640") + + result = run(root, "recipe", "delete", "lb", "-p", name) + + assert result.exit_code == 0, result.output + assert "Deleted recipe 'lb'" in result.output + assert payload(root, "recipe", "list", "-p", name) == {"items": [], "total": 0} + removed = payload(root, "recipe", "create", "lb", "-p", name, "--resize", "stretch:64x64") + assert payload(root, "recipe", "delete", "lb", "-p", name)["deleted"]["id"] == removed["id"] + + +def test_export_with_a_recipe_writes_variants_and_records_the_recipe( + root: Path, tmp_path: Path +) -> None: + name, out = _labeled_release(root, tmp_path) + ok(root, "release", "publish", "--tag", "split", "--project", name, "--split", "0.6,0.2,0.2") + ok( + root, + "recipe", + "create", + "flip", + "-p", + name, + "--resize", + "letterbox:64x64", + "--augment", + "hflip", + "--variants", + "1", + ) + + result = payload( + root, + "export", + "-p", + name, + "--release", + "split", + "-f", + "ultralytics", + "--recipe", + "flip", + "--allow-lossy", + "--out", + str(out), + "--json", + ) + + assert result["preprocessing"]["recipe_name"] == "flip" + assert result["augmented_file_count"] >= 1 + assert result["source_file_count"] + result["augmented_file_count"] == len( + result["preprocessing"]["mapping"] + ) + assert any(path.stem.endswith("-aug1") for path in (out / "labels" / "train").glob("*.txt")) + report = json.loads((out / EXPORT_REPORT_FILENAME).read_text(encoding="utf-8")) + assert report["preprocessing"]["recipe_hash"] == result["preprocessing"]["recipe_hash"] + + +def test_an_augmenting_recipe_over_an_unsplit_release_exits_one_on_check_and_export( + root: Path, tmp_path: Path +) -> None: + name, out = _labeled_release(root, tmp_path) + ok(root, "recipe", "create", "flip", "-p", name, "--augment", "hflip", "--variants", "1") + + for extra in (["--check"], ["--out", str(out)]): + result = run( + root, + "export", + "-p", + name, + "--release", + "v1.0", + "-f", + "dummy", + "--recipe", + "flip", + *extra, + ) + assert result.exit_code == 1, result.output + assert "no split recipe" in result.output + assert not out.exists() + + +def test_an_unknown_recipe_exits_one_naming_it(root: Path, tmp_path: Path) -> None: + name, out = _labeled_release(root, tmp_path) + + result = run( + root, + "export", + "-p", + name, + "--release", + "v1.0", + "-f", + "ultralytics", + "--recipe", + "nope", + "--out", + str(out), + ) + + assert result.exit_code == 1 + assert "no pre-processing recipe named 'nope'" in result.output From 2bd671f434cb783814f6ea5e026c8f3cef44a788 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:44:56 -0700 Subject: [PATCH 5/9] feat(mcp): recipe tools, and recipe on export_release and check_export create_preprocessing_recipe and list_preprocessing_recipes are offered by default; delete_preprocessing_recipe is offered only under --allow-destructive and takes confirm. The tool reference is regenerated. Part of the pre-processing epic (#785). --- docs/content/mcp-tools.md | 9 +- src/visionset/mcp/main.py | 4 + src/visionset/mcp/preprocessing.py | 122 ++++++++++++++++++++++++++ src/visionset/mcp/releases.py | 55 +++++++++++- tests/mcp/test_preprocessing_tools.py | 122 ++++++++++++++++++++++++++ tests/mcp/test_registration.py | 9 +- 6 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 src/visionset/mcp/preprocessing.py create mode 100644 tests/mcp/test_preprocessing_tools.py diff --git a/docs/content/mcp-tools.md b/docs/content/mcp-tools.md index 6adbd709..ca0baf9b 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 -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. +56 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 | | --- | --- | --- | @@ -60,8 +60,10 @@ error envelope, and the three gate words. | `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. | | `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. | +| `check_export` | `project`, `tag`, `target`?, `format`?, `recipe`? | Say what a target or a format would drop from a release, without writing anything. | +| `export_release` | `project`, `tag`, `dest`, `target`?, `format`?, `allow_lossy`?, `recipe`? | Write a release to a local directory, for a target or in one of the installed formats. | +| `list_preprocessing_recipes` | `project` | List a project's pre-processing recipes, oldest first, each with its whole spec. | +| `create_preprocessing_recipe` | `project`, `name`, `spec` | Store a named pre-processing recipe on a project, for `export_release` to apply. | | `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. | @@ -79,3 +81,4 @@ Absent from the listing unless the server was started for them (#108). A `confir | `delete_batch` | `batch_id`, `confirm`? | Delete a batch and how its work was organised. Destructive; requires `confirm=true`. | | `delete_project` | `project`, `confirm`? | Delete a project and everything under it. Destructive; requires `confirm=true`. | | `delete_inference_connection` | `connection`, `confirm`? | Remove a connection. Annotations keep the model provenance they recorded. | +| `delete_preprocessing_recipe` | `project`, `name`, `confirm`? | Delete a pre-processing recipe. Destructive; requires `confirm=true`. | diff --git a/src/visionset/mcp/main.py b/src/visionset/mcp/main.py index ac494a50..d2d2cda4 100644 --- a/src/visionset/mcp/main.py +++ b/src/visionset/mcp/main.py @@ -54,6 +54,7 @@ formats, inference, jobs, + preprocessing, projects, releases, schemas, @@ -130,6 +131,8 @@ (formats.list_export_targets, READS), (releases.check_export, READS), (releases.export_release, WRITES), + (preprocessing.list_preprocessing_recipes, READS), + (preprocessing.create_preprocessing_recipe, WRITES), # After the cycle, not in it: connections are workspace configuration — # every project shares them — so they read as the appendix rather than as a # rung. Within the group, the order is the setup journey: see what is @@ -159,6 +162,7 @@ (batches.delete_batch, DESTROYS), (projects.delete_project, DESTROYS), (inference.delete_inference_connection, DESTROYS), + (preprocessing.delete_preprocessing_recipe, DESTROYS), ) """Tools that destroy something, registered **only on request**. diff --git a/src/visionset/mcp/preprocessing.py b/src/visionset/mcp/preprocessing.py new file mode 100644 index 00000000..1bd5d04c --- /dev/null +++ b/src/visionset/mcp/preprocessing.py @@ -0,0 +1,122 @@ +# usage: from visionset.mcp import preprocessing +"""Pre-processing recipe tools: name what an export does to its images, and list it. + +A recipe is a project resource an agent creates once and names on +``export_release`` and ``check_export``; the export keeps the spec by value, so +nothing an agent does to a recipe afterwards changes an export that already ran. +``delete_preprocessing_recipe`` is offered only under ``--allow-destructive``, +with ``confirm``, on the terms every other delete follows — a recipe is small, +but it is shared, named work. + +There is no ``get`` and no ``update``: a project holds a handful of recipes and +the listing carries every field, and an agent that wants a different recipe +creates it under a new name rather than editing one somebody else may be +exporting with. +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from pydantic import Field + +from visionset import wire +from visionset.kernel.domain import RecipeSpec +from visionset.kernel.errors import ConfirmationRequired +from visionset.kernel.services import PreprocessingRecipeService +from visionset.mcp._resolve import ProjectRef, resolve_project +from visionset.mcp._workspace import opened_workspace + +NameRef = Annotated[ + str, + Field( + description=( + "The recipe's name: lowercase letters, digits, dots, hyphens and underscores, " + "unique within the project." + ) + ), +] +"""Module-level for the ``inspect.signature`` reason.""" + + +def create_preprocessing_recipe( + project: ProjectRef, + name: NameRef, + spec: Annotated[ + RecipeSpec, + Field( + description=( + "What the recipe does. `steps` is a list of `{kind: 'resize', strategy: " + "'letterbox'|'stretch', width, height, pad_value?}` (at most one, first) and " + "`{kind: 'augment', op: 'hflip'|'brightness_contrast'|'rot90', amount?}` " + "(each at most once); `variants_per_asset` (0 to 8) is how many augmented " + "variants each train-fold image gets and requires an augment step; `target` " + "records which export target's hints the recipe was written from." + ) + ), + ], +) -> dict[str, Any]: + """Store a named pre-processing recipe on a project, for `export_release` to apply. + + A recipe resizes every exported image and, with `variants_per_asset` above + 0, writes augmented variants of the train-fold images beside their sources. + Read `list_export_targets` first: each target's `hints` say what its trainer + expects — a recommended size and strategy, whether the trainer resizes on + its own, and whether augmentation is the ordinary practice — and `target` + records which one this recipe was written from. + + Refuses a name the project already uses, a name that is not a slug, and a + spec that breaks the grammar: at most one resize step and it comes first, + an augment step needs `variants_per_asset` of at least 1, and variants need + at least one augment step. Augmentation runs on the train fold only, so an + augmenting recipe can be exported only from a release published with a + split. + """ + with opened_workspace() as workspace: + resolved = resolve_project(workspace, project) + created = PreprocessingRecipeService(workspace).create(resolved.id, name, spec) + return wire.preprocessing_recipe(created) + + +def list_preprocessing_recipes(project: ProjectRef) -> dict[str, Any]: + """List a project's pre-processing recipes, oldest first, each with its whole spec. + + `name` is exactly what `export_release` and `check_export` take as `recipe`. + The spec is the value an export snapshots; an export that already ran keeps + the spec it ran with whatever happens to the recipe afterwards. + """ + with opened_workspace() as workspace: + resolved = resolve_project(workspace, project) + found = PreprocessingRecipeService(workspace).list(resolved.id) + return wire.page([wire.preprocessing_recipe(one) for one in found]) + + +def delete_preprocessing_recipe( + project: ProjectRef, + name: NameRef, + confirm: Annotated[ + bool, + Field( + description=( + "Must be true to actually delete. False returns a refusal and changes nothing." + ) + ), + ] = False, +) -> dict[str, Any]: + """Delete a pre-processing recipe. Destructive; requires `confirm=true`. + + Removes the named recipe and nothing else: every export that used it kept + its own copy of the spec, and their reports still name it. Called without + `confirm=true` it changes nothing and tells you so. An unknown project or + recipe is reported as missing whether or not `confirm` was passed. + """ + with opened_workspace() as workspace: + resolved = resolve_project(workspace, project) + recipes = PreprocessingRecipeService(workspace) + if not confirm: + stored = recipes.get(resolved.id, name) + raise ConfirmationRequired( + f"deleting recipe {stored.name!r} needs confirm=true; nothing was changed" + ) + removed = recipes.delete(resolved.id, name) + return {"deleted": wire.preprocessing_recipe(removed)} diff --git a/src/visionset/mcp/releases.py b/src/visionset/mcp/releases.py index e2e010bb..2d6a7669 100644 --- a/src/visionset/mcp/releases.py +++ b/src/visionset/mcp/releases.py @@ -38,10 +38,11 @@ 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.kernel.services import PreprocessingRecipeService, ProjectService, ReleaseService from visionset.mcp._errors import refused from visionset.mcp._resolve import ProjectRef, resolve_project, resolve_release from visionset.mcp._workspace import opened_workspace +from visionset.preprocessing import registry as preprocessing_registry TagRef = Annotated[str, Field(description="The release tag. Compared case-sensitively.")] """Module-level for the ``inspect.signature`` reason.""" @@ -161,11 +162,24 @@ def _addressed(target: str | None, format: str | None) -> tuple[Exporter, Export return registry.pick(installed, format)[0], None +RecipeRef = Annotated[ + str | None, + Field( + description=( + "A pre-processing recipe of the project, by name. See " + "`list_preprocessing_recipes`. Omit to apply no transform." + ) + ), +] +"""Module-level for the ``inspect.signature`` reason.""" + + def check_export( project: ProjectRef, tag: TagRef, target: TargetRef = None, format: FormatRef = None, + recipe: RecipeRef = None, ) -> dict[str, Any]: """Say what a target or a format would drop from a release, without writing anything. @@ -202,13 +216,25 @@ def check_export( `list_formats`. That flag covers everything a geometry list cannot see — attributes, confidence, provenance — and is true of the format forever; this is about the labels this release actually holds. + + `recipe` names a pre-processing recipe of the project, and the check then + also refuses what `export_release` would refuse for it: augmentation over a + release published without a split, or a step that cannot move a geometry + the release carries. The report itself is unchanged by a recipe. """ 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) plugin, addressed = _addressed(target, format) - report = ReleaseService(workspace).check_export(release.id, plugin, target=addressed) + spec = ( + None + if recipe is None + else PreprocessingRecipeService(workspace).for_release(release.id, recipe).spec + ) + report = ReleaseService(workspace).check_export( + release.id, plugin, target=addressed, recipe=spec + ) return wire.export_compatibility(report) @@ -230,6 +256,7 @@ def export_release( ) ), ] = False, + recipe: RecipeRef = None, ) -> dict[str, Any]: """Write a release to a local directory, for a target or in one of the installed formats. @@ -260,6 +287,16 @@ def export_release( Every successful export also writes `visionset-export-report.json` into `dest`, saying what was and was not carried. It is the kernel's file, not the format's, and it is excluded from `file_count`. + + `recipe` applies one of the project's pre-processing recipes — see + `list_preprocessing_recipes`: every image is resized as the recipe says and + augmented variants are written for the train fold, named `-aug` + beside their source. The result separates `source_file_count` from + `augmented_file_count`, and `preprocessing` carries the recipe as it ran, + its hash, the Pillow version and a file-to-source mapping; the report on + disk carries the same. An augmenting recipe refuses a release published + without a split, and a step refuses a geometry it cannot move — call + `check_export` with the same `recipe` to find out first. """ destination = Path(dest) # An exporter writes into `dest` and creates it if missing, so a path that @@ -273,7 +310,19 @@ def export_release( with opened_workspace() as workspace: release = resolve_release(workspace, project, tag) plugin, addressed = _addressed(target, format) + spec = ( + None + if recipe is None + else PreprocessingRecipeService(workspace).for_release(release.id, recipe).spec + ) result = ReleaseService(workspace).export( - release.id, plugin, destination, allow_lossy=allow_lossy, target=addressed + release.id, + plugin, + destination, + allow_lossy=allow_lossy, + target=addressed, + recipe=spec, + recipe_name=recipe, + drivers=None if spec is None else preprocessing_registry.drivers(), ) return wire.export_result(result) diff --git a/tests/mcp/test_preprocessing_tools.py b/tests/mcp/test_preprocessing_tools.py new file mode 100644 index 00000000..1dfc5a06 --- /dev/null +++ b/tests/mcp/test_preprocessing_tools.py @@ -0,0 +1,122 @@ +"""The pre-processing recipe tools, and a recipe on export.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from tests.mcp._flow import call, call_destructive, error, payload, project, tool_names +from tests.mcp.test_release_tools import promoted + +LETTERBOX: dict[str, Any] = { + "target": "yolo11", + "steps": [{"kind": "resize", "strategy": "letterbox", "width": 640, "height": 640}], + "variants_per_asset": 0, +} +FLIPS: dict[str, Any] = { + "target": None, + "steps": [{"kind": "augment", "op": "hflip"}], + "variants_per_asset": 1, +} + + +@pytest.fixture() +def named(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> str: + return project(monkeypatch, tmp_path) + + +def test_a_recipe_is_created_and_listed_with_its_whole_spec(named: str) -> None: + created = payload(call("create_preprocessing_recipe", project=named, name="lb", spec=LETTERBOX)) + + assert created["name"] == "lb" + assert created["spec"]["steps"][0]["pad_value"] == 114 + listed = payload(call("list_preprocessing_recipes", project=named)) + assert listed == {"items": [created], "total": 1} + + +def test_a_taken_name_and_a_broken_spec_are_refused(named: str) -> None: + payload(call("create_preprocessing_recipe", project=named, name="lb", spec=LETTERBOX)) + + taken = error(call("create_preprocessing_recipe", project=named, name="lb", spec=FLIPS)) + assert "already has a pre-processing recipe named 'lb'" in taken["message"] + broken = call( + "create_preprocessing_recipe", + project=named, + name="v", + spec={**LETTERBOX, "variants_per_asset": 2}, + ) + assert broken.is_error + + +def test_delete_is_offered_only_on_request_and_takes_confirm(named: str) -> None: + payload(call("create_preprocessing_recipe", project=named, name="lb", spec=LETTERBOX)) + assert "delete_preprocessing_recipe" not in tool_names() + assert "delete_preprocessing_recipe" in tool_names(allow_destructive=True) + + refusal = error(call_destructive("delete_preprocessing_recipe", project=named, name="lb")) + assert refusal["retry_with"] == "confirm" + assert payload(call("list_preprocessing_recipes", project=named))["total"] == 1 + + removed = payload( + call_destructive("delete_preprocessing_recipe", project=named, name="lb", confirm=True) + ) + assert removed["deleted"]["name"] == "lb" + assert payload(call("list_preprocessing_recipes", project=named))["total"] == 0 + missing = error(call_destructive("delete_preprocessing_recipe", project=named, name="lb")) + assert "no pre-processing recipe named 'lb'" in missing["message"] + + +def test_an_unknown_recipe_on_export_is_refused_by_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + named = promoted(monkeypatch, tmp_path) + payload(call("publish_release", project=named, tag="v1.0")) + refusal = error(call("check_export", project=named, tag="v1.0", format="dummy", recipe="nope")) + assert "no pre-processing recipe named 'nope'" in refusal["message"] + + +def test_an_augmenting_recipe_over_an_unsplit_release_is_refused_on_check_and_export( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + named = promoted(monkeypatch, tmp_path) + payload(call("publish_release", project=named, tag="v1.0")) + payload(call("create_preprocessing_recipe", project=named, name="flips", spec=FLIPS)) + + checked = error(call("check_export", project=named, tag="v1.0", format="dummy", recipe="flips")) + exported = error( + call( + "export_release", + project=named, + tag="v1.0", + format="dummy", + dest=str(tmp_path / "out"), + recipe="flips", + ) + ) + for refusal in (checked, exported): + assert "no split recipe" in refusal["message"] + + +def test_an_export_with_a_recipe_reports_the_recipe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + named = promoted(monkeypatch, tmp_path) + payload(call("publish_release", project=named, tag="v1.0")) + payload(call("create_preprocessing_recipe", project=named, name="lb", spec=LETTERBOX)) + + result = payload( + call( + "export_release", + project=named, + tag="v1.0", + format="dummy", + dest=str(tmp_path / "out"), + recipe="lb", + ) + ) + + assert result["preprocessing"]["recipe_name"] == "lb" + assert result["preprocessing"]["recipe_hash"] + assert result["preprocessing"]["mapping"] == [] + assert (result["source_file_count"], result["augmented_file_count"]) == (0, 0) diff --git a/tests/mcp/test_registration.py b/tests/mcp/test_registration.py index f14bb645..92c24b81 100644 --- a/tests/mcp/test_registration.py +++ b/tests/mcp/test_registration.py @@ -67,6 +67,8 @@ "check_export", "export_release", "list_export_targets", + "list_preprocessing_recipes", + "create_preprocessing_recipe", "list_formats", "list_inference_connections", "model_download_size", @@ -83,7 +85,12 @@ a set computed from the table would agree with itself no matter what landed.""" -DESTRUCTIVE = {"delete_batch", "delete_project", "delete_inference_connection"} +DESTRUCTIVE = { + "delete_batch", + "delete_project", + "delete_inference_connection", + "delete_preprocessing_recipe", +} """Offered only when the server was started with ``--allow-destructive``. Absent from the listing by default rather than present and gated, because From a9b1081f49bf6774536a05bce332a0ee6c421036 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:46:02 -0700 Subject: [PATCH 6/9] docs: pre-processing recipes, and the recipe on every export surface A new preprocessing.md carries the concept, the grammar, the geometry table, determinism and its scope, the train-only rule, naming and the report; the release, CLI, API and MCP pages gain the recipe half of exporting, the persistence page records migration 17, and the changelog the feature. Part of the pre-processing epic (#785). --- CHANGELOG.md | 18 ++++ docs/content/README.md | 1 + docs/content/cli.md | 25 ++++- docs/content/mcp.md | 9 +- docs/content/preprocessing.md | 183 ++++++++++++++++++++++++++++++++++ docs/content/releases.md | 24 ++++- docs/src/sidebar.mjs | 1 + 7 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 docs/content/preprocessing.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5bcb01..80f0b8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,24 @@ nothing was being distributed. This is the first version that is. ### Added +- **Pre-processing: resize and augmentation recipes applied at export.** Part of the + pre-processing epic (#785). A recipe is a named project resource - at most one resize step + (`letterbox` or `stretch`) followed by augmentation steps (`hflip`, `brightness_contrast`, + `rot90`) and a number of variants per train-fold image - served as + `/projects/{id}/preprocessing-recipes` with a preview at + `POST /projects/{id}/preprocessing-preview`, `visionset recipe`, and the + `create_preprocessing_recipe`, `list_preprocessing_recipes` and (behind `--allow-destructive`) + `delete_preprocessing_recipe` tools. An export names one beside its target - `recipe=` on the + export and compatibility routes, `visionset export --recipe`, `recipe` on `export_release` and + `check_export` - and keeps the spec by value: the job carries a snapshot, and the export report + gains a `preprocessing` block with the spec, its hash, the Pillow version and a mapping from + every written image to its source. `ExportResult` separates `source_file_count` and + `augmented_file_count` from the total. Augmented variants are written for the train fold only + and named `-aug`; a recipe that augments refuses a release published without a split + (`AUGMENTATION_REQUIRES_SPLIT`), and a step refuses a geometry it cannot move + (`PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY`). Workspace format version 17 adds the + `preprocessing_recipes` table. + - **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 diff --git a/docs/content/README.md b/docs/content/README.md index f832a762..bff959d2 100644 --- a/docs/content/README.md +++ b/docs/content/README.md @@ -37,6 +37,7 @@ If you are new to VisionSet, start with [install.md](install.md), then continue | [media.md](media.md) | Decoding raw media: the two processor ports, the accepted image formats, the orientation policy for stills and clips, pinned thumbnails and seek-free frame extraction, and what their determinism does and does not promise | | [datasets.md](datasets.md) | The curated trunk: promotion from a completed batch, what `skipped` keeps out, curation without a `confirm=`, and the append-only change log | | [releases.md](releases.md) | The immutable artifact: what a manifest is and is not, why two publishes agree byte for byte, hash verification, and the seeded split recipe | +| [preprocessing.md](preprocessing.md) | The optional stage at export: resize and augmentation recipes, the grammar, what moves geometry and what moves pixels, determinism and its scope, the train-only rule, and the report | | [events.md](events.md) | Domain events: subscribing by type, why emission follows the commit, at-most-once delivery, and what an isolated subscriber failure does | | [persistence.md](persistence.md) | The metadata store: repositories, unit of work, table layout, migrations and `format_version` | | [examples.md](examples.md) | The six runnable examples: the whole cycle in one pass, ingest on its own, the same cycle driven three ways — over HTTP, from a shell, and over MCP stdio — and the thirty-minute flow that ends at a trainer loading the result, with what each is built to demonstrate | diff --git a/docs/content/cli.md b/docs/content/cli.md index 3cfdecc2..1b81b2d2 100644 --- a/docs/content/cli.md +++ b/docs/content/cli.md @@ -35,8 +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 --target T|--format F --out DIR [--allow-lossy] +visionset export --project P --release TAG --target T|--format F --out DIR [--allow-lossy] [--recipe NAME] visionset export --project P --release TAG --target T|--format F --check # writes nothing; exit 1 = it loses something +visionset recipe create NAME --project P --spec FILE | --resize letterbox:640x640 --augment hflip --variants 2 --target T +visionset recipe list|show NAME|update NAME|delete NAME --project P visionset format list # no --workspace: it opens nothing visionset target list # the models a release can be exported for @@ -459,6 +461,27 @@ means what it looks like. The table is on **stdout** and the summary on stderr, classes and nothing else; `--json` prints `visionset.wire.export_compatibility`, the same document the REST route and the MCP tool publish. +### `visionset recipe`, and `export --recipe` + +`recipe create NAME --project P` stores a [pre-processing recipe](preprocessing.md) under a name, +and `recipe list`, `recipe show NAME`, `recipe update NAME` and `recipe delete NAME` do what they +say; `--json` shapes are the wire's, and `recipe show --json` prints under `spec` exactly what +`--spec FILE` reads back. **Two ways to say what a recipe does, never both**: `--spec FILE` for +a script, or the flag form at a prompt - `--resize letterbox:640x640` (`--pad` for the grey), +`--augment hflip,brightness_contrast` (`--amount` for how far), `--variants 2`, `--target yolo11`. +Mixing the file with a flag, or giving neither, is a usage error at exit 2, and so is a spec +that breaks the recipe grammar - the rule is named. `recipe update` replaces the spec whole and +takes `--rename`; `recipe delete` asks nothing, because every export that used the recipe kept +its own copy. + +`export --recipe NAME` applies one: the recipe is resolved through the release's project, every +image is resized as it says, and augmented variants of the train-fold images are written beside +their sources. `--check` with a recipe also refuses now what the export would refuse - +augmentation over a release published without a split, or a step that cannot move a geometry +the release carries - at exit 1 with the reason. The human output adds one line naming the +source and augmented counts; `--json` carries them as `source_file_count` and +`augmented_file_count`, with the recipe under `preprocessing`. + ### `visionset backfill-thumbnails` `--project P` → `IngestService.backfill_thumbnails`. Renders the previews of assets that have none - diff --git a/docs/content/mcp.md b/docs/content/mcp.md index 34e2a3ab..332ee2e8 100644 --- a/docs/content/mcp.md +++ b/docs/content/mcp.md @@ -192,8 +192,10 @@ call until the end. #439 has since added a job gate, but it changes none of this | `verify_release` | Re-hash every blob a release names. | | `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. | +| `check_export` | What a target or a format would drop from a release, before writing anything. Exactly one of `target` and `format`; `recipe` also checks that the recipe can run. | +| `export_release` | Write a release to a local directory, for a target or in a format. `allow_lossy` where needed; `recipe` applies a pre-processing recipe by name. | +| `create_preprocessing_recipe` | Store a named [pre-processing recipe](preprocessing.md) on a project: a resize step, augmentation steps, and how many variants each train image gets. | +| `list_preprocessing_recipes` | A project's recipes, each with its whole spec. `name` is what `export_release` takes as `recipe`. | ### Inference connections @@ -224,6 +226,7 @@ ask and is refused with `INFERENCE_CONNECTION_NOT_TESTABLE`. | `delete_batch` | **Destructive.** Removes a batch, its task groups, its jobs and the per-asset progress on them. The **annotations survive** - labels hang off assets, not off batches - and so do the assets themselves. A `completed` batch is refused whatever `confirm` says. | | `delete_project` | **Destructive.** Removes the project, its dataset, its batches, its jobs and its annotations. Requires `confirm: true` as well - the parameter is unchanged; what changed is that the tool is not in the listing unless somebody started the server for it. | | `delete_inference_connection` | **Destructive.** Removes a model connection's configuration and nothing else: annotations keep their model provenance (identity is copied at write time), and cached weights stay on disk. Requires `confirm: true`. | +| `delete_preprocessing_recipe` | **Destructive.** Removes a pre-processing recipe and nothing else: every export that used it kept its own copy of the spec. Requires `confirm: true`. | ## `get_asset_image`, and the coordinate frame @@ -293,7 +296,7 @@ Never merged into one, because they guard different things: | | guards | on | | --- | --- | --- | -| `confirm` | destroying data | `delete_project`, `delete_batch` | +| `confirm` | destroying data | `delete_project`, `delete_batch`, `delete_inference_connection`, `delete_preprocessing_recipe` | | `allow_destructive` | narrowing a contract | `create_schema_version`, `publish_schema_draft` | | `allow_lossy` | emitting an incomplete copy of something that stays intact | `export_release` | diff --git a/docs/content/preprocessing.md b/docs/content/preprocessing.md new file mode 100644 index 00000000..047b711a --- /dev/null +++ b/docs/content/preprocessing.md @@ -0,0 +1,183 @@ +# Pre-processing + +An optional stage between a release and the files a trainer reads: resize every exported image to +one size, and write augmented variants of the training images beside their sources. It is +declared as a **recipe** - a named project resource - and applied at export, by name, beside the +target. Releases and manifests are untouched: a release is still the frozen inventory it always +was, and a recipe is what one export chose to do with it. + +```python +from visionset.kernel.domain import AugmentOp, AugmentStep, RecipeSpec, ResizeStep, ResizeStrategy +from visionset.kernel.services import PreprocessingRecipeService, ReleaseService +from visionset.preprocessing.registry import drivers + +spec = RecipeSpec( + target="yolo11", + steps=( + ResizeStep(strategy=ResizeStrategy.LETTERBOX, width=640, height=640), + AugmentStep(op=AugmentOp.HFLIP), + AugmentStep(op=AugmentOp.BRIGHTNESS_CONTRAST, amount=0.2), + ), + variants_per_asset=2, +) +recipe = PreprocessingRecipeService(workspace).create(project.id, "yolo-640", spec) +ReleaseService(workspace).export( + release.id, exporter, dest, target=target, recipe=recipe.spec, recipe_name=recipe.name, + drivers=drivers(), +) +``` + +## A recipe is a value + +`RecipeSpec` is what an export snapshots: the steps, in order, and how many augmented variants +each training image gets. `PreprocessingRecipe` wraps one under a name so a project can list and +edit it. The two are deliberately separate, because **an export keeps the spec by value**: +editing or deleting a recipe after an export changes nothing about that export, and the export +report carries the spec it ran with, its hash, and the Pillow version that produced the bytes. + +There is no state on a recipe and no `allowed_actions` vocabulary. Nothing depends on the stored +value once an export has run, so every operation is always offered and every write is +unconditional - the one resource here that a schema draft's revision protocol does not apply to. + +`recipe_hash(spec)` is `sha256` over the spec's canonical bytes, the same encoder and digest the +manifest uses, so two exports carrying the same spec carry the same hash whatever order the +fields were written in. + +## The grammar + +| Step | Fields | What it does | +| --- | --- | --- | +| `resize` | `strategy` (`letterbox` or `stretch`), `width`, `height` (32 to 8192), `pad_value` (0 to 255, default 114) | Every exported image, base and variant, lands at `width × height`. `stretch` scales each axis on its own; `letterbox` keeps the aspect, scales to fit, and pads the rest with `pad_value` - the grey YOLO trainers letterbox with themselves. | +| `augment` | `op` (`hflip`, `brightness_contrast`, `rot90`), `amount` (0 to 0.5, default 0.2) | One augmentation applied when generating variants. `amount` bounds the brightness and contrast factors and means nothing to the other two. | + +Cross-field rules, refused with the rule named: at most one `resize` step, and it comes first; +each `op` at most once; an `augment` step needs `variants_per_asset` of at least 1; and +`variants_per_asset` (0 to 8) needs at least one `augment` step. `target` records which export +target's hints the recipe was written from and is informational - nothing resolves it, and a +recipe applies to any export. + +The hints come from the target: `GET /export-targets`, `visionset target list` and +`list_export_targets` carry `recommended_size`, `recommended_strategy`, `trainer_resizes` and +`augmentation_common` for each, and a surface preselects from them. Every YOLO target resizes on +its own, so pre-resizing is a choice about archive size and loading speed rather than a +requirement. + +## Where the geometry moves, and where the pixels do + +The kernel owns every coordinate an export writes. `transform_manifest` moves the annotations - +scale and offset for a resize, mirror for `hflip`, a quarter turn for `rot90`, nothing for +`brightness_contrast` - and the pixel driver moves the bytes, and the two agree because both read +the same arithmetic: `letterbox_fit` is the single spelling of where letterboxed content lands, +and the per-variant draws come from one seed. + +| step | bbox | polygon | polyline | classification_tag | +| --- | --- | --- | --- | --- | +| stretch | scale x, y | scale | scale | unchanged | +| letterbox | scale then offset | same | same | unchanged | +| hflip | `x' = W - x - w` | mirror points | mirror points, order kept | unchanged | +| brightness_contrast | unchanged | unchanged | unchanged | unchanged | +| rot90 | rotate corners, rebuilt axis-aligned | rotate points | **refused** | unchanged | + +A step declares what it can transform, and a geometry it cannot is a **typed refusal, never a +consent**: `PreprocessingStepUnsupportedGeometry` names the step, the geometry and the first asset +carrying it. Today that is `rot90` over a polyline, whose point order carries meaning relative +to the frame's axes. The lossy-consent path is for a format that cannot *carry* a label; a label +that cannot *follow its image* is not something to consent to. + +Drivers are plugins on the `PreprocessingDriver` port, discovered over the +`visionset.preprocessing` entry-point group the way exporters are, and the kernel takes driver +instances rather than names. The built-in pair uses Pillow, the only image dependency: a JPEG +comes back a JPEG at quality 95 with its chroma subsampling kept, a PNG a lossless PNG, and any +other encoding a PNG. No metadata travels. + +## Determinism, and its scope + +Variant `k` of an asset is seeded by `sha256(f"{recipe_hash}:{content_hash}:{k}")`, and every +draw - whether `hflip` mirrors, the brightness and contrast factors, how many quarter turns +`rot90` makes - reads a fixed position of that digest. The same recipe over the same bytes +therefore draws the same variant on any machine, and the geometry arithmetic is exact +everywhere. + +**Byte stability is promised within one environment only.** The pixels a resize or an +enhancement produces depend on the codec and resampling code that produced them, so two +machines with different Pillow builds can write different bytes for the same variant. The report +records `pillow_version` for exactly this reason, and a digest in the report's `mapping` is a +fact about one run rather than a promise about the next. + +## The train fold, and nothing else + +Augmented variants are generated **for the train fold only**: a model must never validate on a +variant of an image it trained on. The folds are the release's own split recipe over its frozen +manifest - the same cut `GET /releases/{id}/assignment` answers - so a variant lands exactly +where its source does. A recipe that augments therefore requires a release published with a +split, and one without is refused with `AugmentationRequiresSplit` at pre-flight and again at +export. Base images are written for every asset whatever its fold, resized when the recipe says +so. + +The transform runs **after** the export is narrowed to its target. A geometry the target's task +set does not accept is dropped first, consented through `allow_lossy` like every other drop, so +a polyline a boxes-only target would never write cannot make `rot90` refuse. Consent itself is +asked before any transform: a caller who has not accepted the loss is answered about the loss. + +## Naming, counting, and the report + +A base image keeps its original-hash-derived name; variant `k` is `-aug`, with its label +file named the same way beside it. Inside the kernel that key is what the export's content +reader resolves: the plugin sees one manifest asset per file to write, the base under its source +hash and each variant under its `-aug` key, sharing the source's `asset_id` so the plugin's own +fold lookup keeps it with its source. + +`ExportResult` separates what the release held from what the recipe added: `source_file_count` +and `augmented_file_count` count the images the plugin read and wrote, and the annotation pair +counts the labels the same way; `file_count` stays the total of everything written, labels and +descriptors included. `visionset-export-report.json` gains a `preprocessing` block - `null` for +an export that applied no recipe: + +```json +{ + "preprocessing": { + "recipe_name": "yolo-640", + "spec": { "target": "yolo11", "steps": [ … ], "variants_per_asset": 2 }, + "recipe_hash": "…", + "pillow_version": "12.0.0", + "mapping": [ + { "file": "images/train/.jpg", "source_content_hash": "", "exported_sha256": "…", "variant": 0 }, + { "file": "images/train/-aug1.jpg", "source_content_hash": "", "exported_sha256": "…", "variant": 1 } + ] + } +} +``` + +Every row of `mapping` traces a written image to the manifest asset it came from; a plugin that +names its images after the key it read them under is matched by name, and one that names them +its own way is matched by digest. + +## Previewing a recipe + +`POST /projects/{id}/preprocessing-preview` takes a spec, an asset and a variant index and answers +the image and its placed annotations, rendered on the export's own kernel path over a one-asset +manifest with the asset in the train fold - so every variant a spec declares can be looked at +whether or not a release exists. The image is capped to 512 pixels on its longer side, labels +scaled to match, and the response is never cached: the spec is the request's own. + +## Refusals + +| Error | When | +| --- | --- | +| `PreprocessingRecipeNotFound` | The project has no recipe under that name. 404. | +| `PreprocessingRecipeNameTaken` | Another recipe of the project carries that name, or a rename lands on one. Checked before writing and refused by a unique index, the `ReleaseTagTaken` shape. 409. | +| `InvalidName` | The name is not a slug: lowercase letters, digits, dots, hyphens and underscores, starting with a letter or digit, at most 64 characters. 422. | +| `AugmentationRequiresSplit` | The recipe augments and the release was published without a split recipe. Raised at pre-flight and at export. 409. | +| `PreprocessingStepUnsupportedGeometry` | A step met a geometry it cannot transform - `rot90` over a polyline. Carries `step`, `geometry` and the first `asset_id`. 409. | +| `ExportSourceUnreadable` | A step needs the source's pixel size and the manifest never recorded one, or the asset's bytes are gone. 409. | +| `PreprocessingDriverNotFound` | No installed driver applies a step kind the recipe holds. A fact about the installation, not the request. 500. | + +## On every surface + +| | | +| --- | --- | +| REST | `POST`/`GET /projects/{id}/preprocessing-recipes`, `GET`/`PUT`/`DELETE /projects/{id}/preprocessing-recipes/{name}`, `POST /projects/{id}/preprocessing-preview`; `recipe=` on `POST /releases/{id}/export` and `GET /releases/{id}/export-compatibility`. The job carries the recipe as a snapshot. | +| CLI | `visionset recipe create NAME -p P --spec FILE` or `--resize letterbox:640x640 --augment hflip,brightness_contrast --variants 2 --target yolo11`; `recipe list`, `show`, `update`, `delete`; `export --recipe NAME`. See [cli.md](cli.md#visionset-release-and-visionset-export). | +| MCP | `create_preprocessing_recipe`, `list_preprocessing_recipes`, `delete_preprocessing_recipe` (only with `--allow-destructive`); `recipe` on `export_release` and `check_export`. See [mcp.md](mcp.md#datasets-releases-and-export). | + +The export half is described with the rest of exporting in [releases.md](releases.md#exporting). diff --git a/docs/content/releases.md b/docs/content/releases.md index 0ed20b2c..12af8a49 100644 --- a/docs/content/releases.md +++ b/docs/content/releases.md @@ -308,6 +308,21 @@ 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. +### A recipe at export + +`ReleaseService.export(..., recipe=, recipe_name=, drivers=)` applies a [pre-processing +recipe](preprocessing.md): every image is resized as the recipe says, and augmented variants of +the train-fold images are written beside their sources as `-aug`. The recipe is a +value the export snapshots - the report carries the spec it ran with, its hash and the Pillow +version - so editing or deleting the stored recipe afterwards changes nothing here. The narrowing +above runs first and the recipe runs over what is left, so a geometry the target drops can never +make a step refuse; consent is asked before any transform. `check_export(..., recipe=)` refuses +now what the export would refuse: `AugmentationRequiresSplit` for an augmenting recipe over a +release published without a split, and `PreprocessingStepUnsupportedGeometry` for a step that +cannot move a geometry the export would carry. `ExportResult` separates `source_file_count` from +`augmented_file_count`, and `preprocessing` on the report maps every written image to the asset +it came from. + ### Export targets The user-facing unit of export is a **target**: the model the release will train. A target @@ -656,6 +671,7 @@ 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 export --project road-signs --release v1.0 --target yolo11 --recipe yolo-640 --out ./out visionset target list ``` @@ -739,8 +755,8 @@ 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?target=|format= → 200 ExportCompatibilityOut -POST /releases/{id}/export?target=|format=&allow_lossy= → 202 BackgroundJobOut +GET /releases/{id}/export-compatibility?target=|format=&recipe= → 200 ExportCompatibilityOut +POST /releases/{id}/export?target=|format=&allow_lossy=&recipe= → 202 BackgroundJobOut GET /formats → 200 FormatPage GET /export-targets → 200 ExportTargetPage ``` @@ -837,6 +853,10 @@ carries the identical body under `detail.compatibility`. | `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. | +| `PreprocessingRecipeNotFound` | The release's project has no recipe under the name the export was given. Resolved through the project the release's dataset hangs off. | +| `AugmentationRequiresSplit` | The recipe asks for augmented variants and the release was published without a split recipe, so there is no train fold to augment. Raised at pre-flight and again at export. | +| `PreprocessingStepUnsupportedGeometry` | A recipe step cannot transform a geometry the export would carry - `rot90` over a polyline. A refusal, never a consent: a label that cannot follow its image is not something to consent to. Carries the step, the geometry and the first asset. | +| `PreprocessingDriverNotFound` | No installed driver applies a step kind the recipe holds. A fact about the installation rather than the request, 500 over HTTP; the message lists the kinds that are installed. | ## In the browser diff --git a/docs/src/sidebar.mjs b/docs/src/sidebar.mjs index 78f56c68..f4382d04 100644 --- a/docs/src/sidebar.mjs +++ b/docs/src/sidebar.mjs @@ -81,6 +81,7 @@ export const sidebar = [ { slug: "inference" }, { slug: "datasets" }, { slug: "releases" }, + { slug: "preprocessing" }, { slug: "events" }, { slug: "background-jobs" }, { slug: "persistence" }, From 6f3ae5e254dac959523ec17fd663ecf6127d0ef7 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:49:18 -0700 Subject: [PATCH 7/9] docs: format the pre-processing example the way ruff wants a fenced block --- docs/content/preprocessing.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/content/preprocessing.md b/docs/content/preprocessing.md index 047b711a..568e2196 100644 --- a/docs/content/preprocessing.md +++ b/docs/content/preprocessing.md @@ -22,7 +22,12 @@ spec = RecipeSpec( ) recipe = PreprocessingRecipeService(workspace).create(project.id, "yolo-640", spec) ReleaseService(workspace).export( - release.id, exporter, dest, target=target, recipe=recipe.spec, recipe_name=recipe.name, + release.id, + exporter, + dest, + target=target, + recipe=recipe.spec, + recipe_name=recipe.name, drivers=drivers(), ) ``` From 642a111c1778395725f4f1470ead6bdaad1318f6 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:15:45 -0700 Subject: [PATCH 8/9] test(architecture): the generated contract ceilings follow the API's measured growth --- tests/architecture/test_tracked_file_sizes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/architecture/test_tracked_file_sizes.py b/tests/architecture/test_tracked_file_sizes.py index b884551d..a9be3675 100644 --- a/tests/architecture/test_tracked_file_sizes.py +++ b/tests/architecture/test_tracked_file_sizes.py @@ -25,18 +25,20 @@ # The API contract, regenerated by scripts/export_openapi.py and gated against drift in CI. # Same shape of exemption as uv.lock: text, machine-generated, and its size is a function of # how many operations the API has rather than of anything anybody chose. It crossed the - # default limit at 53 operations, running ~4 KB apiece; this ceiling is roughly 120 more. + # default limit at 53 operations, running ~4 KB apiece, and crossed 512 KiB at 99 operations + # once the published docstrings enumerated every refusal (~5.4 KB apiece); this ceiling is + # roughly 45 more at that rate. # # It is a *ceiling*, not a licence. If this one is ever hit the question is whether the API # really grew that much, and the answer is not another zero here. - "openapi.json": 512 * 1024, + "openapi.json": 768 * 1024, # The typed client, regenerated by scripts/generate_client.mjs from openapi.json and gated # against drift in CI. Third of the same shape: text, machine-generated, and a function of how # many operations the API has. Listed at 194 KB — *inside* the default limit by 6 KB, which is # about one endpoint. Waiting for it to trip would mean the next surface task discovering a # media guard it has nothing to do with, so the exemption is taken deliberately now rather # than under duress later. Same ceiling as the spec it is derived from. - "frontend/ui-core/src/generated/api.ts": 512 * 1024, + "frontend/ui-core/src/generated/api.ts": 768 * 1024, # The frontend workspace's resolved dependency graph — the pnpm counterpart of uv.lock, and # exempted on the same terms: text, machine-generated, and being large is the file's job. It # lived under the default limit until the shadcn preset foundation brought its dependencies From 64d6cb6cb489521f81bb4dd8596430b152af0980 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:46:12 -0700 Subject: [PATCH 9/9] fix(kernel): the export mapping picks the sibling whose digest matches, not the last one walked --- .../kernel/services/release_service.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/visionset/kernel/services/release_service.py b/src/visionset/kernel/services/release_service.py index c1591d2f..60e7ce88 100644 --- a/src/visionset/kernel/services/release_service.py +++ b/src/visionset/kernel/services/release_service.py @@ -940,17 +940,30 @@ def mapping(self, dest: Path, written: list[Path]) -> tuple[ExportFileMapping, . that read and did not write — is left out rather than guessed. """ rows: dict[str, ExportFileMapping] = {} - by_stem = {path.stem: path for path in written} + by_stem: dict[str, list[Path]] = {} + for path in written: + by_stem.setdefault(path.stem, []).append(path) + claimed: set[Path] = set() unmatched: dict[str, _Produced] = {} for key, produced in self._produced.items(): if produced.exported_sha256 is None: continue - if (path := by_stem.get(key)) is not None: - rows[key] = _mapping_row(dest, path, produced) + # A label file shares the image's stem, so the stem alone is + # ambiguous; the digest says which sibling holds the pixels. + match = next( + ( + candidate + for candidate in by_stem.get(key, ()) + if sha256_hex(candidate.read_bytes()) == produced.exported_sha256 + ), + None, + ) + if match is not None: + rows[key] = _mapping_row(dest, match, produced) + claimed.add(match) else: unmatched[produced.exported_sha256] = produced if unmatched: - claimed = {path for path in (by_stem.get(key) for key in rows) if path is not None} for path in written: if path in claimed: continue