diff --git a/docs/content/examples.md b/docs/content/examples.md index 30a042c0..44ed16ef 100644 --- a/docs/content/examples.md +++ b/docs/content/examples.md @@ -234,6 +234,7 @@ real dependency since #16, so a second hand-rolled PNG encoder beside it would b | Trunk | `complete` → `promote` → `GET /datasets/{d}/stats` | | Release | `POST /datasets/{d}/releases`, `GET …/manifest`, `GET …/verify` | | Export | `GET /formats` → `POST /releases/{r}/export?format=dummy` → a zip on disk | +| Recipe | `POST /projects/{p}/preprocessing-recipes`, then `POST /releases/{r}/export?target=yolo11&recipe=yolo-640` - refused **409 `LOSSY_EXPORT_NOT_CONSENTED`** until `allow_lossy=true` - and the archive is opened for `preprocessing.recipe_hash` in `visionset-export-report.json` and a `labels/train/-aug1.txt` beside its image | | Pixels | `GET /projects/{p}/assets/{a}/content`, hashed against the asset's `content_hash` | | Refusal | the same request with no `Authorization` header → **401 `UNAUTHORIZED`** | @@ -276,7 +277,9 @@ is that `visionset` is on `PATH`, which `uv run` arranges. `examples/cli_end_to_end.sh` is M3's exit criterion - *the full cycle without touching Python* - written as the thing that criterion describes. It runs `visionset init`, `project create`, `schema apply`, `ingest`, `batch approve/start/complete/promote`, a `job` loop, `release -publish/verify`, `format list` and `export`, and then asserts. +publish/verify`, `format list` and `export`, then `recipe create` and a second `export --target +yolo11 --recipe yolo-640 --allow-lossy`, and then asserts - on the release's `--json`, and on the +recipe export's report and its three `-aug1` train variants. ## Three things it is built to demonstrate @@ -334,6 +337,7 @@ quietly leaving the impression that a terminal can label images. | 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 | +| 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 | ## Four things it is built to demonstrate diff --git a/docs/content/mcp-walkthrough.md b/docs/content/mcp-walkthrough.md index 4fb3648b..9782ed19 100644 --- a/docs/content/mcp-walkthrough.md +++ b/docs/content/mcp-walkthrough.md @@ -32,6 +32,7 @@ example proves the transport. | 5 | `next_pending_assets`, `get_asset_image`, `add_annotations`, `set_asset_progress` | the loop | | 6 | `complete_job`, `complete_batch`, `promote_batch`, `dataset_stats` | the finished work reaches the trunk | | 7 | `publish_release`, `verify_release`, `list_formats`, `export_release` | a frozen artifact, on disk | +| 7b | `create_preprocessing_recipe`, `export_release` with `recipe` | the same release, resized and augmented for a model | | 8 | `publish_release` again | a refusal, on purpose | ## 1 - Find out where you are @@ -206,6 +207,24 @@ bill nobody should pay. There is no `get_release_manifest`, for that last reason, and no `get_release_assignment` - `export_release` puts the folds on disk in the form anything downstream actually consumes. +## 7b - And once more, for a model, through a recipe + +``` +create_preprocessing_recipe project=... name="yolo-640" + spec={"target":"yolo11","steps":[{"kind":"resize","strategy":"letterbox","width":640,"height":640}, + {"kind":"augment","op":"hflip"}],"variants_per_asset":1} +export_release project=... tag="v1.0" target="yolo11" recipe="yolo-640" allow_lossy=true dest="/abs/out/yolo11" + -> {"augmented_file_count": 1, "preprocessing": {"recipe_name": "yolo-640", "recipe_hash": "...", "mapping": [...]}, ...} +``` + +A recipe is a project resource named on the export, and the export keeps the spec by value: the +result's `preprocessing` carries the spec as it ran, its hash, and a mapping from every file +written to the source it came from. Augmentation is written for the train fold only, which is what +the split in step 7 is for - one of the two released assets lands there, so one +`images/train/-aug1.png` is written beside its source with `labels/train/-aug1.txt`. +`allow_lossy` because the format `yolo11` resolves to declares itself lossy; `check_export` with +the same `recipe` answers the consent question without writing anything. + ## 8 - And it ends on a refusal ``` diff --git a/examples/cli_end_to_end.sh b/examples/cli_end_to_end.sh index 9b21d69b..c23a9998 100755 --- a/examples/cli_end_to_end.sh +++ b/examples/cli_end_to_end.sh @@ -116,12 +116,44 @@ visionset release publish --tag v1.0 --project road-signs --split 0.5,0.25,0.25 visionset release verify v1.0 --project road-signs say "8. export in an installed format" -# `dummy` is the only exporter this repository ships and it writes nothing, so a -# file_count of 0 below is the honest report of an export that ran. +# `dummy` writes nothing, so a file_count of 0 below is the honest report of an +# export that ran. visionset format list visionset export --project road-signs --release v1.0 --format dummy --out "$DEST/export" --json -say "9. the release as a program reads it" +say "9. a recipe, and the same release exported for a model through it" +# A recipe is a project resource named on the export; the export keeps the spec +# by value, and its report says which one ran under which hash. Augmentation +# runs on the train fold only, which is what the split in step 7 is for. +# `--allow-lossy` because the format yolo11 resolves to declares itself lossy; +# without it the export exits 1 and writes nothing. +visionset recipe create yolo-640 --project road-signs \ + --resize letterbox:640x640 --augment hflip --variants 1 --target yolo11 +visionset recipe list --project road-signs +visionset export --project road-signs --release v1.0 --target yolo11 --recipe yolo-640 \ + --allow-lossy --out "$DEST/export-yolo11" --json > "$DEST/export-yolo11.json" +python3 - "$DEST/export-yolo11.json" "$DEST/export-yolo11" <<'PY' +import json +import re +import sys +from pathlib import Path + +result = json.load(open(sys.argv[1], encoding="utf-8")) +exported = Path(sys.argv[2]) +recipe_hash = result["preprocessing"]["recipe_hash"] +assert re.fullmatch(r"[0-9a-f]{64}", recipe_hash), result["preprocessing"] +assert result["preprocessing"]["recipe_name"] == "yolo-640", result["preprocessing"] +assert result["augmented_file_count"] == 3, result +report = json.loads((exported / "visionset-export-report.json").read_text(encoding="utf-8")) +assert report["preprocessing"]["recipe_hash"] == recipe_hash, report["preprocessing"] +variants = sorted(exported.glob("labels/train/*-aug1.txt")) +assert len(variants) == 3, variants +for label in variants: + assert (exported / "images" / "train" / f"{label.stem}.png").is_file(), label +print(f"recipe {recipe_hash[:12]}… wrote {len(variants)} augmented train images and their labels") +PY + +say "10. the release as a program reads it" visionset release list --project road-signs --json > "$DEST/releases.json" python3 - "$DEST/releases.json" <<'PY' import json @@ -138,7 +170,7 @@ assert release["split"] == {"train": 0.5, "val": 0.25, "test": 0.25, "seed": 0}, print("--json shapes are what the docs say they are") PY -say "10. and a refusal, because a script has to be able to branch on one" +say "11. and a refusal, because a script has to be able to branch on one" # A command inside an `if` condition does not trip `set -e`, which is what makes # demonstrating a failure safe. A release is never edited, so the second publish # under the same tag is refused with one sentence on stderr and exit 1. diff --git a/examples/http_end_to_end.py b/examples/http_end_to_end.py index 25f2fd95..bd795020 100644 --- a/examples/http_end_to_end.py +++ b/examples/http_end_to_end.py @@ -40,6 +40,7 @@ import time import urllib.error import urllib.request +import zipfile from dataclasses import dataclass from email.message import Message from hashlib import sha256 @@ -98,6 +99,8 @@ class Summary: manifest_bytes: int verified: bool export_bytes: int + recipe_hash: str + augmented_label: str content_hash_matched: bool unauthorized_code: str @@ -485,6 +488,52 @@ def _walk(client: Client, base_url: str, downloads: Path) -> Summary: (downloads / "release.zip").write_bytes(archive) _say(f"export settled after {export_polls} polls: {len(archive)} bytes of zip to {downloads}") + # (9b) Export it again for a model, through a pre-processing recipe. A + # recipe is a project resource, named on the export, and the export keeps + # the spec by value: the report inside the archive carries it under its + # hash. Augmentation runs on the train fold only, which is why the release + # above was published with a split. + recipe = client.json( + "POST", + f"/projects/{project}/preprocessing-recipes", + 201, + json_body={ + "name": "yolo-640", + "spec": { + "target": "yolo11", + "steps": [ + {"kind": "resize", "strategy": "letterbox", "width": 640, "height": 640}, + {"kind": "augment", "op": "hflip"}, + ], + "variants_per_asset": 1, + }, + }, + ) + assert recipe["name"] == "yolo-640", recipe + # The format `yolo11` resolves to declares itself lossy, so the first launch + # is the consent question — answered on the request, before any job exists + # — and the retry is the identical call plus `allow_lossy`. + addressed = f"/releases/{release['id']}/export?target=yolo11&recipe=yolo-640" + refused_export = client.json("POST", addressed, 409) + assert refused_export["code"] == "LOSSY_EXPORT_NOT_CONSENTED", refused_export + _, _, launched_recipe = client.request("POST", f"{addressed}&allow_lossy=true", 202) + recipe_job = json.loads(launched_recipe)["id"] + settled_recipe, _ = _poll_job(client, recipe_job) + assert settled_recipe["result"]["target"] == "yolo11", settled_recipe + _, _, yolo_archive = client.request("GET", f"/background-jobs/{recipe_job}/artifact", 200) + (downloads / "release-yolo11.zip").write_bytes(yolo_archive) + with zipfile.ZipFile(BytesIO(yolo_archive)) as opened: + report = json.loads(opened.read("visionset-export-report.json")) + names = opened.namelist() + recipe_hash = report["preprocessing"]["recipe_hash"] + assert recipe_hash == settled_recipe["result"]["recipe_hash"], report["preprocessing"] + assert report["preprocessing"]["spec"] == recipe["spec"], report["preprocessing"] + augmented_label = next( + name for name in names if name.startswith("labels/train/") and name.endswith("-aug1.txt") + ) + assert augmented_label.replace("labels/", "images/", 1).removesuffix(".txt") + ".png" in names + _say(f"exported for yolo11 under recipe {recipe_hash[:12]}…: {augmented_label} written") + # (10) And reach the pixels. A gallery renders these directly, so the media # type has to be right and the bytes have to be the originals — asserted by # hashing what came back against the hash the asset listing reported. @@ -522,6 +571,8 @@ def _walk(client: Client, base_url: str, downloads: Path) -> Summary: manifest_bytes=len(manifest), verified=verified, export_bytes=len(archive), + recipe_hash=recipe_hash, + augmented_label=augmented_label, content_hash_matched=content_matched, unauthorized_code=refused["code"], ) diff --git a/examples/mcp_end_to_end.py b/examples/mcp_end_to_end.py index 10a364d7..2cd475bb 100644 --- a/examples/mcp_end_to_end.py +++ b/examples/mcp_end_to_end.py @@ -39,6 +39,7 @@ import asyncio import base64 import io +import json import shutil import sys from dataclasses import dataclass @@ -102,6 +103,9 @@ class Summary: verified: bool formats: tuple[str, ...] export_directory: str + recipe_hash: str + augmented_files: int + augmented_label: str republish_retry_with: Any @@ -370,6 +374,56 @@ async def tool(name: str, /, **arguments: Any) -> CallToolResult: assert Path(exported["directory"]).is_dir(), exported _say(f"release {TAG} verified {verified}, exported to {exported['directory']}") + # (7b) Once more for a model, through a pre-processing recipe. The + # recipe is a project resource named on the export; the export keeps + # the spec by value, and the result says what it produced. `yolo11` + # resolves to a format that declares itself lossy, so the launch + # carries `allow_lossy` — `check_export` would say the same first. + recipe = ok( + await tool( + "create_preprocessing_recipe", + project=PROJECT, + name="yolo-640", + spec={ + "target": "yolo11", + "steps": [ + {"kind": "resize", "strategy": "letterbox", "width": 640, "height": 640}, + {"kind": "augment", "op": "hflip"}, + ], + "variants_per_asset": 1, + }, + ) + ) + listed_recipes = ok(await tool("list_preprocessing_recipes", project=PROJECT)) + assert [row["name"] for row in listed_recipes["items"]] == ["yolo-640"], listed_recipes + with_recipe = ok( + await tool( + "export_release", + project=PROJECT, + tag=TAG, + target="yolo11", + recipe="yolo-640", + allow_lossy=True, + dest=str(export / "yolo11"), + ) + ) + preprocessing = with_recipe["preprocessing"] + assert preprocessing["recipe_name"] == "yolo-640", preprocessing + assert preprocessing["spec"] == recipe["spec"], preprocessing + recipe_hash = preprocessing["recipe_hash"] + # One train-fold image under this split, so one variant beside it, + # named for its source and traced back to it in the mapping. + assert with_recipe["augmented_file_count"] == 1, with_recipe + variant = next(row for row in preprocessing["mapping"] if row["variant"] == 1) + assert variant["file"] == f"images/train/{variant['source_content_hash']}-aug1.png", variant + augmented_label = f"labels/train/{variant['source_content_hash']}-aug1.txt" + yolo_dir = Path(with_recipe["directory"]) + assert (yolo_dir / variant["file"]).is_file(), variant + assert (yolo_dir / augmented_label).is_file(), augmented_label + report = json.loads((yolo_dir / "visionset-export-report.json").read_text(encoding="utf-8")) + assert report["preprocessing"]["recipe_hash"] == recipe_hash, report["preprocessing"] + _say(f"exported for yolo11 under recipe {recipe_hash[:12]}…: {augmented_label} written") + # (8) And the walk ends on a refusal it also asserts. A release is # immutable, so the tag cannot be reused — and the envelope carries # `retry_with` rather than a code, because "which flag would make this @@ -398,6 +452,9 @@ async def tool(name: str, /, **arguments: Any) -> CallToolResult: verified=verified, formats=formats, export_directory=exported["directory"], + recipe_hash=recipe_hash, + augmented_files=with_recipe["augmented_file_count"], + augmented_label=augmented_label, republish_retry_with=reused["retry_with"], ) diff --git a/frontend/app/cycle/cycle.spec.ts b/frontend/app/cycle/cycle.spec.ts index 0d03758a..43603d93 100644 --- a/frontend/app/cycle/cycle.spec.ts +++ b/frontend/app/cycle/cycle.spec.ts @@ -33,6 +33,11 @@ * third, so the walk meets the real lossy consent — the sentence naming the target * and the count — before the archive arrives, and then reads `data.yaml` out of * the download to see the class map the trainer would. + * + * **And once more through a pre-processing recipe.** Written on the Dataset's + * Pre-processing view, previewed by the real kernel, chosen in the export + * dialog; the second archive is opened for the report that names the recipe + * under its hash and for the train fold's augmented variant beside its source. */ import { expect, test, type Download, type Page, type TestInfo } from "@playwright/test"; @@ -60,6 +65,9 @@ function images(): string[] { const TAG = "v1"; +/** The recipe the walk writes, named on the second export and in its report. */ +const RECIPE = "yolo-640"; + /** * The reserved model id that resolves to this build's own no-op segmenter. * @@ -1302,6 +1310,11 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa await page.getByTestId("publish-release").click(); await page.getByTestId("release-tag").fill(TAG); + // With folds, at the dialog's own fractions. Augmentation is written for + // the train fold only, so the recipe export further down needs a release + // that has one — and 0.7 of three assets puts two there. + await page.getByTestId("use-split").check(); + await expect(page.getByTestId("split-hint")).toContainText("same seed gives the same folds"); await page.getByTestId("publish-submit").click(); // The new release lands on the Releases view; the dialog was opened from // the header, which every view shares. @@ -1500,6 +1513,94 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa await expectArchive(download); }); + await test.step("write a recipe on the Pre-processing view, previewed by the real kernel", async () => { + /* + * The stage between a release and the files a trainer reads, driven the + * way a person drives it: the view's invitation, the editor's four steps, + * *Save recipe*. Every hop is real — the target catalog the editor seeds + * its resize from, the preview route rendering this project's own frames + * through the same kernel path an export takes, and the `POST` that + * stores the recipe. The view's own tests stub all three, which is right + * for them and is why they cannot notice a preview that refuses a shape + * the release actually holds. + */ + await page.keyboard.press("Escape"); + await expect(page.getByTestId("export-dialog")).toHaveCount(0); + + await page.getByTestId("dataset-tab-preprocessing").click(); + await expect(page.getByTestId("recipes-empty")).toBeVisible(); + await page.getByTestId("recipe-new").click(); + await expect(page.getByTestId("recipe-editor")).toBeVisible(); + await page.getByTestId("recipe-name").fill(RECIPE); + + // The target first, because choosing one applies its hints: YOLO11's say + // letterbox to 640, and the resize step arrives filled in rather than + // typed. Asserted rather than typed over, since the hints are the + // catalog's answer and a wrong one would be worth failing on. + await page.getByTestId("recipe-target").click(); + await page.getByRole("option", { name: /YOLO11/ }).click(); + await expect(page.getByTestId("recipe-step-target")).toHaveAttribute("data-state", "complete"); + await expect(page.getByTestId("resize-letterbox")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("resize-width")).toHaveValue("640"); + await expect(page.getByTestId("resize-height")).toHaveValue("640"); + await expect(page.getByTestId("recipe-step-resize")).toHaveAttribute("data-state", "complete"); + + // One augmentation; ticking the first one makes one variant, which is the + // spec's own rule rather than a default. + await page.getByTestId("augment-hflip").check(); + await expect(page.getByTestId("augment-variants")).toHaveValue("1"); + await expect(page.getByTestId("recipe-step-augment")).toHaveAttribute("data-state", "complete"); + + // The preview: three cells of the first row, each one a real render of a + // frame this walk ingested — the original, the letterbox, and variant 1. + // Generous, because the preview debounces the draft and then goes through + // Pillow three times. + for (const cell of ["original", "resize", "augment"]) { + await expect(page.getByTestId(`preview-0-${cell}`)).toHaveAttribute("data-state", "rendered", { + timeout: 20_000, + }); + } + await expect(page.getByTestId("recipe-step-preview")).toHaveAttribute("data-state", "complete"); + + await page.getByTestId("recipe-save").click(); + await expect(page.getByTestId("recipe-footer-note")).toContainText("No unsaved changes"); + await expect(page.getByTestId("recipe-save-error")).toHaveCount(0); + // Stored: the list names it, and the tab counts it. + await expect(page.getByTestId(`recipe-${RECIPE}`)).toBeVisible(); + await expect(page.getByTestId("dataset-tab-preprocessing")).toContainText("1"); + }); + + await test.step("export through the recipe, and find its variant in the archive", async () => { + /* + * The same dialog, one more control: the recipe picker, which offers + * `None` and the project's recipes by name. The consent question is asked + * again — a recipe changes nothing about what the target drops — and the + * archive that comes back is read for the two things a recipe promises: + * the report names the recipe under its hash, and the train fold carries + * a variant beside its source, label and all. This is the only place the + * whole chain runs for real: the queue, the worker, the resize and the + * augmentation drivers, and the format laying the variant out where a + * trainer will find it. + */ + await page.getByTestId("dataset-tab-releases").click(); + await page.getByTestId(`export-${TAG}`).click(); + await page.getByTestId("export-target").click(); + await page.getByRole("option", { name: /YOLO11/ }).click(); + await page.getByTestId("export-recipe").click(); + await page.getByRole("option", { name: RECIPE }).click(); + + await page.getByTestId("export-submit").click(); + const consent = page.getByTestId("lossy-consent"); + await expect(consent).toContainText("1 polyline would be dropped."); + await consent.getByTestId("lossy-checkbox").check(); + + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByTestId("export-submit").click(), + ]); + await expectRecipeArchive(download); + }); + await test.step("edit the connection, and watch the row answer for it", async () => { /* * A stub cannot referee this body, because it is written by whoever wrote @@ -1631,11 +1732,13 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa * wherever it is present. `curatedSizeRefused` carries what the form rendered, * so the expectation follows the installation instead of guessing at it. * - * The last entry is the export's own 409: the first launch is addressed to - * `yolo11` without `allow_lossy`, and the release holds a polyline that - * target drops, so the refusal is the consent question itself — the one - * refusal in the walk a person is meant to see. Exactly one, because the - * retry carries the flag. + * The last two entries are the export's own 409, once per export: each + * first launch is addressed to `yolo11` without `allow_lossy`, and the + * release holds a polyline that target drops, so the refusal is the + * consent question itself — the one refusal in the walk a person is meant + * to see. Exactly one per export, because the retry carries the flag; the + * second export names a recipe, which changes nothing about what the + * target drops and so asks the same question again. * * Anything else — a route that starts refusing, a 404 that becomes a 500, a * second refusal from a route allowed one — fails here with its method, its @@ -1656,6 +1759,7 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa expect.stringMatching(annotationDraft), expect.stringMatching(annotationDraft), expect.stringMatching(lossyLaunch), + expect.stringMatching(lossyLaunch), ]); // And the icon is genuinely served under the mount, rather than absent and // unnoticed: `vite preview` would answer 200 with `index.html` here, which is @@ -1800,19 +1904,67 @@ async function expectArchive(download: Download): Promise { } /** - * One file out of a zip, by name — enough of the format to read a descriptor. + * The archive of the second export: the same target, through the recipe. + * + * Two things a recipe promises, both read out of the bytes the browser saved. + * The report at the archive's root carries `preprocessing` — the recipe by name, + * by value, and under the hash an export snapshots it as — and the train fold + * carries variant 1 of a source beside that source: `images/train/-aug1.png` + * with `labels/train/-aug1.txt`, the layout `ultralytics` reads labels + * from by substituting one path segment. The report's mapping traces the + * variant back to its source hash, which is what makes the file name checkable + * rather than merely present. + */ +async function expectRecipeArchive(download: Download): Promise { + expect(download.suggestedFilename()).toBe(`${TAG}-yolo11.zip`); + const saved = await download.path(); + expect(saved).not.toBeNull(); + const archive = readFileSync(saved as string); + const entries = zipEntries(archive); + + const report = JSON.parse(zipEntry(archive, "visionset-export-report.json").toString("utf8")); + expect(report.target).toBe("yolo11"); + expect(report.preprocessing.recipe_name).toBe(RECIPE); + expect(report.preprocessing.recipe_hash).toMatch(/^[0-9a-f]{64}$/); + expect(report.preprocessing.spec.variants_per_asset).toBe(1); + + const variants = report.preprocessing.mapping.filter( + (row: { variant: number }) => row.variant === 1, + ); + // Two of three assets are in the train fold under the published split, and + // each of them gets exactly one variant. + expect(variants).toHaveLength(2); + for (const variant of variants) { + expect(variant.file).toBe(`images/train/${variant.source_content_hash}-aug1.png`); + expect(entries.has(variant.file)).toBe(true); + expect(entries.has(`labels/train/${variant.source_content_hash}-aug1.txt`)).toBe(true); + } + expect([...entries.keys()].filter((name) => /^labels\/train\/[0-9a-f]{64}-aug1\.txt$/.test(name))) + .toHaveLength(2); +} + +/** Where an entry's bytes are in a zip, and how they were written. */ +interface ZipEntry { + readonly method: number; + readonly compressed: number; + readonly local: number; +} + +/** + * What a zip holds, by name — enough of the format to read a descriptor. * * Walks the central directory from the end-of-central-directory record, which - * is where a zip says what it holds, then inflates the entry from its local - * header. Stored and deflated entries are the two `shutil.make_archive` writes. + * is where a zip says what it holds. Stored and deflated entries are the two + * `shutil.make_archive` writes; `zipEntry` inflates one from its local header. */ -function zipEntry(archive: Buffer, name: string): Buffer { +function zipEntries(archive: Buffer): Map { let end = archive.length - 22; while (end >= 0 && archive.readUInt32LE(end) !== 0x06054b50) end -= 1; expect(end, "end-of-central-directory record").toBeGreaterThanOrEqual(0); - const entries = archive.readUInt16LE(end + 10); + const count = archive.readUInt16LE(end + 10); let offset = archive.readUInt32LE(end + 16); - for (let index = 0; index < entries; index += 1) { + const entries = new Map(); + for (let index = 0; index < count; index += 1) { expect(archive.readUInt32LE(offset)).toBe(0x02014b50); const method = archive.readUInt16LE(offset + 10); const compressed = archive.readUInt32LE(offset + 20); @@ -1820,15 +1972,19 @@ function zipEntry(archive: Buffer, name: string): Buffer { const extraLength = archive.readUInt16LE(offset + 30); const commentLength = archive.readUInt16LE(offset + 32); const local = archive.readUInt32LE(offset + 42); - const entryName = archive.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); - if (entryName === name) { - expect(archive.readUInt32LE(local)).toBe(0x04034b50); - const start = - local + 30 + archive.readUInt16LE(local + 26) + archive.readUInt16LE(local + 28); - const bytes = archive.subarray(start, start + compressed); - return method === 8 ? inflateRawSync(bytes) : Buffer.from(bytes); - } + const name = archive.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + entries.set(name, { method, compressed, local }); offset += 46 + nameLength + extraLength + commentLength; } - throw new Error(`${name} is not in the archive`); + return entries; +} + +function zipEntry(archive: Buffer, name: string): Buffer { + const entry = zipEntries(archive).get(name); + if (entry === undefined) throw new Error(`${name} is not in the archive`); + const { method, compressed, local } = entry; + expect(archive.readUInt32LE(local)).toBe(0x04034b50); + const start = local + 30 + archive.readUInt16LE(local + 26) + archive.readUInt16LE(local + 28); + const bytes = archive.subarray(start, start + compressed); + return method === 8 ? inflateRawSync(bytes) : Buffer.from(bytes); } diff --git a/tests/examples/test_cli_end_to_end.py b/tests/examples/test_cli_end_to_end.py index 7c3b8a6a..55b24704 100644 --- a/tests/examples/test_cli_end_to_end.py +++ b/tests/examples/test_cli_end_to_end.py @@ -14,7 +14,9 @@ from __future__ import annotations +import json import os +import re import shutil import subprocess import sys @@ -111,3 +113,20 @@ def test_it_leaves_the_export_directory_it_was_given(destination: Path, workspac # is what proves the export ran rather than being skipped. assert (destination / "export").is_dir() assert workspace.is_dir() + + +def test_it_leaves_a_recipe_export_with_its_report_and_train_variants( + destination: Path, workspace: Path +) -> None: + # ``--recipe`` on a ``--target`` export: the report at the root names the + # recipe under its hash, and the train fold — three of six under the + # script's 0.5 split — carries one ``-aug1`` variant per image, label and all. + exported = destination / "export-yolo11" + report = json.loads((exported / "visionset-export-report.json").read_text(encoding="utf-8")) + assert report["preprocessing"]["recipe_name"] == "yolo-640" + assert re.fullmatch(r"[0-9a-f]{64}", report["preprocessing"]["recipe_hash"]) + variants = sorted(exported.glob("labels/train/*-aug1.txt")) + assert len(variants) == 3 + for label in variants: + assert (exported / "images" / "train" / f"{label.stem}.png").is_file() + assert workspace.is_dir() diff --git a/tests/examples/test_http_end_to_end.py b/tests/examples/test_http_end_to_end.py index 0daa245b..e01d3283 100644 --- a/tests/examples/test_http_end_to_end.py +++ b/tests/examples/test_http_end_to_end.py @@ -19,6 +19,7 @@ from __future__ import annotations import importlib.util +import re import shutil import sys from collections.abc import Iterator @@ -110,3 +111,15 @@ def test_the_same_request_without_a_token_is_refused(summary: Any) -> None: def test_the_export_produced_an_archive(summary: Any) -> None: """`dummy` writes no files, so the zip is empty — but it is still a zip.""" assert summary.export_bytes > 0 + + +def test_the_recipe_export_reports_its_hash_and_wrote_a_train_variant(summary: Any) -> None: + """A recipe named on `?recipe=` reaches the archive as a report and as files. + + The example reads both out of the downloaded zip: `preprocessing.recipe_hash` + in `visionset-export-report.json`, and a `-aug1` label under `labels/train/` + beside its image — the train fold's variant, which is the only fold that + gets one. + """ + assert re.fullmatch(r"[0-9a-f]{64}", summary.recipe_hash) + assert re.fullmatch(r"labels/train/[0-9a-f]{64}-aug1\.txt", summary.augmented_label) diff --git a/tests/examples/test_mcp_end_to_end.py b/tests/examples/test_mcp_end_to_end.py index 162e1d3d..010af14d 100644 --- a/tests/examples/test_mcp_end_to_end.py +++ b/tests/examples/test_mcp_end_to_end.py @@ -20,6 +20,7 @@ from __future__ import annotations import importlib.util +import re import shutil import sys from collections.abc import Iterator @@ -127,6 +128,19 @@ def test_the_export_wrote_where_it_was_told(summary: Any) -> None: assert Path(summary.export_directory).is_dir() +def test_the_recipe_export_reports_its_hash_and_wrote_a_train_variant(summary: Any) -> None: + """A recipe named on `export_release` reaches the result, the report and the disk. + + Two of four assets are released and the split puts one in the train fold, so + exactly one variant is written — named for its source, traced to it in the + mapping, and with a label file beside its image. The example asserts the + files exist; this pins the shape it read them by. + """ + assert re.fullmatch(r"[0-9a-f]{64}", summary.recipe_hash) + assert summary.augmented_files == 1 + assert re.fullmatch(r"labels/train/[0-9a-f]{64}-aug1\.txt", summary.augmented_label) + + def test_a_domain_refusal_arrives_as_a_result_and_names_no_retry(summary: Any) -> None: """The two-failure-shape rule, and the reason `retry_with` replaced a code.