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
2 changes: 1 addition & 1 deletion .agents/skills/domain/batch-lifecycle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The kernel tables are authoritative. Quote them; never re-derive them.
9. **Closure is a job-level fact, and the workspace reads it at job level.** Read-only is a *transition*, not only an entry state: finishing a job flips the open workspace in place — same window, every frame, from the re-read declaration. Two rulings come with it, and both look like untidiness to a later reader:
- **Frame-verb gating is job-level, never frame-level.** `Skip` / `Un-skip` and the flow verb stop rendering when the *job* is closed (or its batch is), not when the *frame* is read-only. A `skipped` frame is read-only per-frame and still needs its `Un-skip` — the one edge back out of `skipped` — and the navigation cluster is measured to one width, so a slot that emptied and refilled as somebody walked a mixed job would move the arrows under their cursor.
- **`complete` is the job's declaration, not the last frame's.** `Finish job` stays reachable on a frame that is itself settled and read-only: a job whose last frame is `annotated` is precisely the job that is ready to finish. Withdrawing it along with the frame's verbs strands the job with no way to close it.
10. **Unattended writing enters its own editable state through one door, never through `add`.** `AnnotationService.enter_unreviewed` is the only path a model's unattended prediction takes: every annotation must carry `provenance="model"`, the asset must be exactly `unannotated` (narrower than `WRITABLE_PROGRESS`), and the labels commit with the move to `pre_labeled` in the same transaction — never a two-step path through `annotated` that a crash could catch mid-way. The same door may **supersede its own earlier writes**: asked to replace, it removes the `provenance="model"` rows on a frame that is still `pre_labeled` and lands the new ones in the same transaction, leaving the frame `pre_labeled` (or `unannotated` if nothing landed); a frame anybody edited, confirmed or skipped is refused (`AssetNotWritable`). Eligibility is progress, never provenance (decided on #683). `unannotated → pre_labeled` is therefore a **derived unnamed edge**, the same shape as the existing `unannotated ↔ annotated` pair: nobody clicks it, `UNNAMED_EDGES` (`kernel/domain/capabilities.py`) computes it from `progress_after_annotating`, and it is the consequence of a batch-level pre-label action, not a per-asset control. `add` never lands a write at `pre_labeled` — accepting a model's *suggestion* by hand is still a person's write and still only ever produces `annotated`.
10. **Unattended writing enters its own editable state through one door, never through `add`.** `AnnotationService.enter_unreviewed` is the only path a model's unattended prediction takes: every annotation must carry `provenance="model"`, the asset must be exactly `unannotated` (narrower than `WRITABLE_PROGRESS`), and the labels commit with the move to `pre_labeled` in the same transaction — never a two-step path through `annotated` that a crash could catch mid-way. The same door may **supersede its own earlier writes**: asked to replace, it removes the `provenance="model"` rows on a frame that is still `pre_labeled` and lands the new ones in the same transaction, leaving the frame `pre_labeled` (or `unannotated` if nothing landed); a frame anybody edited, confirmed or skipped is refused (`AssetNotWritable`). Eligibility is progress, never provenance (decided on #683). `unannotated → pre_labeled` is therefore a **derived unnamed edge**, the same shape as the existing `unannotated ↔ annotated` pair: nobody clicks it, `UNNAMED_EDGES` (`kernel/domain/capabilities.py`) computes it from `progress_after_annotating`, and it is the consequence of a **job-level** pre-label action (`JobAction.PRE_LABEL`, declared on an open job of an `in_annotation` batch), never a per-asset control. A batch-level or project-level launch is a fan-out over open jobs — one `annotation.pre_label` row per job, keyed by the job — and reports one row per job; the batch's `pre_label_run` is the newest of its jobs'. Decided 2026-08-25. `add` never lands a write at `pre_labeled` — accepting a model's *suggestion* by hand is still a person's write and still only ever produces `annotated`.
- `pre_labeled` means labels exist, a model wrote them, and no person has touched them — a state of its own rather than `review_pending`, which means a person finished this and asked for review, and cannot be reused for unattended output without inverting every affordance a reviewer's read-only frame relies on. Its three memberships each carry a reason. It is in `WRITABLE_PROGRESS`, because correcting a prediction is the normal path here and must cost nothing. It is **not** in `PROMOTABLE_PROGRESS` — the reason that matters most: an asset at `annotated` may enter the Dataset when its batch completes, so a model's labels that no person has ever seen must never sit there; provenance already records who wrote a label, and this is what stops that record from also being the thing that lets it into the curated trunk unseen. It is not in `SETTLED_PROGRESS` either, because a batch where every frame carries an unreviewed guess is not done — that is exactly the work still outstanding — so it cannot let a job, and through it a batch, complete. `pre_labeled → annotated` is derived the same way `unannotated → annotated` is — the moment a person edits the frame, `progress_after_annotating` takes it over — and it is also the one derived edge with a name: `AssetAction.CONFIRM` lets a person keep a model's labels as the frame's own without editing them (decided on #676). Labels exist either way; the marker records judgment, and the non-promotable protection is unchanged. `pre_labeled → skipped` is claimed by `AssetAction.SKIP`, the same act that already skips an `unannotated` or `annotated` frame.

11. **The orphan invariant is per-annotation, and releases gate content.** An annotation is valid against **its own batch's pin**, and the trunk may legitimately hold annotations naming a class the *active* schema version no longer declares. That state is reachable without a race: publish a narrowing version while a batch pinned to the older one is still open, then write a label under the class the newer version dropped. It is tolerated in the trunk and refused at the boundary where a frozen artifact is produced — `ReleaseService.publish` revalidates every candidate annotation against the **active** schema and raises `ReleaseContentWouldViolateSchema` (409 `RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA`, per-class blockers in `detail`) before a manifest is written. The active-version alternative — a publish that refuses, closes or re-pins open batches — was considered and declined: it turns a schema decision into an administrative one and inverts what the pin exists for. Decided on #585 and shipped with the release-publish compatibility gate (#694).
Expand Down
2 changes: 2 additions & 0 deletions .agents/skills/frontend/information-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ column carries are in `docs/content/ui/navigation.md`, *Inside a project*.

Rules:

- **Annotate enters by job.** The project's `Annotate` control opens the chosen `in_annotation` batch's one job directly (`/jobs/:jobId`) when the batch has exactly one, and the batch gallery otherwise — the gallery's job panel is where a job is chosen, and it is the only door: the gallery header carries the `approved → in_annotation` transition and nothing that opens the editor. With several open batches the dropdown stays batch-level and the same rule applies to the pick. A panel's `Annotate` takes a `pending` job to `in_progress` before opening it; `Continue` and `View` only open. Panel controls are secondary — the header's transition is the page's one filled control.
- **A batch with jobs shows one job's frames at a time.** The gallery is an accordion of jobs with at most one panel open and every panel closable; the open panel's counts, timeline and frames are that job's, and the panel open on arrival is the first job with work left. A draft batch, having no jobs, keeps the flat grid. The rule is observability: a batch-wide grid beneath a per-job control puts two scopes for the same frames on one screen. Nothing about it changes an address — the gallery route is unchanged and the open panel is not in the URL.
- **A correction batch is reached from the batch that needs correcting**, never from a "new batch" form: the gallery header and the Batches row both offer it on a `completed` batch, capability-gated on `create_correction`. The annotator's read-only banner and the gallery's bulk bar *link* to it rather than duplicating it — creating a batch is a curation act, curation lives on the batch view, and a second place batches are made is a second place the rules can drift.
- **Dataset is first-class.** It is the product's central object and must be reachable in ≤1 click from any project section. It is never gated behind, or discoverable only through, onboarding UI. Promotion success links onward to it; the gallery links to it once a batch is `completed`.
- **The frames blocking a narrowing are a subsection of Schema, not a screen.** They are a *view of* the draft on the editor above them, the same relation version history has to the schema. A row links to **every** batch holding its frame rather than to one: an annotation carries an `asset_id` and no batch, so there is no single annotator address to prefer. The section is omitted entirely when the host wires no batch route, on the rule the Batches section already follows. It shows a window of the frames and states the total as text rather than a "see all": the destination that control would need is a project-wide asset view, and there is none — the count is a property of the proposal, not the length of a list somebody can open.
Expand Down
14 changes: 11 additions & 3 deletions docs/content/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,20 @@ DELETE /batches/{batch_id} ?confirm=true
POST /batches/{batch_id}/approve with a partition spec
POST /batches/{batch_id}/start
GET /batches/{batch_id}/pre-label?connection_id=&geometries= the classes a run would ask for, and the shapes it writes
POST /batches/{batch_id}/pre-label launch; replace_model_labels redoes an earlier pass, geometries narrows the shapes
POST /projects/{project_id}/batches/pre-label launch over every open batch, or the named ones; one job per batch
POST /batches/{batch_id}/pre-label launch over every open job of the batch; one row per open job
POST /projects/{project_id}/batches/pre-label launch over every open batch, or the named ones; one row per open job
POST /batches/{batch_id}/repin ?allow_destructive=
POST /batches/{batch_id}/complete
POST /batches/{batch_id}/corrections a new batch over a completed one
GET /batches/{batch_id}/jobs
GET /batches/{batch_id}/assets paged; ?progress=&sort=
GET /batches/{batch_id}/assets paged; ?progress=&sort=&job=
POST /batches/{batch_id}/assets draft only
DELETE /batches/{batch_id}/assets?id=&id= draft only
GET /jobs/{job_id}
GET /jobs/{job_id}/progress
POST /jobs/{job_id}/start
POST /jobs/{job_id}/complete
POST /jobs/{job_id}/pre-label launch over one job; replace_model_labels redoes an earlier pass, geometries narrows the shapes
GET /jobs/{job_id}/next the next n waiting assets
PUT /jobs/{job_id}/assets/{asset_id}/progress
GET /jobs/{job_id}/assets/{asset_id}/annotations
Expand Down Expand Up @@ -148,6 +149,13 @@ property of the schema rather than a version number a client could guess.
`GET /datasets/{id}/assets` is the curated trunk's. A project page reads the first, a gallery
the second, a release the third.

**One job's frames are the batch listing narrowed, not a listing of their own.** `?job=` keeps
the assets that job carries, composing with `progress` and `sort`, and `total` is the size of
what matched; a job the batch does not have is 404 `JOB_NOT_FOUND`, resolved after the batch
itself, and a draft - which has no jobs - matches nothing rather than refusing. A
`/jobs/{id}/assets` of its own would be a fourth window onto assets a batch already indexes by
job, and two addresses for one page is two places paging, sorting and filtering can drift.

The project listing is **ordered by arrival, newest first**, which `Asset.ingested_at` is what
made possible: until that field existed nothing recorded when an asset arrived, and the listing
was deterministic but arbitrary. A whole ingest run shares one timestamp, so *within* a run the
Expand Down
2 changes: 1 addition & 1 deletion docs/content/architecture/backend/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[`src/visionset/jobs/`](../../../../src/visionset/jobs/) holds the handlers for work
that outlives the request that asked for it: an ingest run, an export, a weights
download, an integrity check, a batch pre-label run.
download, an integrity check, a pre-label run over one annotation job.

## Launch and poll

Expand Down
36 changes: 22 additions & 14 deletions docs/content/background-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,35 @@ handling for one GIL.
| `export.release` | `visionset/jobs/export.py` | `POST /releases/{id}/export` |
| `inference.download_weights` | `visionset/jobs/weights.py` | `POST /inference/connections/{id}/download` |
| `inference.check_integrity` | `visionset/jobs/integrity.py` | `POST /inference/connections/{id}/check-integrity` |
| `annotation.pre_label` | `visionset/jobs/prelabel.py` | `POST /batches/{id}/pre-label`, `POST /projects/{id}/batches/pre-label` (one row per batch) |
| `annotation.pre_label` | `visionset/jobs/prelabel.py` | `POST /jobs/{id}/pre-label`, `POST /batches/{id}/pre-label`, `POST /projects/{id}/batches/pre-label` (one row per job) |

The last three are why the throttled progress below matters: a weight download is
gigabytes, an integrity check reads every byte of them, and pre-labeling is one
forward pass per untouched asset - or, where its payload carries
`replace_model_labels`, per pre-labeled one as well - so all three report for
minutes rather than seconds. `processed` and `total` are counted in **assets** for
pre-labeling, where the other two count bytes and files. None of the four CLI
pre-labeling, where the other two count bytes and files. None of the five CLI
equivalents queues: `visionset inference download`, `visionset inference
check-integrity`, `visionset batch pre-label`, and `visionset project pre-label` run
their shared bodies inline because a terminal has no worker.

They are also the only four launchers that **join a run instead of starting a
second one** — the project launch (`POST /projects/{id}/batches/pre-label`) joins
per batch too: asked for a kind this connection or this batch already has queued
or running, the route answers with that job. It is the route's own read of the
queue rather than anything `enqueue` does, so every other launcher above queues a
fresh job each time it is asked — and because nothing brackets the read and the
enqueue, even these four coalesce the ordinary repetition rather than
guaranteeing uniqueness. `docs/content/inference.md` and `docs/content/batches.md` say what that
buys and what it deliberately does not refuse.
check-integrity`, `visionset job pre-label`, `visionset batch pre-label`, and
`visionset project pre-label` run their shared bodies inline because a terminal has
no worker.

**A pre-labeling row is keyed by the annotation job it is over**, `annotation_job_id`
in its payload beside the `batch_id` that leads to the pinned schema. A batch is
partitioned into jobs and a run reaches one job's assets, so the batch launches are
a fan-out: `POST /batches/{id}/pre-label` queues one row per open job of the batch,
`POST /projects/{id}/batches/pre-label` one per open job of every selected batch,
and each row is polled and cancelled on its own.

The three pre-labeling launches, the weight download and the integrity check are
the only launchers that **join a run instead of starting a second one** — and both
fan-outs join per job: asked for a kind this connection or this job already has
queued or running, the route answers with that job. It is the
route's own read of the queue rather than anything `enqueue` does, so every other
launcher above queues a fresh job each time it is asked — and because nothing
brackets the read and the enqueue, even these coalesce the ordinary repetition
rather than guaranteeing uniqueness. `docs/content/inference.md` and
`docs/content/batches.md` say what that buys and what it deliberately does not refuse.

Verify, publish, promote and thumbnail backfill are still synchronous. They are
future job types, not an oversight - each answers inside its request today and
Expand Down
Loading
Loading