Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/content/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hash>-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`** |

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/content/mcp-walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<hash>-aug1.png` is written beside its source with `labels/train/<hash>-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

```
Expand Down
40 changes: 36 additions & 4 deletions examples/cli_end_to_end.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions examples/http_end_to_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"],
)
Expand Down
57 changes: 57 additions & 0 deletions examples/mcp_end_to_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import asyncio
import base64
import io
import json
import shutil
import sys
from dataclasses import dataclass
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"],
)

Expand Down
Loading
Loading