From 01136b17ea0e110e036b72e1b7a7312c01e11b29 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:55:19 -0700 Subject: [PATCH 1/5] fix(formats): yolov5 trains segment and classify as well as detect --- docs/content/releases.md | 2 +- src/visionset/formats/ultralytics/__init__.py | 7 ++- tests/formats/test_registry.py | 60 ++++++++++++++++++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/docs/content/releases.md b/docs/content/releases.md index 8bbe328b..411aeb26 100644 --- a/docs/content/releases.md +++ b/docs/content/releases.md @@ -356,7 +356,7 @@ The catalog this build ships, generated from the declarations by | `yolo26` | YOLO26 | `ultralytics-yolo` | `ultralytics` | classify, depth, detect, obb, pose, segment, semantic | bbox, classification_tag, polygon | 640×640 | letterbox | | `yolov10` | YOLOv10 | `ultralytics-yolo` | `ultralytics` | detect | bbox | 640×640 | letterbox | | `yolov3` | YOLOv3 | `ultralytics-yolo` | `ultralytics` | detect | bbox | 640×640 | letterbox | -| `yolov5` | YOLOv5 | `ultralytics-yolo` | `ultralytics` | detect | bbox | 640×640 | letterbox | +| `yolov5` | YOLOv5 | `ultralytics-yolo` | `ultralytics` | classify, detect, segment | bbox, classification_tag, polygon | 640×640 | letterbox | | `yolov6` | YOLOv6 | `ultralytics-yolo` | `ultralytics` | detect | bbox | 640×640 | letterbox | | `yolov7` | YOLOv7 | `community-yolo` | `yolov5-yaml` | detect | bbox | 640×640 | letterbox | | `yolov8` | YOLOv8 | `ultralytics-yolo` | `ultralytics` | classify, detect, obb, pose, segment | bbox, classification_tag, polygon | 640×640 | letterbox | diff --git a/src/visionset/formats/ultralytics/__init__.py b/src/visionset/formats/ultralytics/__init__.py index 9f0e76b2..1bc64ea3 100644 --- a/src/visionset/formats/ultralytics/__init__.py +++ b/src/visionset/formats/ultralytics/__init__.py @@ -146,7 +146,12 @@ def _target( _EVERYTHING, ), _target("yolov6", "YOLOv6", frozenset({Task.DETECT}), frozenset({GeometryType.BBOX})), - _target("yolov5", "YOLOv5", frozenset({Task.DETECT}), frozenset({GeometryType.BBOX})), + _target( + "yolov5", + "YOLOv5", + frozenset({Task.DETECT, Task.SEGMENT, Task.CLASSIFY}), + _EVERYTHING, + ), _target("yolov3", "YOLOv3", frozenset({Task.DETECT}), frozenset({GeometryType.BBOX})), } ) diff --git a/tests/formats/test_registry.py b/tests/formats/test_registry.py index 0ee5e79c..7be74c6b 100644 --- a/tests/formats/test_registry.py +++ b/tests/formats/test_registry.py @@ -15,7 +15,14 @@ from visionset.formats._targets import self_target from visionset.formats.registry import exporter, exporters, pick -from visionset.kernel.domain import Annotation, GeometryType, Manifest, Release +from visionset.kernel.domain import ( + Annotation, + GeometryType, + Manifest, + Release, + TargetFamily, + Task, +) from visionset.kernel.errors import ExportFormatNotFound from visionset.kernel.ports import ContentReader @@ -68,6 +75,57 @@ def test_a_discovered_exporter_declares_its_targets() -> None: assert target.tasks == frozenset() +#: Every YOLO trainer this build addresses, with the tasks each accepts. The +#: catalog table in `docs/content/releases.md` is generated from the same +#: declarations, so a dropped or narrowed target would only ever show up +#: there as a diff nobody reads; this is the assertion that fails instead. +YOLO_TARGETS = { + "yolo26": { + Task.DETECT, + Task.SEGMENT, + Task.SEMANTIC, + Task.DEPTH, + Task.CLASSIFY, + Task.POSE, + Task.OBB, + }, + "yolo12": {Task.DETECT, Task.SEGMENT, Task.CLASSIFY, Task.POSE, Task.OBB}, + "yolo11": {Task.DETECT, Task.SEGMENT, Task.CLASSIFY, Task.POSE, Task.OBB}, + "yolov10": {Task.DETECT}, + "yolov9": {Task.DETECT, Task.SEGMENT}, + "yolov8": {Task.DETECT, Task.SEGMENT, Task.CLASSIFY, Task.POSE, Task.OBB}, + "yolov7": {Task.DETECT}, + "yolov6": {Task.DETECT}, + "yolov5": {Task.DETECT, Task.SEGMENT, Task.CLASSIFY}, + "yolov3": {Task.DETECT}, +} + + +def test_the_yolo_targets_are_exactly_these_ten_with_these_tasks() -> None: + declared = { + target.name: set(target.tasks) + for plugin in exporters().values() + for target in plugin.targets + if target.family is not TargetFamily.OTHER + } + + assert declared == YOLO_TARGETS + + +def test_a_yolo_target_carries_a_geometry_for_each_task_the_dialect_lays_out() -> None: + """``segment`` without polygons, or ``classify`` without tags, is a task no export can reach.""" + behind = { + Task.DETECT: GeometryType.BBOX, + Task.SEGMENT: GeometryType.POLYGON, + Task.CLASSIFY: GeometryType.CLASSIFICATION_TAG, + } + for plugin in exporters().values(): + for target in plugin.targets: + for task, geometry in behind.items(): + if task in target.tasks: + assert geometry in target.supported_geometries, (target.name, task) + + def test_every_installed_exporter_stays_discovered() -> None: """Discovery filters on the port, so one missing member silently drops a plugin from every surface — this is what would say which one.""" From 6c8026b46959610ac7ea19ee9ad1220e87c37f2f Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:56:53 -0700 Subject: [PATCH 2/5] feat(kernel): an exporter without a target is refused at scan --- docs/content/architecture/backend/formats.md | 10 +++++-- src/visionset/kernel/errors.py | 19 +++++++------ src/visionset/kernel/ports/exporter.py | 15 +++++++--- tests/formats/test_registry.py | 30 ++++++++++++++++++-- tests/kernel/test_export_seam.py | 18 +++++++++++- tests/kernel/test_export_target.py | 19 +++++++++++++ 6 files changed, 94 insertions(+), 17 deletions(-) diff --git a/docs/content/architecture/backend/formats.md b/docs/content/architecture/backend/formats.md index 75447f50..dd546edb 100644 --- a/docs/content/architecture/backend/formats.md +++ b/docs/content/architecture/backend/formats.md @@ -61,8 +61,14 @@ Beside them sits `targets`: the models the format writes for, each a frozen `ExportTarget` with its tasks, the geometries an export addressed to it carries, and the pre-processing hints a recipe editor preselects. A format that is not a trainer's declares one target named after itself, so every surface renders one control. The -registry validates the declarations at the scan - a target promising a geometry the -format never writes, or one name declared by two formats, is refused there - and the +registry validates the declarations at the scan, and three rules are enforced there +rather than at export time: an exporter declares at least one target, because a +format with none is installed yet unreachable through the one control a surface +renders; each target's geometries stay within the union of the exporter's +`supported_geometries` and `degraded_geometries`, so the catalog never promises a +file the format does not write; and a target name is declared by exactly one +installed format, so resolving it is never a guess. The first two refuse with +`InvalidExportTarget` and the third with `ExportTargetConflict`, and the kernel derives the catalog `GET /export-targets`, `visionset target list` and `list_export_targets` all render. [`docs/content/releases.md`](../../releases.md#export-targets) carries the catalog and the narrowing rule. diff --git a/src/visionset/kernel/errors.py b/src/visionset/kernel/errors.py index c594fc86..5f19aa46 100644 --- a/src/visionset/kernel/errors.py +++ b/src/visionset/kernel/errors.py @@ -811,14 +811,17 @@ class ExportTargetConflict(VisionSetError): class InvalidExportTarget(VisionSetError): - """An exporter declares a target it cannot deliver. - - A target's ``supported_geometries`` must stay within the declaring - exporter's own, because the target is a promise about that exporter's - output — one claiming a geometry the exporter never writes would make the - catalog describe files that do not appear. Raised by ``validate_targets``, - which is a check on the *declaration*: nothing about the caller's request - is wrong, the installed plugin is. + """An exporter's target declaration is defective. + + Either it declares no target at all — the target control is the one + gesture every surface renders, so such a format is installed yet + unreachable — or a target's ``supported_geometries`` reach outside the + declaring exporter's own. The second matters because the target is a + promise about that exporter's output, and one claiming a geometry the + exporter never writes would make the catalog describe files that do not + appear. Raised by ``validate_targets``, which is a check on the + *declaration*: nothing about the caller's request is wrong, the installed + plugin is. """ diff --git a/src/visionset/kernel/ports/exporter.py b/src/visionset/kernel/ports/exporter.py index 41fec146..f3496b88 100644 --- a/src/visionset/kernel/ports/exporter.py +++ b/src/visionset/kernel/ports/exporter.py @@ -170,8 +170,10 @@ def export( def validate_targets(exporter: Exporter) -> None: """Check an exporter's target declarations against the exporter itself. - Every target's ``supported_geometries`` must stay within what the exporter - writes at all — its ``supported_geometries`` and its + There must be at least one, because the target control is the one gesture + every surface renders and an exporter declaring none would be installed + yet unreachable. Every target's ``supported_geometries`` must stay within + what the exporter writes at all — its ``supported_geometries`` and its ``degraded_geometries`` together — so a defective declaration is refused where it can be named rather than surfacing as a catalog entry whose exports are missing what it promised. Degraded counts because a target @@ -179,9 +181,14 @@ def validate_targets(exporter: Exporter) -> None: lane resampled still has a target that carries lanes. Raises: - InvalidExportTarget: a target claims a geometry the exporter does not - write. + InvalidExportTarget: the exporter declares no target, or a target + claims a geometry the exporter does not write. """ + if not exporter.targets: + raise InvalidExportTarget( + f"format {exporter.format_name!r} declares no export target, so nothing " + f"can address it; declare at least one" + ) written = exporter.supported_geometries | exporter.degraded_geometries for target in exporter.targets: undeliverable = target.supported_geometries - written diff --git a/tests/formats/test_registry.py b/tests/formats/test_registry.py index 7be74c6b..dc1bfd68 100644 --- a/tests/formats/test_registry.py +++ b/tests/formats/test_registry.py @@ -9,6 +9,7 @@ from __future__ import annotations from collections.abc import Iterable +from importlib.metadata import EntryPoint from pathlib import Path import pytest @@ -17,14 +18,15 @@ from visionset.formats.registry import exporter, exporters, pick from visionset.kernel.domain import ( Annotation, + ExportTarget, GeometryType, Manifest, Release, TargetFamily, Task, ) -from visionset.kernel.errors import ExportFormatNotFound -from visionset.kernel.ports import ContentReader +from visionset.kernel.errors import ExportFormatNotFound, InvalidExportTarget +from visionset.kernel.ports import ContentReader, Exporter class _AnImporter: @@ -237,6 +239,30 @@ def export( assert not isinstance(_Outdated(), Exporter) +class _Targetless(_AnExporter): + """Carries every member of the port, and declares nothing under ``targets``.""" + + format_name = "targetless" + targets: frozenset[ExportTarget] = frozenset() + + +def test_a_plugin_declaring_no_target_is_refused_at_the_scan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The port's ``isinstance`` filter cannot see an empty set; the scan's validation must.""" + from visionset.formats import registry + + assert isinstance(_Targetless(), Exporter) + shipped = tuple(registry.entry_points(group="visionset.formats")) + defective = EntryPoint( + name="targetless", value=f"{__name__}:_Targetless", group="visionset.formats" + ) + monkeypatch.setattr(registry, "entry_points", lambda *, group: (*shipped, defective)) + + with pytest.raises(InvalidExportTarget, match="'targetless'"): + registry.exporters() + + def test_a_plugin_missing_the_targets_member_is_not_an_exporter() -> None: """An exporter with no target would be a format no target control can reach. diff --git a/tests/kernel/test_export_seam.py b/tests/kernel/test_export_seam.py index 33b6e5c1..fd39bc67 100644 --- a/tests/kernel/test_export_seam.py +++ b/tests/kernel/test_export_seam.py @@ -79,7 +79,23 @@ class ImageWriter: supported_geometries = frozenset(GeometryType) degraded_geometries: frozenset[GeometryType] = frozenset() supported_modalities = frozenset({"image"}) - targets: frozenset[ExportTarget] = frozenset() + targets = frozenset( + { + ExportTarget( + name="image-writer", + label="image-writer", + family=TargetFamily.OTHER, + tasks=frozenset(), + supported_geometries=frozenset(GeometryType), + hints=PreprocessingHints( + recommended_size=None, + recommended_strategy=None, + trainer_resizes=True, + augmentation_common=False, + ), + ) + } + ) def __init__(self) -> None: self.handed: Manifest | None = None diff --git a/tests/kernel/test_export_target.py b/tests/kernel/test_export_target.py index 291da67e..4273e046 100644 --- a/tests/kernel/test_export_target.py +++ b/tests/kernel/test_export_target.py @@ -187,6 +187,25 @@ def test_a_target_declared_twice_is_a_conflict_naming_both_formats() -> None: assert "b-format" in str(refusal.value) +def test_an_exporter_declaring_no_target_is_refused_by_name() -> None: + """A format nothing can address is a defect in the declaration, not a caller's error.""" + plugin = _Format("a-format", frozenset({GeometryType.BBOX}), frozenset()) + + with pytest.raises(InvalidExportTarget) as refusal: + validate_targets(plugin) + + assert "'a-format'" in str(refusal.value) + assert "no export target" in str(refusal.value) + + +def test_the_installed_set_refuses_an_exporter_declaring_no_target() -> None: + sound = _Format("a-format", frozenset({GeometryType.BBOX}), frozenset({_target("a")})) + targetless = _Format("b-format", frozenset({GeometryType.BBOX}), frozenset()) + + with pytest.raises(InvalidExportTarget, match="'b-format'"): + validate_installed({"a-format": sound, "b-format": targetless}) + + def test_a_target_within_its_exporter_validates() -> None: plugin = _Format( "a-format", From a5f70319e3f99de5c4186b94ee8ee45301a09eef Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:58:06 -0700 Subject: [PATCH 3/5] test(formats): task derivation follows the target, not the dialect --- tests/formats/test_ultralytics.py | 84 ++++++++++++++++++++++++++- tests/formats/test_yolo_writer.py | 94 +++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 tests/formats/test_yolo_writer.py diff --git a/tests/formats/test_ultralytics.py b/tests/formats/test_ultralytics.py index a9c27160..1b33c55b 100644 --- a/tests/formats/test_ultralytics.py +++ b/tests/formats/test_ultralytics.py @@ -24,12 +24,13 @@ import pytest from tests.fixtures.media import write_image -from visionset.formats.ultralytics import DATA_FILENAME, UltralyticsExporter +from visionset.formats.ultralytics import DATA_FILENAME, TARGETS, UltralyticsExporter from visionset.kernel import ExportSourceUnreadable from visionset.kernel.domain import ( Annotation, BboxGeometry, ClassificationGeometry, + ExportTarget, GeometryType, LabelClass, PolygonGeometry, @@ -120,8 +121,10 @@ def publish(self, tag: str = "v1", *, split: SplitRecipe | None = None) -> UUID: dataset_id = self.projects.get_dataset(self.project.id).id return self.releases.publish(dataset_id, tag, split=split).id - def export(self, release_id: UUID, dest: Path) -> Path: - self.releases.export(release_id, UltralyticsExporter(), dest, allow_lossy=True) + def export(self, release_id: UUID, dest: Path, *, target: ExportTarget | None = None) -> Path: + self.releases.export( + release_id, UltralyticsExporter(), dest, allow_lossy=True, target=target + ) return dest def close(self) -> None: @@ -359,6 +362,81 @@ def test_a_release_holding_only_tags_is_written_as_a_class_tree(tmp_path: Path) assert len(list((out / "train" / "time-of-day").iterdir())) == 1 +def _target(name: str) -> ExportTarget: + (found,) = (one for one in TARGETS if one.name == name) + return found + + +def test_a_tags_only_release_addressed_to_a_detect_target_is_not_a_class_tree( + tmp_path: Path, +) -> None: + """The task follows the target, not the dialect. + + The dialect can lay out ``classify``, but ``yolov10`` has no such task and + carries no tag, so the service hands the plugin a manifest with no tag in + it and the export is the detect layout with nothing on its images. + """ + fixture = Fixture(tmp_path) + fixture.label({0: [_tag()], 1: [_tag()]}) + out = fixture.export(fixture.publish(), tmp_path / "out", target=_target("yolov10")) + fixture.close() + + assert (out / DATA_FILENAME).exists() + assert not (out / "train").exists() + labels = sorted((out / "labels" / "train").iterdir()) + assert len(labels) == 3 + assert all(path.read_text(encoding="utf-8") == "" for path in labels) + + +def test_a_polygon_release_addressed_to_a_detect_target_is_written_as_detect( + tmp_path: Path, +) -> None: + """A polygon selects ``segment`` only when the target carries it; ``yolov10`` does not.""" + fixture = Fixture(tmp_path) + lane = Annotation( + asset_id=uuid4(), + label_class="lane", + schema_version=1, + geometry=PolygonGeometry(points=[(8.0, 12.0), (24.0, 12.0), (16.0, 36.0)]), + provenance="human", + ) + fixture.label({0: [lane, _box(x=8, y=12, width=16, height=24)]}) + out = fixture.export(fixture.publish(), tmp_path / "out", target=_target("yolov10")) + fixture.close() + + rows = [ + path.read_text(encoding="utf-8") + for path in sorted((out / "labels" / "train").iterdir()) + if path.read_text(encoding="utf-8") + ] + (written,) = rows + assert written.splitlines() == ["0 0.250000 0.500000 0.250000 0.500000"] + + +def test_a_polygon_release_addressed_to_a_segment_target_keeps_its_vertices( + tmp_path: Path, +) -> None: + fixture = Fixture(tmp_path) + lane = Annotation( + asset_id=uuid4(), + label_class="lane", + schema_version=1, + geometry=PolygonGeometry(points=[(8.0, 12.0), (24.0, 12.0), (16.0, 36.0)]), + provenance="human", + ) + fixture.label({0: [lane]}) + out = fixture.export(fixture.publish(), tmp_path / "out", target=_target("yolov5")) + fixture.close() + + rows = [ + path.read_text(encoding="utf-8") + for path in sorted((out / "labels" / "train").iterdir()) + if path.read_text(encoding="utf-8") + ] + (written,) = rows + assert written.splitlines() == ["1 0.125000 0.250000 0.375000 0.250000 0.250000 0.750000"] + + def test_a_class_that_cannot_name_a_directory_is_refused_by_name(tmp_path: Path) -> None: fixture = Fixture(tmp_path) fixture.schemas.create_version( diff --git a/tests/formats/test_yolo_writer.py b/tests/formats/test_yolo_writer.py new file mode 100644 index 00000000..2e4f3792 --- /dev/null +++ b/tests/formats/test_yolo_writer.py @@ -0,0 +1,94 @@ +"""``derive_task``: the one task an export is written for, from what is present and accepted. + +The manifest handed in is already narrowed to the target — ``ReleaseService`` +removes what the target does not carry before the plugin sees it — so the +``accepted`` set here is the *dialect's*, and the narrowing shows up as an +absence in the manifest rather than as a smaller set. Both halves are pinned: +the geometry that selects a task, and the acceptance that lets it. +""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest + +from visionset.formats._yolo_writer import derive_task +from visionset.kernel.domain import ( + BboxGeometry, + ClassificationGeometry, + Geometry, + GeometryType, + LabelClass, + Manifest, + ManifestAnnotation, + ManifestAsset, + PolygonGeometry, + Task, +) + +EVERY_TASK = frozenset({Task.DETECT, Task.SEGMENT, Task.CLASSIFY}) + +BOX = BboxGeometry(x=1.0, y=2.0, width=3.0, height=4.0) +POLYGON = PolygonGeometry(points=[(0.0, 0.0), (4.0, 0.0), (2.0, 3.0)]) +TAG = ClassificationGeometry() + + +def _manifest(*geometries: Geometry) -> Manifest: + annotations = tuple( + ManifestAnnotation( + id=uuid4(), + label_class="thing", + schema_version=1, + geometry=geometry, + provenance="human", + ) + for geometry in geometries + ) + return Manifest( + schema_version=1, + classes=(LabelClass(name="thing", geometries=tuple(GeometryType)),), + assets=( + ManifestAsset( + asset_id=uuid4(), + content_hash="0" * 64, + uri="/incoming/frame.png", + width=8, + height=8, + annotations=annotations, + ), + ), + ) + + +@pytest.mark.parametrize( + ("present", "expected"), + [ + ((), Task.DETECT), + ((BOX,), Task.DETECT), + ((POLYGON,), Task.SEGMENT), + ((BOX, POLYGON), Task.SEGMENT), + ((TAG,), Task.CLASSIFY), + ((TAG, BOX), Task.DETECT), + ((TAG, POLYGON), Task.SEGMENT), + ], +) +def test_the_geometry_present_selects_the_task( + present: tuple[Geometry, ...], expected: Task +) -> None: + assert derive_task(_manifest(*present), EVERY_TASK) is expected + + +def test_a_polygon_is_detect_when_segment_is_not_accepted() -> None: + assert derive_task(_manifest(POLYGON), frozenset({Task.DETECT})) is Task.DETECT + + +def test_a_tag_is_detect_when_classify_is_not_accepted() -> None: + assert derive_task(_manifest(TAG), frozenset({Task.DETECT})) is Task.DETECT + + +def test_a_polygon_beside_a_tag_is_classify_only_when_segment_is_not_accepted() -> None: + accepted = frozenset({Task.DETECT, Task.CLASSIFY}) + + assert derive_task(_manifest(TAG, POLYGON), accepted) is Task.DETECT + assert derive_task(_manifest(TAG), accepted) is Task.CLASSIFY From 584acbd7395f78f418c0bf3cbc944adff4548799 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:59:55 -0700 Subject: [PATCH 4/5] test(server,mcp): the yolo alias is accepted on REST and MCP without a warning --- docs/content/api.md | 7 +++++++ docs/content/releases.md | 8 ++++++-- tests/mcp/test_release_tools.py | 27 +++++++++++++++++++++++++++ tests/server/test_releases.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/docs/content/api.md b/docs/content/api.md index 7ab679c3..bb61e04e 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -340,6 +340,13 @@ GET /background-jobs/{job_id} → 200 { "state": "running", "proce GET /background-jobs/{job_id}/artifact → 200 application/zip ``` +`format=yolo`, the former name of `ultralytics`, is still accepted on this route and on +`export-compatibility` until the release after next: it resolves to the same plugin, the job's +`result.format` and the compatibility report's `format` read `ultralytics`, and no deprecation +text appears anywhere in the response. Only the CLI warns, on stderr. The MCP `export_release` +tool behaves as this route does. Address the export by `target` where you can; the alias is a +grace period, not a second name. + The two surfaces are separate because they describe different things. An ingest job knows what it is *about* - a source, a batch - and publishes those as fields a client can navigate. A background job is about whatever its payload says, so it publishes `type` and `result` instead. What they diff --git a/docs/content/releases.md b/docs/content/releases.md index 411aeb26..47813115 100644 --- a/docs/content/releases.md +++ b/docs/content/releases.md @@ -392,8 +392,12 @@ Two formats write a YOLO dataset, and they differ only in the grammar of `data.y *dialect*: the wire identifier `format_name` names the descriptor grammar, and the model a person will train - the *target* - resolves to exactly one dialect. `ultralytics` is what every trainer from YOLOv3 to YOLO26 in the Ultralytics line reads; `yolov5-yaml` is the older grammar YOLOv7 -reads. `yolo`, the former name of `ultralytics`, is accepted as an alias for one release and then -removed; `visionset export --format yolo` says so on stderr and continues. +reads. `yolo`, the former name of `ultralytics`, is accepted as an alias until the release after +next, and then removed. Every surface resolves it to the same plugin and reports `format_name` as +`ultralytics`; only the CLI warns, because `visionset export --format yolo` has a stderr to say +so on. `POST /releases/{id}/export?format=yolo` and `export_release(format="yolo")` accept it +silently, the response carrying no deprecation text at all - a warning has no field to land in +on a 202 or in a tool result. The layout both share: diff --git a/tests/mcp/test_release_tools.py b/tests/mcp/test_release_tools.py index 78219770..8b4e18ea 100644 --- a/tests/mcp/test_release_tools.py +++ b/tests/mcp/test_release_tools.py @@ -352,6 +352,33 @@ def test_an_export_can_be_addressed_to_a_target( assert written["target"] == "dummy" +def test_the_former_yolo_name_exports_through_ultralytics_without_a_warning( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The alias resolves to the plugin installed under its current name, and the + result says nothing about it: the CLI is the one surface that warns.""" + named = promoted(monkeypatch, tmp_path, count=1) + payload(call("publish_release", project=named, tag="v1.0")) + dest = tmp_path / "exports" / "yolo" + + outcome = call( + "export_release", + project=named, + tag="v1.0", + format="yolo", + allow_lossy=True, + dest=str(dest), + ) + result = payload(outcome) + + assert (result["format"], result["target"]) == ("ultralytics", None) + assert result["compatibility"]["format"] == "ultralytics" + assert (dest / "data.yaml").is_file() + rendered = outcome.model_dump_json().lower() + assert "deprecat" not in rendered + assert "warning" not in rendered + + @pytest.mark.parametrize("tool", ["check_export", "export_release"]) @pytest.mark.parametrize( "address", [{}, {"target": "dummy", "format": "dummy"}], ids=["neither", "both"] diff --git a/tests/server/test_releases.py b/tests/server/test_releases.py index 05a05ed9..c6762062 100644 --- a/tests/server/test_releases.py +++ b/tests/server/test_releases.py @@ -44,6 +44,7 @@ ) from tests.server._jobs import InlineDispatcher +from visionset.formats._targets import self_target from visionset.kernel.domain import MANIFEST_VERSION from visionset.kernel.services.release_service import EXPORT_REPORT_FILENAME @@ -450,6 +451,36 @@ def test_an_export_can_be_addressed_to_a_target_instead_of_a_format( } +class _UltralyticsNamed(WritingExporter): + """What the alias resolves to: the plugin installed under the current name.""" + + format_name = "ultralytics" + targets = self_target(format_name, WritingExporter.supported_geometries) + + +def test_the_former_yolo_name_exports_through_ultralytics_without_a_warning( + client: TestClient, release: str +) -> None: + """The alias is honoured silently here: a warning has nowhere to go on a 202.""" + with_exporters(client.app, _UltralyticsNamed()) + + launched = client.post(f"/releases/{release}/export", params={"format": "yolo"}) + assert launched.status_code == 202, launched.text + settled = client.get(f"/background-jobs/{launched.json()['id']}").json() + + assert settled["state"] == "succeeded", settled + assert settled["result"]["format"] == "ultralytics" + assert settled["result"]["target"] is None + for body in (launched.text, json.dumps(settled)): + assert "deprecat" not in body.lower() + assert "warning" not in body.lower() + compatibility = client.get( + f"/releases/{release}/export-compatibility", params={"format": "yolo"} + ) + assert compatibility.status_code == 200, compatibility.text + assert compatibility.json()["format"] == "ultralytics" + + def test_both_target_and_format_is_422_and_so_is_neither(client: TestClient, release: str) -> None: with_exporters(client.app, WritingExporter()) From 2c4f01e649e709085fccc7ebfc83a84b717f240c Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:00:34 -0700 Subject: [PATCH 5/5] docs(releases): the 422 for target and format, and the polygon claim --- docs/content/api.md | 6 +++++- docs/content/examples.md | 2 +- docs/content/releases.md | 12 +++++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/content/api.md b/docs/content/api.md index bb61e04e..5ce36c1a 100644 --- a/docs/content/api.md +++ b/docs/content/api.md @@ -559,7 +559,11 @@ Both are reachable on the same route, and they differ in `detail`: payload parsed, and a kernel rule rejected it. `detail` is usually `null`. Most malformed input arrives as the first: a `LabelClass` that cannot be constructed never -reaches a service to be refused by one. +reaches a service to be refused by one. The export address is one more of the first kind: +`POST /releases/{id}/export` and `GET /releases/{id}/export-compatibility` take exactly one of +`target` and `format`, and both or neither is a 422 `VALIDATION_ERROR` whose single error has +`loc: ["query"]`, `msg: "give exactly one of target and format"` and an `input` echoing the two +values as sent. A refusal from a **bulk write** carries `detail.index` - the position in the array you sent of the item that caused it: diff --git a/docs/content/examples.md b/docs/content/examples.md index 44ed16ef..da56de96 100644 --- a/docs/content/examples.md +++ b/docs/content/examples.md @@ -336,7 +336,7 @@ quietly leaving the impression that a terminal can label images. | Annotate | `add_annotations` with every edge multiplied by `scale`, then `set_asset_progress` for the rest, then `complete_job` | | Trunk | `complete_batch` → `promote_batch` → `dataset_stats` | | Release | `publish_release`, `list_releases`, `verify_release` | -| Export | `list_formats` → `export_release(dest=…)` - a directory, not an archive | +| Export | `list_formats` → `export_release(format="dummy", dest=…)` - a directory, not an archive; a `format` addresses no trainer, so this is the plain-format call and the Recipe row below is the same release addressed by `target` | | Recipe | `create_preprocessing_recipe`, `list_preprocessing_recipes`, then `export_release(target="yolo11", recipe="yolo-640", allow_lossy=True, dest=…)` - the result's `preprocessing` names the recipe under its hash and maps the train fold's `-aug1` variant to its source, and both files are on disk | | Refusal | `publish_release` on the same tag → a **result** carrying an error envelope, `retry_with` null | diff --git a/docs/content/releases.md b/docs/content/releases.md index 47813115..71e02c01 100644 --- a/docs/content/releases.md +++ b/docs/content/releases.md @@ -648,11 +648,13 @@ will not write two lanes claiming the same one of its four mask slots. Both name both are the same `ExportSourceUnreadable` the YOLO exporter raises for a class the schema does not declare. -**YOLO, COCO and VOC carry no polyline at all**, and that is checked rather than assumed -(`test_the_three_general_formats_declare_polyline_truthfully`). YOLO and VOC reduce a *polygon* -to its bounding box, which is defensible because a polygon encloses an area a box approximates; -an open path encloses nothing, so a box drawn round it would be an invention. COCO's -`segmentation` is a closed ring and it has no open-path primitive. All three therefore report a +**The YOLO dialects, COCO and VOC carry no polyline at all**, and that is checked rather than +assumed (`test_the_general_formats_declare_polyline_truthfully`). `yolov5-yaml` and `voc` reduce a +*polygon* to its bounding box, which is defensible because a polygon encloses an area a box +approximates; an open path encloses nothing, so a box drawn round it would be an invention. +`ultralytics` writes a polygon as its vertices - its presence is what selects the `segment` layout, +and its `degraded_geometries` is empty - but has no row for an open path either. COCO's +`segmentation` is a closed ring and it has no open-path primitive. All four therefore report a polyline class as **dropped**, and their label files contain no trace of one. ### The destination is the caller's