Draft — planning seed, not an implementation ticket. Captures partial information as of
2026-08-07; a dedicated design session will produce the real spec. Do not pick up for
implementation. Seven of the nine open decisions were ratified on 2026-08-07 — see the decision comments. Only the design-session details named in the stamps remain open.
The problem
Offline data augmentation — materialising transformed copies of a training set — is table
stakes for a dataset platform: every commercial competitor ships it, and a user with a small
dataset (the typical open-source case) expects it. On its own it is not a differentiator, and
it is not even the dominant practice — modern training frameworks apply transforms per-epoch
inside the loop, and VisionSet is not a training framework. What VisionSet can own is the
half a trainer cannot: the augmentation recipe as a declarative, seeded, versioned artifact
— the same shape SplitRecipe already has, so that "release v1.2 was trained with these
transforms at this seed" is a fact the workspace can state rather than a note in somebody's
README. Online augmentation stays out of scope as an execution concern.
Where it goes, and where it does not
Verified against HEAD (5aebfc6).
- Not at ingest. Asset identity is the SHA-256 of the content
(uq_asset_project_content_hash), and IngestService dedups on it. Synthetic pixels in the
content-address space of source data corrupts what an asset is.
- Not in the trunk.
batch-lifecycle decision 8 (G5): "An Annotation hangs off its
asset_id and nothing else… Do not add supersession links, per-round filtering, or
annotation ids on DatasetMember." Derived assets would need "hide derived" filtering in
every gallery, stat and batch surface, forever.
- Not in the blob store.
BlobStore has no delete() — blobs are never deleted — so a
variant written there is permanent workspace growth. Proposed invariant: the augmentation
executor never calls BlobStore.put. Augmented pixels live in export output, which is
disposable and regenerable.
- A recipe on the release, materialised at export. Export already commits pixels in a
trainer's format, already runs in a worker (visionset.jobs.export, job type
export.release), and already refuses to lose information silently.
The recipe attaches to the release row, not to the manifest
The draft this issue came from proposed putting the recipe inside the frozen manifest. That
is wrong by the manifest's own doctrine (kernel/domain/release.py module docstring):
The manifest is a pure function of content. Nothing time-, machine- or
identity-specific goes inside it… That is what makes "publish twice from an unchanged
dataset and the bytes agree" a property of the design rather than something to engineer.
SplitRecipe is already an instance of that rule being applied — a recipe is a statement
about how to use content, not about what the content is, so it lives on the release row
(_tables.py:565, a nullable JSON column) and not in the document. An augmentation recipe
passes the same test. Putting one in the manifest would need a MANIFEST_VERSION bump and
would stop two releases of identical content sharing a manifest blob.
The named migration implication
Migration 6, release_augmentation — one nullable JSON column on release.
FORMAT_VERSION 5 → 6.
- Same shape as the
split column, one for one: Mapped[dict[str, Any] | None],
mapped_column(JSON, nullable=True).
- An
ALTER TABLE ADD COLUMN, not a rebuild, because it is nullable. ReleaseRow's
docstring notes a NOT NULL column here would force a rebuild — that does not bind, and
must not be dodged with a server_default.
- Two standing rules a migration task gets wrong: the new column is declared last on
ReleaseRow (the ALTER-adds-last rule), and its undo goes into _at_generation_one —
a column-adding migration whose undo is omitted never runs in the fresh-vs-migrated test and
that test then passes vacuously.
ReleaseOut gains one nullable field; openapi.json and the generated TS client are
regenerated.
This cost is contingent on the attachment decision below: Option A costs zero migrations.
The blocker a design session must resolve first
The split is resolved inside the format plugin, not before it — so an augmentation stage
cannot simply be inserted, and the obvious implementation causes the exact val/test
contamination augmentation is supposed to avoid.
- The port is
export(release, manifest, dest, *, content: ContentReader) -> None
(kernel/ports/exporter.py). It receives no fold information.
- Each image-laying-out exporter calls
folds_of(release, manifest) itself
(formats/_layout.py:61; call site at formats/yolo/__init__.py:133, likewise COCO and
VOC), which re-runs assign_split over the manifest it was handed.
assign_split keys on content_hash. A variant has a new content hash — so if an
executor widens the manifest with variants, the plugin assigns each variant a fold by
hash, independently of its source asset. Variants of a training image land in val and
test, and because counts come from largest-remainder apportionment over a larger set,
original assets change folds too: the same release exports two different splits
depending on whether augmentation was on.
So "the executor receives assets after split resolution, and variants inherit their source's
fold trivially" describes a pipeline that does not exist. Four routes, none free:
- Widen
Exporter.export with an optional precomputed fold map. Signature-compatible,
not behaviour-compatible: a third-party plugin ignoring it contaminates silently.
- Move fold resolution above the plugin and pass a fold map as a required argument. Clean;
a breaking port change (cf. #365).
- Post-process the export directory —
dest/images/train/ is the train fold on disk, but
labels are format-specific, so per-format label rewriting reappears, which is the
duplication _layout.py exists to prevent.
- Augment inside each plugin. Rejected: N formats × the same logic.
The good news in the same place. ReleaseService.export composes the ContentReader
and passes the manifest as a value — the plugin never sees a workspace or the frozen blob.
So an executor can legitimately hand down a derived manifest plus a reader that resolves
variant hashes from a temporary directory, leaving the release's manifest hash untouched. The
seam exists. Only fold resolution is in the wrong place.
Declared loss: a third input to the existing gate, not a second mechanism
Rotation on an axis-aligned box does not survive exactly — re-fitting inflates it. Crop
truncates. Occlusion dropout can erase a whole object and orphan its label. VisionSet already
refuses to lose information silently, and the existing machinery is the right shape:
_compatibility(release, manifest, exporter) judges one format, one release, per class and
produces ClassCompatibility with SUPPORTED / DEGRADED / DROPPED plus a reason.
Recipe loss is the same shape with a different second argument — rotation on a bbox class is
precisely DEGRADED, reason "axis-aligned box re-fit inflates the box".
Two consequences:
- Applicability must be judged per class, never against the schema's geometry union. The
kernel already warns about this trap for annotations (annotation_service.py,
errors.py): the union "would let a polygon through under a bbox class". A schema with
one polygon class and one bbox class would report "rotation is fine" while every box
degrades.
- Expect a fourth gate word. This repo keeps
confirm=, allow_destructive= and
allow_lossy= deliberately separate, "never one except". "This format cannot carry my
polygons" and "my recipe degrades my boxes" are different consents with different remedies.
visionset export --check must see recipe loss too, or it breaks its own promise that
--check && export means something.
Library options — licenses verified 2026-08-07
| Option |
License |
New runtime dep |
Reach |
Cost |
| A′ — Pillow only |
MIT-CMU; already a hard dependency (pillow>=11.0) |
none |
flip, rotate, affine/perspective, crop, scale, brightness/contrast/colour, blur, JPEG artifacts. Not weather, not heavy noise. |
Lowest. No extra, no optional-import vocabulary. Annotation math is still the real work. |
| A — own implementation on OpenCV + numpy |
OpenCV Apache-2.0, numpy BSD-3-Clause |
opencv-python-headless, numpy |
everything incl. weather/occlusion |
Highest build; full control |
B — legacy albumentations 2.0.8 |
MIT |
albumentations (→ numpy, opencv) |
broad, immediately |
Archived — see below |
| C — vendor selected transforms from the MIT-era code |
MIT + attribution |
as taken |
chosen subset |
Medium; own the code, skip re-deriving the math |
| D — torchvision / Kornia |
BSD / Apache |
PyTorch |
broad |
Disproportionate: a multi-GB tensor stack inside a dataset tool for offline CPU work |
AlbumentationsX |
AGPL-3.0-only |
— |
— |
Excluded on license — incompatible with shipping as a hard dependency of an Apache-2.0 distribution. Recorded so it is not re-proposed. |
Evidence: AlbumentationsX PyPI license_expression: AGPL-3.0-only, repo active
(archived: false, last push 2026-08-06). Legacy albumentations 2.0.8 is MIT and the repo
is archived (archived: true, last push 2025-06-25). opencv-python* wheels bundle
FFmpeg under LGPL-2.1 in all wheels (Qt5 LGPL-3 in non-headless Linux wheels) — not a
blocker, but a distribution fact, and an argument for -headless if OpenCV is chosen.
Two facts that reshape this table:
- Pillow is already shipped, so a meaningful subset of augmentation needs no new
dependency and no extra at all. A staged answer (A′ for v1, an extra later for
weather/noise) is available and costs nothing to keep open.
- This distribution has no pip extras.
pyproject.toml has no
[project.optional-dependencies]; the yolo and coco entries are PEP 735
[dependency-groups], i.e. dev groups — pip install visionset[yolo] installs nothing.
visionset[augment] would be the first extra ever shipped here, and there is no
"missing extra, one-sentence refusal" vocabulary to reuse. The model to copy is
MediaToolUnavailable (ffmpeg): checked lazily per call, deliberately outside the error
family it sits next to, carrying the install hint in its message.
Architecture sketch (not ratified)
- The kernel models the recipe: a frozen pydantic model in
kernel/domain/ — ordered
transform specs, parameters, probabilities, variant count, seed, target-split policy, with a
recipe_version — validated the way SplitRecipe is, so an invalid recipe cannot be
constructed at all. No numpy, no OpenCV, ever.
- Execution lives outside the kernel, behind a narrow port. The precedent is
visionset/jobs/export.py's own words: "Turning a format name into an Exporter means
visionset.formats.registry, and import-linter forbids visionset.kernel from importing
it… that single fact places visionset.jobs where it is." Open: a visionset.augment
sibling (needs a new entry in contract 1's forbidden_modules plus a contract-3 mirror) or
a module inside visionset.jobs (needs no contract change at all) — the answer depends on
whether any caller other than the export handler is planned.
- Determinism: per-asset seed from
recipe_seed + asset.content_hash — content_hash is a
required field on ManifestAsset and is already what assign_split keys on, so it is
available at materialisation. The honest promise is byte-identical within one build of the
imaging library, matching what thumbnails and video frame extraction already promise; not
across builds.
- No release-level capabilities.
allowed_actions exists on BatchOut, JobOut and
BatchAssetOut only; a release is immutable and declares none. The question anyone asks of
a release — "what would this cost?" — is already check_export →ExportCompatibility,
which is also the right home for recipe loss.
Known limitations to state rather than engineer around
- Video frames.
ManifestAsset carries asset_id, content_hash, uri, width,
height, annotations and nothing else — no frame_index, no frame_timestamp, no
source_id. An executor working from the manifest structurally cannot tell a frame from a
still. Good for leakage; it also means 200 consecutive frames of one clip would get 200
independently sampled transforms, which is defensible for detection and wrong for anything
temporal. cf. #364.
- Multi-image mixing (mixup, CutMix, mosaic, copy-paste) is cross-sample and
overwhelmingly online-only. Out of scope for offline materialisation.
- Augmentation multiplies information, it does not add any — it replicates annotation
errors N times over. Aggressive transforms produce out-of-distribution images and degraded
labels; the mitigation is declared loss plus preview, not a better default.
- Progress. The export handler consults its
ProgressReporter once before starting,
because Exporter has no progress channel. Augmentation would be the first export-side work
with a natural per-item unit, so a long augmented export would show no progress at all.
- 3D.
GeometryType reserves CUBOID_3D and POLYLINE_3D and Asset.modality is typed
to extend. A recipe schema designed 2D-only would need reopening. cf. #363.
Open decisions (Armando)
Seven of the nine items below were ratified on 2026-08-07 — see the decision comments on
this issue. Each is stamped with its outcome; the original trade-off analysis is left in place
as the reasoning behind the call. Only the design-session details named in the stamps remain
open.
-
Milestone placement. Resolved — see decision comment (2026-08-07): 0.1.0. The
post-0.2.0 framing of the earlier decision comment was a working presumption, never a
ratified decision, and is superseded. Sequencing consequence: the Exporter port change in
item 5 ships with this feature in 0.1.0, and cf. #365 inherits the already-changed port
rather than carrying the change itself.
-
Recipe attachment model. Resolved — see decision comment (2026-08-07): Option B, one
recipe.json format at two commitment levels. The sub-question is resolved too: with a
frozen recipe on the release and an ad-hoc one at export, the export refuses with one
sentence unless an explicit override flag is passed — neither source ever wins silently.
Still open: the override flag's spelling (design-session detail).
- Option A — ad-hoc only.
visionset export --augment recipe.json; the release stores
nothing. Zero migrations. The cost is provenance: a Release today records
manifest_hash, schema_version, the counts, split, created_at and
visionset_version, and verify re-hashes every blob against them — the release can
prove what it was made of. An ad-hoc recipe is recorded nowhere in the workspace, so "v1.2
was trained with rotation ±15° at seed 42" would be true and unprovable, and a re-export a
month later could silently differ.
- Option B — same file format, two commitment levels. Attachable at
release publish
(frozen, provenance-bearing, migration 6) and passable ad-hoc at export for
experimentation. This is split's exact shape. Sub-question that must be answered in
the same session: when a release carries a recipe and the export passes one, does it
refuse, or does one win?
- The
--augment recipe.json syntax is available under both.
-
Backend library. Resolved — see decision comment (2026-08-07): an own implementation
on opencv-python-headless + numpy, as a hard runtime dependency — not an extra. A′
(Pillow only) is declined, as is any staged rollout; full technique reach ships on one
backend from v1.
-
UI scope of v1. Resolved — see decision comment (2026-08-07): a visual recipe editor
with live per-transform preview, executed server-side through the same executor, with
full UI/CLI parity over one recipe.json. Still open: editor scope details
(design-session detail).
-
The Exporter port question. Resolved — see decision comment (2026-08-07): route 2
— fold resolution moves above the format plugin and the port takes a precomputed fold map as
a required argument. Route 1 is rejected: a third-party plugin ignoring an optional
kwarg would contaminate val/test silently. Sequenced with the Exporter contract work in
cf. #365 so the port breaks once.
-
Package placement. Resolved — see decision comment (2026-08-07): a visionset.augment
sibling package with its own import contracts, mirroring the visionset.jobs shape. The
second caller that justifies it exists by decision — the server-side preview path.
-
The fourth gate word. Resolved — see decision comment (2026-08-07): recipe-declared
loss gets its own consent word, never folded into allow_lossy. Still open: the word's
spelling (design-session detail).
-
Video frames. Resolved — see decision comment (2026-08-07): v1 states the
temporal limitation in docs; temporal-consistent augmentation is scoped out to cf. #364.
No engineering around it in v1.
-
Where recipe-loss reporting lives. ExportCompatibility.format_name /
format_is_lossy are format-shaped fields, and the report is round-tripped through
AliasChoices and written to disk. Carrying recipe loss means either widening
ExportCompatibility (a wire change and an on-disk report-format change) or a sibling
per-class document. Open — design-session decision. cf. the durable-findings comment.
Standing constraint
Per the dependency policy on #81 (cf. #81), third-party inference/labeling frameworks were
not evaluated as options here.
References
cf. #365 — 0.2.0 scope: more formats. Owns the lossiness contract this feature extends and
the Exporter port any fold-resolution change touches.
cf. #411 — 0.2.0 scope: import of external datasets. The other half of
visionset.formats; shares the "declare what is carried, refuse silent drops" principle.
cf. #363 — 0.2.0 scope: 3D and point clouds. Decides whether the recipe schema may be
designed 2D-only.
cf. #364 — 0.2.0 scope: video tracking. The temporal-consistency limitation above.
cf. #352 — per-vertex annotation data; any transform-applicability table over polylines
inherits it.
cf. #375 — geometry categories; the applicability matrix is a per-geometry table and would
group the same way.
cf. #81 — AI-assist substrate. Adjacent, neither blocks the other.
Draft — planning seed, not an implementation ticket. Captures partial information as of
2026-08-07; a dedicated design session will produce the real spec. Do not pick up for
implementation. Seven of the nine open decisions were ratified on 2026-08-07 — see the decision comments. Only the design-session details named in the stamps remain open.
The problem
Offline data augmentation — materialising transformed copies of a training set — is table
stakes for a dataset platform: every commercial competitor ships it, and a user with a small
dataset (the typical open-source case) expects it. On its own it is not a differentiator, and
it is not even the dominant practice — modern training frameworks apply transforms per-epoch
inside the loop, and VisionSet is not a training framework. What VisionSet can own is the
half a trainer cannot: the augmentation recipe as a declarative, seeded, versioned artifact
— the same shape
SplitRecipealready has, so that "release v1.2 was trained with thesetransforms at this seed" is a fact the workspace can state rather than a note in somebody's
README. Online augmentation stays out of scope as an execution concern.
Where it goes, and where it does not
Verified against HEAD (
5aebfc6).(
uq_asset_project_content_hash), andIngestServicededups on it. Synthetic pixels in thecontent-address space of source data corrupts what an asset is.
batch-lifecycledecision 8 (G5): "AnAnnotationhangs off itsasset_idand nothing else… Do not add supersession links, per-round filtering, orannotation ids on
DatasetMember." Derived assets would need "hide derived" filtering inevery gallery, stat and batch surface, forever.
BlobStorehas nodelete()— blobs are never deleted — so avariant written there is permanent workspace growth. Proposed invariant: the augmentation
executor never calls
BlobStore.put. Augmented pixels live in export output, which isdisposable and regenerable.
trainer's format, already runs in a worker (
visionset.jobs.export, job typeexport.release), and already refuses to lose information silently.The recipe attaches to the release row, not to the manifest
The draft this issue came from proposed putting the recipe inside the frozen manifest. That
is wrong by the manifest's own doctrine (
kernel/domain/release.pymodule docstring):SplitRecipeis already an instance of that rule being applied — a recipe is a statementabout how to use content, not about what the content is, so it lives on the
releaserow(
_tables.py:565, a nullable JSON column) and not in the document. An augmentation recipepasses the same test. Putting one in the manifest would need a
MANIFEST_VERSIONbump andwould stop two releases of identical content sharing a manifest blob.
The named migration implication
Migration 6,
release_augmentation— one nullable JSON column onrelease.FORMAT_VERSION5 → 6.splitcolumn, one for one:Mapped[dict[str, Any] | None],mapped_column(JSON, nullable=True).ALTER TABLE ADD COLUMN, not a rebuild, because it is nullable.ReleaseRow'sdocstring notes a
NOT NULLcolumn here would force a rebuild — that does not bind, andmust not be dodged with a
server_default.ReleaseRow(the ALTER-adds-last rule), and its undo goes into_at_generation_one—a column-adding migration whose undo is omitted never runs in the fresh-vs-migrated test and
that test then passes vacuously.
ReleaseOutgains one nullable field;openapi.jsonand the generated TS client areregenerated.
This cost is contingent on the attachment decision below: Option A costs zero migrations.
The blocker a design session must resolve first
The split is resolved inside the format plugin, not before it — so an augmentation stage
cannot simply be inserted, and the obvious implementation causes the exact val/test
contamination augmentation is supposed to avoid.
export(release, manifest, dest, *, content: ContentReader) -> None(
kernel/ports/exporter.py). It receives no fold information.folds_of(release, manifest)itself(
formats/_layout.py:61; call site atformats/yolo/__init__.py:133, likewise COCO andVOC), which re-runs
assign_splitover the manifest it was handed.assign_splitkeys oncontent_hash. A variant has a new content hash — so if anexecutor widens the manifest with variants, the plugin assigns each variant a fold by
hash, independently of its source asset. Variants of a training image land in
valandtest, and because counts come from largest-remainder apportionment over a larger set,original assets change folds too: the same release exports two different splits
depending on whether augmentation was on.
So "the executor receives assets after split resolution, and variants inherit their source's
fold trivially" describes a pipeline that does not exist. Four routes, none free:
Exporter.exportwith an optional precomputed fold map. Signature-compatible,not behaviour-compatible: a third-party plugin ignoring it contaminates silently.
a breaking port change (
cf. #365).dest/images/train/is the train fold on disk, butlabels are format-specific, so per-format label rewriting reappears, which is the
duplication
_layout.pyexists to prevent.The good news in the same place.
ReleaseService.exportcomposes theContentReaderand passes the manifest as a value — the plugin never sees a workspace or the frozen blob.
So an executor can legitimately hand down a derived manifest plus a reader that resolves
variant hashes from a temporary directory, leaving the release's manifest hash untouched. The
seam exists. Only fold resolution is in the wrong place.
Declared loss: a third input to the existing gate, not a second mechanism
Rotation on an axis-aligned box does not survive exactly — re-fitting inflates it. Crop
truncates. Occlusion dropout can erase a whole object and orphan its label. VisionSet already
refuses to lose information silently, and the existing machinery is the right shape:
_compatibility(release, manifest, exporter)judges one format, one release, per class andproduces
ClassCompatibilitywithSUPPORTED/DEGRADED/DROPPEDplus areason.Recipe loss is the same shape with a different second argument — rotation on a bbox class is
precisely
DEGRADED, reason "axis-aligned box re-fit inflates the box".Two consequences:
kernel already warns about this trap for annotations (
annotation_service.py,errors.py): the union "would let a polygon through under a bbox class". A schema withone polygon class and one bbox class would report "rotation is fine" while every box
degrades.
confirm=,allow_destructive=andallow_lossy=deliberately separate, "never oneexcept". "This format cannot carry mypolygons" and "my recipe degrades my boxes" are different consents with different remedies.
visionset export --checkmust see recipe loss too, or it breaks its own promise that--check && exportmeans something.Library options — licenses verified 2026-08-07
pillow>=11.0)opencv-python-headless,numpyalbumentations2.0.8AlbumentationsXEvidence: AlbumentationsX PyPI
license_expression: AGPL-3.0-only, repo active(
archived: false, last push 2026-08-06). Legacyalbumentations2.0.8 is MIT and the repois archived (
archived: true, last push 2025-06-25).opencv-python*wheels bundleFFmpeg under LGPL-2.1 in all wheels (Qt5 LGPL-3 in non-headless Linux wheels) — not a
blocker, but a distribution fact, and an argument for
-headlessif OpenCV is chosen.Two facts that reshape this table:
dependency and no extra at all. A staged answer (A′ for v1, an extra later for
weather/noise) is available and costs nothing to keep open.
pyproject.tomlhas no[project.optional-dependencies]; theyoloandcocoentries are PEP 735[dependency-groups], i.e. dev groups —pip install visionset[yolo]installs nothing.visionset[augment]would be the first extra ever shipped here, and there is no"missing extra, one-sentence refusal" vocabulary to reuse. The model to copy is
MediaToolUnavailable(ffmpeg): checked lazily per call, deliberately outside the errorfamily it sits next to, carrying the install hint in its message.
Architecture sketch (not ratified)
kernel/domain/— orderedtransform specs, parameters, probabilities, variant count, seed, target-split policy, with a
recipe_version— validated the waySplitRecipeis, so an invalid recipe cannot beconstructed at all. No numpy, no OpenCV, ever.
visionset/jobs/export.py's own words: "Turning a format name into anExportermeansvisionset.formats.registry, and import-linter forbidsvisionset.kernelfrom importingit… that single fact places
visionset.jobswhere it is." Open: avisionset.augmentsibling (needs a new entry in contract 1's
forbidden_modulesplus a contract-3 mirror) ora module inside
visionset.jobs(needs no contract change at all) — the answer depends onwhether any caller other than the export handler is planned.
recipe_seed + asset.content_hash—content_hashis arequired field on
ManifestAssetand is already whatassign_splitkeys on, so it isavailable at materialisation. The honest promise is byte-identical within one build of the
imaging library, matching what thumbnails and video frame extraction already promise; not
across builds.
allowed_actionsexists onBatchOut,JobOutandBatchAssetOutonly; a release is immutable and declares none. The question anyone asks ofa release — "what would this cost?" — is already
check_export→ExportCompatibility,which is also the right home for recipe loss.
Known limitations to state rather than engineer around
ManifestAssetcarriesasset_id,content_hash,uri,width,height,annotationsand nothing else — noframe_index, noframe_timestamp, nosource_id. An executor working from the manifest structurally cannot tell a frame from astill. Good for leakage; it also means 200 consecutive frames of one clip would get 200
independently sampled transforms, which is defensible for detection and wrong for anything
temporal.
cf. #364.overwhelmingly online-only. Out of scope for offline materialisation.
errors N times over. Aggressive transforms produce out-of-distribution images and degraded
labels; the mitigation is declared loss plus preview, not a better default.
ProgressReporteronce before starting,because
Exporterhas no progress channel. Augmentation would be the first export-side workwith a natural per-item unit, so a long augmented export would show no progress at all.
GeometryTypereservesCUBOID_3DandPOLYLINE_3DandAsset.modalityis typedto extend. A recipe schema designed 2D-only would need reopening.
cf. #363.Open decisions (Armando)
Seven of the nine items below were ratified on 2026-08-07 — see the decision comments on
this issue. Each is stamped with its outcome; the original trade-off analysis is left in place
as the reasoning behind the call. Only the design-session details named in the stamps remain
open.
Milestone placement. Resolved — see decision comment (2026-08-07): 0.1.0. The
post-0.2.0 framing of the earlier decision comment was a working presumption, never a
ratified decision, and is superseded. Sequencing consequence: the
Exporterport change initem 5 ships with this feature in 0.1.0, and
cf. #365inherits the already-changed portrather than carrying the change itself.
Recipe attachment model. Resolved — see decision comment (2026-08-07): Option B, one
recipe.jsonformat at two commitment levels. The sub-question is resolved too: with afrozen recipe on the release and an ad-hoc one at export, the export refuses with one
sentence unless an explicit override flag is passed — neither source ever wins silently.
Still open: the override flag's spelling (design-session detail).
visionset export --augment recipe.json; the release storesnothing. Zero migrations. The cost is provenance: a
Releasetoday recordsmanifest_hash,schema_version, the counts,split,created_atandvisionset_version, andverifyre-hashes every blob against them — the release canprove what it was made of. An ad-hoc recipe is recorded nowhere in the workspace, so "v1.2
was trained with rotation ±15° at seed 42" would be true and unprovable, and a re-export a
month later could silently differ.
release publish(frozen, provenance-bearing, migration 6) and passable ad-hoc at export for
experimentation. This is
split's exact shape. Sub-question that must be answered inthe same session: when a release carries a recipe and the export passes one, does it
refuse, or does one win?
--augment recipe.jsonsyntax is available under both.Backend library. Resolved — see decision comment (2026-08-07): an own implementation
on
opencv-python-headless+ numpy, as a hard runtime dependency — not an extra. A′(Pillow only) is declined, as is any staged rollout; full technique reach ships on one
backend from v1.
UI scope of v1. Resolved — see decision comment (2026-08-07): a visual recipe editor
with live per-transform preview, executed server-side through the same executor, with
full UI/CLI parity over one
recipe.json. Still open: editor scope details(design-session detail).
The
Exporterport question. Resolved — see decision comment (2026-08-07): route 2— fold resolution moves above the format plugin and the port takes a precomputed fold map as
a required argument. Route 1 is rejected: a third-party plugin ignoring an optional
kwarg would contaminate val/test silently. Sequenced with the
Exportercontract work incf. #365so the port breaks once.Package placement. Resolved — see decision comment (2026-08-07): a
visionset.augmentsibling package with its own import contracts, mirroring the
visionset.jobsshape. Thesecond caller that justifies it exists by decision — the server-side preview path.
The fourth gate word. Resolved — see decision comment (2026-08-07): recipe-declared
loss gets its own consent word, never folded into
allow_lossy. Still open: the word'sspelling (design-session detail).
Video frames. Resolved — see decision comment (2026-08-07): v1 states the
temporal limitation in docs; temporal-consistent augmentation is scoped out to
cf. #364.No engineering around it in v1.
Where recipe-loss reporting lives.
ExportCompatibility.format_name/format_is_lossyare format-shaped fields, and the report is round-tripped throughAliasChoicesand written to disk. Carrying recipe loss means either wideningExportCompatibility(a wire change and an on-disk report-format change) or a siblingper-class document. Open — design-session decision. cf. the durable-findings comment.
Standing constraint
Per the dependency policy on #81 (cf. #81), third-party inference/labeling frameworks were
not evaluated as options here.
References
cf. #365— 0.2.0 scope: more formats. Owns the lossiness contract this feature extends andthe
Exporterport any fold-resolution change touches.cf. #411— 0.2.0 scope: import of external datasets. The other half ofvisionset.formats; shares the "declare what is carried, refuse silent drops" principle.cf. #363— 0.2.0 scope: 3D and point clouds. Decides whether the recipe schema may bedesigned 2D-only.
cf. #364— 0.2.0 scope: video tracking. The temporal-consistency limitation above.cf. #352— per-vertex annotation data; any transform-applicability table over polylinesinherits it.
cf. #375— geometry categories; the applicability matrix is a per-geometry table and wouldgroup the same way.
cf. #81— AI-assist substrate. Adjacent, neither blocks the other.