A label class accepts a set of geometries - #593
Merged
Conversation
`LabelClass.geometry` becomes `geometries`, a non-empty deduplicated tuple kept in one sorted order. A class labelled as a box on some frames and as a polygon on others is one class; splitting it in two made every consumer downstream re-unify them, and COCO's own docstring already recorded the cost. The write gate in `AnnotationService._validate` becomes a membership test in that class's own set — still not the version's union, which is wider. `schema_diff` answers the module's governing question per geometry, the shape the `select` options rule already had: one added is additive, one removed is destructive. So widening a class is an ordinary save and narrowing stays behind the flag. No migration. `annotation_schema.classes` is a JSON column, so a `model_validator(mode='before')` on `LabelClass` reading the retired singular key covers stored schemas and stored release manifests alike — and MCP, which takes the domain model directly. `LabelClassBody` deliberately does not read it: a REST client sending it is better told so than silently reinterpreted. `MANIFEST_VERSION` moves to 2, since `Manifest.classes` carries these verbatim. Documents already published keep their bytes and their hashes and still load, so every existing release stays verifiable. The export report is now one row per (class, geometry). Keyed by class alone it carried one verdict for a class YOLO answers twice — writing the boxes whole and reducing the polygons — and would have misdescribed half its own output. No exporter changed: every one of them already branches per annotation. cf. #584
…ved from it `LabelClass.geometries` mirrors the wire, and `toolFor` gains the tool the host currently holds: it keeps that tool when the class accepts it and falls to the class's first drawable geometry when it does not. **An active tool the selected class forbids is unrepresentable**, because one function decides and it never returns one. `InputHost` gains `activeTool`. Without it `activate-class` compares two class defaults rather than two resolved tools, and misses a real move: a host drawing polygons under a both-shapes class that switches to a boxes-only one does change tool, and a polygon in flight has to be cancelled. `isTaggableClass` and `drawableGeometries` stop being each other's negation — a class may accept a tag and a shape — so `classAction` sends `toggle-tag` only when the class draws nothing. Folding a drawable class into it would tag the asset where somebody pressing a class digit meant to arm it. `allowedGeometriesFor` filters instead of wrapping a scalar, which its own docstring had predicted was the only change a set would need. cf. #584
…exists is an offer The schema editor's single-select becomes a checkbox group under the same category headings, using the native input this form already uses for an attribute's `required` flag — no new dependency, no new primitive, and what a class accepts is readable without opening anything. The last ticked box does not come off and carries why. **A defect the new test caught, worth stating:** the first draft refused that last box with `preventDefault()` on the input's click. React synthesises a checkbox's `onChange` from the same native click, so cancelling the click does not cancel the change — the class went to an empty set while the tick stayed on screen, a control lying about what it had just done. The refusal now lives where the value is computed, which cannot come apart. **The rescue flow.** A name the published version already declares stops being a red box and becomes an offer: the alert says what the class accepts today and what publishing would add, and the primary reads `Add polygon to sign`. It carries the **existing** class's colour and attributes, so a form opened to make a new class cannot quietly wipe what the old one declared. The refusal that remains is a name typed twice in one sitting, which has nothing to offer because both entries are being written now. `composeVersion` replaces a same-named class **in place** rather than appending: two classes with one name is what `create_version` refuses outright, and appending would also renumber the digit hotkeys, which are positions in the authored order. The tool strip narrows to the held class's own geometries, and the page holds the preferred tool beside the drawing class at job scope, for the query-key reason the drawing class is already there. cf. #584
The interface was showing users database identifiers, in two vocabularies.
`ToolPalette` had a private `TOOL_LABELS` saying `Box`; every other surface —
class rows, the reassignment menu, the add-a-class dialog's checkboxes and prose
and its primary button, the schema editor's badges, the project summary — printed
the raw `GeometryType`. So one thing was `Box` on the left of the canvas and
`bbox` on the right, and a tag class's row read `classification_tag`.
`GEOMETRY_LABELS` lives beside `GEOMETRY_CATEGORY` in the module that already owns
geometry presentation, total over the union by `satisfies` so a ninth member fails
the build until somebody names it. The strip capitalises at its own control; every
other caller reads the word as-is.
**Lowercase, because the same word is used two ways** — as a chip in a dense row
(`box · polygon`) and inside a sentence ("Publishing adds polygon to it"). Only
the first letter is a sentence-position question, which the test states as *never
starts with a capital* rather than *is lowercase*: `3D box` is an acronym and the
stricter rule would have forced `3d box`, wrong in every position.
`formatGeometries` joins with ` · ` rather than ` or `. A middot is what a set
reads as at this density, and in a 248px row those four characters come out of the
class name.
It is also the largest width saving available in the class list — a tag class's
row spent about 110px of 248 on `classification_tag` and now spends 22 on `tag`,
against the 32px widening the whole panel would buy.
Tests address a checkbox by `data-testid`, which keeps the wire value, so a test
says *which* geometry without also asserting what it is called.
cf. #584
Arming a class stopped answering which shape the next drag produces, and until now the only place that answer lived was the tool strip at the **far left** of the canvas while the class was chosen on the right — one decision split across the width of the picture, in a loop repeated hundreds of times a job. The armed row's geometry words become a segmented control: the active shape lit, pressing another switches the tool **without moving the class**. That retarget rule already shipped in `ToolPalette` and is tested in both directions, so the panel is a second caller of an existing rule rather than a new one. **Only the armed row, and the accessible answer and the density answer agree.** `ClassListRow` is documented as "a real `<button>` spanning the whole row", and HTML forbids interactive descendants inside a button — so a row offering a choice has to become a group with an inner name button. That is also what you want at the fifty-class ontologies principle 7 is written for: an unarmed row has no live choice, and fifty pickers for one decision is noise. Exactly one row is armed, so the extra tab stops are bounded at (shapes − 1). The row is gated on `drawableGeometries`, not `geometries`: a class may accept a tag beside a box, and a tag has no canvas gesture — offering it would be a tool the canvas cannot answer. `toolForClass` is extracted from `toolFor` and exported, because a list iterating `schema.classes` holds the class and has no document. Writing `drawable.find(…) ?? drawable[0]` in the panel instead would be two spellings of one fallback, which is how a strip and a panel come to disagree about which shape is lit — the one thing they must not do. It is also why the lit segment resolves through it rather than comparing the raw preference: the held tool may be one this class forbids. The panel takes `2xl:w-80`. The extra 32px is headroom for a class naming three shapes, not a fix, and it is withheld below 1536px on purpose — `ANNOTATOR_MIN_VIEWPORT_PX` is 768, where a collapsed rail already leaves a 384px stage, and a width chosen on a large monitor must not be charged to the smallest screen the editor opens on at all. `EditorNotice`'s clearance arithmetic is stated at 1280px and stays true. cf. #584
…n footer `ClassFields` splits Name | Geometry on `md:`, a **viewport** breakpoint rather than a container one, so on any desktop the grid splits however narrow the box is — at the default `max-w-lg` each column was ~224px against a geometry row needing ~269px, and the checkboxes wrapped onto three lines. The box has to be wide enough for a split it cannot prevent. `2xl` is the smallest that clears it. Second defect, same string: the dialog carried no `max-h` and no scroll, and `DialogContent` is centred with `-translate-y-1/2` — so content taller than the viewport overflowed off both edges and took the footer with it, which a class with a few attributes reaches. cf. #584
`activate()` clicked the row's centre. That worked by about fourteen pixels: once an armed row carries a shape picker, a longer class name or a third shape moves that centre onto a shape segment — and the press would switch the **tool** while `data-selected` still read true, so every assertion around it kept passing. Both markups now put a `-name` handle on the name, so choosing a class has one target whether the row is a plain button or a group. cf. #584
…e docs Found by checking this branch against `main` rather than by a conflict: a class that can hold two shapes makes the orphan gate coarser than the question it stands in for. `car` accepts `bbox · polygon` and the project holds one **bbox** `car`. Taking `polygon` away orphans nothing, and is refused anyway by the refusal no flag overrides. `SchemaDiff.destructive_classes` is a set of *names*, and that set is what reaches `add_schema_version_unless_annotated`, whose predicate asks whether the project holds any `car` — never whether it holds one drawn as the shape being removed. While a class held a single geometry those were the same question. Conservative in the safe direction: it refuses rather than orphaning, so nothing is lost and no invariant moves. Pinned rather than fixed because making it exact changes the port method and the guarded-insert contract #589 landed for the TOCTOU race — argued in #592, which the test names and which `docs/schemas.md` links. The test is a tripwire for that work: invert it when the gate learns about geometry, never quietly delete it. cf. #584, #592
… know Integration with what landed on main while this branch was open — three places where code that arrived after the geometry sweep still spelled a class's shape singular, and one that is a real defect rather than a rename. `SchemaEditor`'s `canonical` projection — #583's fix for a draft that read as dirty against the version it had just published — compared `declared.geometry`. That field is gone, and swapping in the array is not enough: the two sides spell one set differently. The domain sorts and dedupes, so the active version always reads canonical, while a draft's copy is whatever order the boxes were ticked in. Untick the shape a class already had and tick it back, and the editor offered to publish a version identical to the one in force. Verified by breaking it: with the sort removed, `does not call a reordered geometry set an unsaved change` fails and the other nine in that file pass. Asserted on `dirty` rather than through a save, and the reason is worth writing down — going through a save **cannot see this**. The draft is re-based onto the wire's own copy afterwards, so both sides come out canonical whatever the comparison does; the first version of this test round-tripped a save, passed, and passed just as happily with the fix removed. The other two are renames in fixtures that arrived with #590 and #591: `test_schema_refusals.py` and `test_batch_tools.py` posted the retired `geometry` key, which `LabelClassBody` refuses with `extra_forbidden` — the wire is strict on purpose while `LabelClass` still lifts the old spelling, so stored documents keep loading and a client sending it is told rather than reinterpreted. cf. #584
#591's own addition to the cycle, written before a class held a set. The wire refuses the singular spelling with `extra_forbidden`, so the publish that walk makes to prove a pin advances answered 422 and the whole cycle failed on it. The browser suites were again the only ones that saw it: the class list this line extends comes back off the wire already plural, so nothing in Python or in vitest reads this payload. cf. #584
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #584.
A label class accepts a set of geometries
LabelClass.geometrybecomesgeometries, a non-empty deduplicated tuple kept in one sortedorder. A class labelled as a box on some frames and as an outline on others is one class;
splitting it into
carandcar_polygonmade every consumer downstream re-unify them, andthe COCO exporter's own docstring already recorded the cost.
Identity stays the class name per schema version. Geometry becomes a per-annotation property
constrained by the class's set. No composite
(name, geometry)identity.Kernel
union, which is wider as soon as two classes accept different shapes. No new error class;
DisallowedGeometrynames the set.schema_diffanswers per geometry, the shape theselectoptions rule already had: oneadded is additive, one removed is destructive. That falls straight out of the module's own
governing question, and it is what makes widening an ordinary save while narrowing stays
behind
allow_destructive.annotation_schema.classesis a JSON column, so amodel_validator(mode="before")onLabelClassreading the retired singular key coversstored schemas and stored release manifests alike — and MCP, which takes the domain model
directly, so an agent written against either spelling works.
LabelClassBodydeliberatelydoes not read it: a REST client sending the old key is better told so than silently
reinterpreted.
FORMAT_VERSIONstays 8.frozenset.release.canonical_byteshashesmodel_dump(mode="json")and a set's iteration order is not stable across processes, so a set-valued field would make
a release hash irreproducible. Pinned by a test.
MANIFEST_VERSION1 → 2, sinceManifest.classescarries these verbatim. Documentsalready published keep their bytes and their hashes and still load, so every existing release
stays verifiable — asserted.
Export — behaviour unchanged, report corrected
Every exporter already branches on the geometry an annotation holds, never on its class, so
formats/{coco,yolo,voc,lanes}needed no logic change.One latent assumption is fixed, the one §5 of the dispatch anticipated:
ReleaseService._compatibilitykeyed its report by class with a singleGeometryTypeeach. Aclass holding boxes and polygons is two answers under YOLO — the boxes written whole, the
polygons reduced — and one row could carry only one of them, misdescribing half its own output
whichever it picked. The report is now one row per
(class, geometry);ClassCompatibilitykeeps its scalar
geometryfield and a class simply contributes several rows. This movesopenapi.json, the generated client andvisionset export's table; no UI renders these rows.Wire and generated artifacts
geometries: string[]on input and output. Regenerated and committed:openapi.json,frontend/ui-core/src/generated/{api,checks}.ts,tests/fixtures/wire_annotations.json,docs/mcp-tools.md.Annotator core
A tool can no longer be derived from a class alone, so
toolForgains the tool the hostholds and resolves: it keeps that tool when the class accepts it and falls to the class's
first drawable geometry when it does not. An active tool the selected class forbids is
unrepresentable, because one function decides and it never returns one.
InputHostgainsactiveTool. Without itactivate-classcompares two class defaultsrather than two resolved tools and misses a real move — a host drawing polygons under a
both-shapes class that switches to a boxes-only one does change tool, and a polygon in flight
has to be cancelled.
isTaggableClassanddrawableGeometriesstop being each other's negation, since a class mayaccept a tag and a shape.
classActiontherefore sendstoggle-tagonly when the classdraws nothing: folding a drawable class into it would tag the asset where somebody pressing a
class digit meant to arm it.
Schema editor and the rescue flow
The single-select becomes a checkbox group under the same category headings, using the
native input this form already uses for an attribute's
requiredflag — no new dependency andno new primitive. The last ticked box does not come off and carries why (
title+aria-disabled, never the real attribute, so the keyboard still reaches the explanation).A name the published version already declares is an offer, not an error. The alert says what
the class accepts today and what publishing would add; the primary reads
Add polygon to “sign”. It carries the existing class's colour and attributes — a form opened to make a newclass must not quietly wipe what the old one declared — and goes out through the ordinary
create_versionpath, so it is an ordinary additive schema change. The refusal that remains isa name typed twice in one sitting, which has nothing to offer because both entries are being
written now.
composeVersionreplaces a same-named class in place: two classes with one name is whatcreate_versionrefuses outright, and appending would also renumber the digit hotkeys, whichare positions in the authored order.
Tool strip
Narrows to the held class's own geometries; with no class selected it is the schema's union, as
before. The page holds the preferred tool beside the drawing class at job scope, for the
query-key reason the drawing class is already there. Pressing a tool the held class already
accepts changes only the tool and never the class — a strip that re-armed the geometry's
first declaring class would silently retarget somebody's labels.
Two defects found while building this
preventDefault()on a checkbox's click does not cancel React'sonChange. The firstdraft refused the last ticked box that way; React synthesises a checkbox's
onChangefromthe same native click, so the class went to an empty set while the tick stayed on screen — a
control lying about what it had just done. The refusal now lives where the value is computed,
which cannot come apart. The new test is what caught it.
export_wire_fixtures.pyassertedpopulated.geometry is GeometryType.BBOXand wouldhave silently regenerated a fixture from a different class had the assertion not been there.
Tests
pre-A label class accepts a set of geometries, not one #584 schema row (rewritten through the store, since no service can produce that state)
still loads; a v1 manifest still parses;
canonical_bytesis deterministic; the diff callswidening additive and narrowing destructive.
a whole half and a degraded half, COCO carries both.
performs,
composeVersion's four rules, the tool strip's narrowing, and both directions ofthe retarget guard.
cycle.spec.tsnow authors a two-shape class against a realcreate_version.Every new rule was verified by breaking it. Two mutation batteries, 17 cases, each asserting
its anchor before and after and reverting by the recorded diff with a clean-tree check between
cases. Two came back green and both were real gaps, now closed: no fixture had a class that was
both taggable and drawable, and none had a two-shape class to see the retarget guard. The
retarget site is a conditional, so both directions were mutated separately.
Test plan
Full
bash scripts/check.sh, run in stages against the ~10-minute harness ceiling, plus thedocsgroup (opt-in, and this change editsdocs/). Every stage's exit code:uv run pytest tests/kernel tests/cli tests/mcp -quv run pytest tests/server tests/formats tests/inference tests/jobs -quv run pytest tests/architecture tests/examples tests/packaging tests/scripts tests/test_versioning.py -qruff check·ruff format --check·mypy src/visionset·lint-importsbash scripts/check.sh generatedbash scripts/check.sh frontendbash scripts/check.sh browserbash scripts/check.sh docsThe test directory list was read from
ls tests/at run time, not remembered.Found, not fixed
SchemaEditor.save()checks only dirty + blank name, so a collision there still surfaces as a raw
INVALID_SCHEMAbanner title after Save. Widening a class is already possible there by editing the class
directly, so no rescue flow was added — but the two surfaces now answer the same mistake
differently.
INVALID_SCHEMAcarries no structureddetail. The colliding class name is prose insidemessage, whichdocs/api.mdsays is not part of the contract. Nothing here depends on it —the rescue flow reads the schema the dialog already has — but a client wanting to build the
same flow from the wire alone cannot.
INVALID_SCHEMAis not inREFUSAL_PROSE, so it renders with its raw code as the alerttitle. Pre-existing.
Flags for Armando
to arm the first polygon class from anywhere; with a class selected the strip now shows only
that class's shapes, so switching to another class's geometry goes through the class list.
This is what the dispatch specifies, and
DESIGN.mdnow states it — worth a look in thebrowser before it ships, since it changes a gesture people have.
MANIFEST_VERSIONmoved to 2. Nothing refuses a higher version today (the field isdocumentary,
ge=1), so the bump buys an honest record rather than a behaviour. I did notadd the refusal check — no caller asked for one. Say if you want it.
the back-compat read lives. Stated in the tool docs rather than hidden. REST accepts only the
new one.
alternative — letting the dialog's blank colour through — would overwrite what the class
already declared. It does mean the colour picker on screen is not what gets published in that
one case; the alert says so.
describeGeometriescopy ("One shape for now. Tick another and this class acceptsboth.") is mine, not yours. Reword freely.
cf. #584
The workspace surfaces, after a class became a set
Five commits on top of the above, from looking at the running app rather than at a test. Only
one of the four findings was the reported symptom.
One vocabulary for geometries, and it is not the wire's
The interface was showing users database identifiers, in two vocabularies.
ToolPalettehada private
TOOL_LABELSsayingBox; class rows, the reassignment menu, the add-a-class dialog'scheckboxes and prose and its primary button, the schema editor's badges and the project summary
all printed the raw
GeometryType. So one thing wasBoxon the left of the canvas andbboxon the right, and a tag class's row read
classification_tag.GEOMETRY_LABELSnow lives besideGEOMETRY_CATEGORY, total over the union bysatisfies.Lowercase —
box · polygon · polyline · tag— because the same word is read as a chip in a rowand inside a sentence; the strip capitalises at its own control, so there is one source and one
transform. The test states the rule as never starts with a capital rather than is
lowercase, which is a correction the test itself forced:
3D boxis an acronym and the stricterrule would have demanded
3d box, wrong in every position.It is also the largest width saving available in the class list — a tag class's row spent ~110px
of 248 on
classification_tagand now spends 22 ontag, against the 32px widening the entirepanel buys.
The armed class row is the shape picker
Arming a class stopped answering which shape the next drag produces, and the only place that
answer lived was the tool strip at the far left of the canvas while the class was chosen on
the right — one decision split across the width of the picture, in a loop repeated hundreds of
times a job.
The armed row's geometry words become a segmented control: active shape lit, pressing another
switches the tool and never the class. That retarget rule already shipped in
ToolPaletteand is tested in both directions, so the panel is a second caller of an existing rule.
Only the armed row, and the accessible answer and the density answer agree.
ClassListRowisdocumented as "a real
<button>spanning the whole row" and HTML forbids interactive descendantsinside a button — so a row offering a choice becomes a group with an inner name button. That is
also what you want at fifty classes: an unarmed row has no live choice, and fifty pickers for one
decision is noise. Exactly one row is armed, so the extra tab stops are bounded at (shapes − 1).
toolForClassis extracted fromtoolForand exported, because a list iteratingschema.classesholds the class and has no document; writing
drawable.find(…) ?? drawable[0]in the panel wouldbe two spellings of one fallback, which is how a strip and a panel come to disagree about which
shape is lit. It is also why the lit segment resolves through it rather than comparing the raw
preference — the held tool may be one this class forbids.
The dialog, and a second defect on the same line
ClassFieldssplits Name | Geometry onmd:, a viewport breakpoint rather than a containerone, so on any desktop the grid splits however narrow the box is: at the default
max-w-lgeachcolumn was ~224px against a geometry row needing ~269px.
max-w-2xlis the smallest that clearsit.
Same string, second defect: the dialog carried no
max-hand no scroll, andDialogContentiscentred with
-translate-y-1/2— content taller than the viewport overflowed off both edges andtook the footer with it, which a class with a few attributes reaches.
Panel width — measured, and mostly declined
The emptiness in the reported screenshot is vertical, not horizontal.
2xl:w-80gives 32pxof headroom at 1536px and nothing below it, because
ANNOTATOR_MIN_VIEWPORT_PXis 768, wherea collapsed rail already leaves a 384px stage — a width chosen on a large monitor must not be
charged to the smallest screen the editor opens on.
EditorNotice's clearance arithmetic isstated at 1280px and is untouched.
A fragility found in the suite itself
activate()in the cycle spec clicked the row's centre, which worked by about fourteenpixels. Once an armed row carries a picker, a longer name or a third shape moves that centre onto
a shape segment — and the press would switch the tool while
data-selectedstill read true, soevery assertion around it kept passing. Both markups now expose a
-namehandle.Tests
Seven new panel tests and six vocabulary tests. Mutation-verified: 7 cases, 7 red, each
naming its intended test — every row sprouting a picker, a single-shape class sprouting one, the
picker moving the class, the lit segment reading the raw preference, a tag counting as a drawable
shape, the labels collapsing back to wire values, and the middot reverting to "or". Committed
before the first mutation and reverted by the recorded diff with a clean-tree check between cases.
Checkboxes are addressed by
data-testid, which keeps the wire value, so a test says whichgeometry without also asserting what it is called — that belongs to
geometryCategory.test.ts.Rebased onto main, and what that turned up
Three PRs landed in
schema_service.pywhile this branch was open — #583, #589, #590 — plus#591, which rewrote the add-a-class dialog this branch also touches. Rebased onto
7d99bae.The conflicts were adjacency and resolved by keeping both sides; three things needed judgement.
The repin step is gone, and its tests go with it. #591 moved the pin into the kernel, so
runAddClassis save → publish andsteps.repinno longer exists. This branch'swhen the batch will not take the pinblock was dropped rather than merged — it drives aparameter that is not there.
composeVersion's own tests stay.SchemaEditor's draft comparison had a real defect, not a rename. #583'scanonicalprojection compared
declared.geometry. Swapping in the array is not enough: a set has no order,and the two sides spell it differently — the domain sorts, the draft holds tick-order. Untick a
shape a class already had and tick it back, and the editor offered to publish a version identical
to the one in force. Fixed with a sort, and verified by removing it, which reddens
does not call a reordered geometry set an unsaved changeand nothing else.That test asserts
dirtyrather than round-tripping a save, and the first version of it didround-trip — passed, and passed just as happily with the fix removed. A save cannot see this,
because the draft is re-based onto the wire's own copy afterwards and both sides come out
canonical whatever the comparison does. Worth stating because it is the second time on this
branch that a test which looked right proved nothing.
The cycle spec posted the retired key. #591's own real-server walk was written before a class
held a set, so its publish answered 422. The browser suites were again the only ones that saw it:
the class list that line extends comes back off the wire already plural, so no Python or vitest
test reads that payload.
A limit this branch creates, stated rather than shipped quietly — #592
Removing one of a class's geometries is refused whenever any annotation exists under that
class, even when none of them carry the shape being removed and nothing would be orphaned.
SchemaDiff.destructive_classesis a set of names, and that set is what reachesadd_schema_version_unless_annotated, whose predicate asks whether the project holds anycar—never whether it holds one drawn as the shape being dropped. While a class held a single geometry
those were the same question; a set makes the gate coarser than the question it stands in for.
Conservative in the safe direction — it refuses rather than orphaning — so no data is lost and no
invariant moves. Not fixed here because making it exact changes the port method and the
guarded-insert contract #589 landed for the TOCTOU race, which needs its own red-before-green pass
on the concurrency tests. Filed as #592, documented in
docs/schemas.md, and pinned bytest_dropping_one_geometry_of_several_is_refused_by_the_class_not_the_shape— a tripwire toinvert when the gate learns about geometry, not to delete.
Verified end to end against the kernel
The loop that motivated the change, run for real on the rebased tree: a batch pinned to v1 where
caracceptsbbox, publishcaracceptingbbox · polygon— additive, no flag — the pinadvances by itself (#591), and a polygon
caris then accepted into that already-open batch.Gate
Re-run in full on the rebased tree, in stages against the ~10-minute ceiling.
pytest(kernel, architecture, formats, versioning)pytest(server, cli, mcp)pytest(jobs, inference, examples, packaging, scripts)ruff format --check/ruff check/mypy src/visionset/lint-importsbash scripts/check.sh generatedbash scripts/check.sh frontendbash scripts/check.sh browserbash scripts/check.sh docsFound, not fixed
ClassFields' grid should be a container query. Tailwind v4 supports@container, and agrid splitting on the viewport inside a fixed-width box is the root cause of the dialog being
undersized. Not taken here because it would make the dialog stack rather than widen, which is
the opposite of what was asked — but it is the better fix and the width is the workaround.
b/pare unclaimed; digits arm classes andvis select.A keyboard-first annotator cannot switch shape at all. Recorded rather than invented under time
pressure.
Flags for Armando
and DESIGN.md states the ceiling. This is the accepted cost of words over glyphs.
class when the held one cannot draw the shape pressed. Two controls, one state — worth
confirming it feels right in the browser, which is what Tier B is for.
GEOMETRY_LABELSnames the four unimplemented geometries too (mask,keypoints,3D box,3D polyline). They reach a user only through a refusal today. Reword freely.