feat(inference): ModelProvider port, local adapter, weight download (#418 slice 2) - #434
Merged
Conversation
…418 slice 2) The port re-shaped from a placeholder into a designed one, the first adapter that implements it, and the download that takes a local connection from not_set_up to ready. The port was predict(asset, schema) -> Sequence[Annotation], which failed the dual test #418 sets in three ways. Each is answered: per asset -> per batch, so a hosted provider pays one round trip for a chunk rather than one per image; a materialised sequence -> an iterator, so a slow provider has somewhere to be and a caller reports progress between yields without the port naming a reporter, a queue or a future; an Annotation -> an AssetPrediction, because a model produces a claim rather than a schema-validated label. A target carries bytes, not a path: handing over an Asset and letting the provider read the file is exactly the spelling a remote adapter cannot implement. visionset.inference is a new sibling of formats, wire and jobs, and every heavy import lives inside a function so a base install still starts a server and runs a worker. The two Phase 0 findings ship as code with tests: fp16 needs autocast plus a grid_sample dtype cast, and raw output needs cross-box NMS. download_weights joins ConnectionAction in the same change as the route, the command and the job that perform it, gated on local + not_set_up. There is no test action and no annotation writing anywhere in this slice. cf. #418, #421, #424
This was referenced Aug 8, 2026
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.
The
ModelProviderport re-shaped from a placeholder into a designed one, the first adapter thatimplements it, and the weight download that takes a local connection from
not_set_uptoready.cf. #418(the port),cf. #421(the connections it is instantiated from),cf. #424(the firstconsumer). This is slice 2 of #418 and does not complete it — the hosted adapter is a later slice.
What changed
kernel/ports/model_provider.py) — waspredict(asset, schema) -> Sequence[Annotation], declared to fix a name and documented as aplaceholder. It failed ModelProvider: declare the kernel port for autolabeling — local and cloud adapters both ordinary #418's dual test (must hold for a local runner and a hosted service) in
three ways, and each is answered: per asset → per batch, so a hosted provider pays one round
trip for a chunk rather than one per image; a materialised sequence → an iterator, so a slow
provider has somewhere to be and a caller reports progress between yields without the port naming
a reporter, a queue or a future; an
Annotation→ anAssetPrediction, because a modelproduces a claim and not a schema-validated label.
kernel/domain/prediction.py) —PredictionRequest/PredictionTarget/TextPrompt/PointPrompt/AssetPrediction/PredictedRegion. A target carries bytes,not a path: handing over an
Assetand letting the provider read the file is precisely thespelling a remote adapter cannot implement.
visionset.inference(new package, sibling offormats/wire/jobs) —LocalTransformersProvider, cross-box NMS, the fp16 shims, the weight download, andprovider_for(), the composition root that resolves a connection by kind.local-inferenceextra — torch, transformers, accelerate, huggingface_hub. Every one isreached from inside a function, and an architecture test proves the base import graph stays
clear of all four in a fresh interpreter.
download_weightsjoinsConnectionAction, legal iff the kind islocalandthe state is
not_set_up, in the same change as the route, the command and the job that performit.
testis still absent.POST /inference/connections/{id}/download(202 +Location, the export route'scontract) and
visionset inference download <name|id>with--json.openapi.jsonand thegenerated TS client are regenerated and committed.
docs/inference.mdgains the extra, the download flow, the cache location, the CPUfallback and six new error rows;
docs/install.mdgains a section;docs/cli.mdanddocs/README.mdfollow.No annotation is written anywhere in this slice, no HTTP adapter, no
testaction, and no UI.The two spike findings, as code with tests
Both are from #418's findings comment, and both have a test that fails if the code is removed.
torch.autocastaround the forward plus agrid_samplethat casts itssampling grid to the input's dtype — autocast does not cover
grid_sample, which is why thespike hit it inside deformable attention. Scoped to the forward and restored in a
finally,unlike the spike's permanent module-level swap. Proved twice:
tests/inference/test_fp16.pydrives the rule with stand-ins that carry a
dtypeand ato(runs everywhere, including withno torch installed), and
test_a_half_precision_grid_sample_survives_a_float32_grid_on_cudareproduces the originalfailure against real half-precision CUDA tensors — skipped without a GPU, so it does not run in
CI.
anything leaves the adapter. Cross-box rather than per-label, because a multi-phrase prompt
finds one animal and calls it two things.
Design decisions worth review
The port names no entry-point group, and that is the answer rather than an omission. #418 asks
for one or an argument against. A format is a plugin — a third party ships one and the product must
discover it. A provider is not: the recorded decision is that adapters are instantiated from
user-created connections, so
InferenceConnectionis the registry, and a provider discovered byentry point would have nothing to be instantiated from. Worse, a workspace could acquire the ability
to predict through an unrelated
pip install, which is what "VisionSet never downloads a model onits own" exists to prevent. Resolution is a
matchonConnectionTypein the composition root.PointPromptships unserved. The port takes a discriminated prompt — words or a place on theimage — and this adapter is a detector, so it refuses points with
UNSUPPORTED_PROMPT. #424 is thenext issue and is point-prompted; designing the payload for one way of asking would mean reshaping
the port for it. Neither variant encodes a model's API: the model-specific knobs (per-head
thresholds, tokenizer punctuation) stay in the adapter, and the port carries only
minimum_confidence, which every model can honour.retryingon the download gate.sweep_orphansre-enqueues an idempotent orphan as a newjob, so a crash between the state flip committing and the row settling produces a second run against
a connection that is now
ready. Refusing it would fail a job whose work is done.retrying=Truerelaxes the state half of the gate and only that half — a kind with no weights of its own has
none on the second attempt either — and it is passed by the job handler and by nobody else.
Two 5xx errors, on
MediaToolUnavailable's licence.LOCAL_INFERENCE_UNAVAILABLEandINFERENCE_CONNECTION_NOT_RUNNABLEare conditions of the installation, not of the request: no statechange and no retry makes either succeed, so 409 would promise something it cannot keep and 503
would promise transience. Both expose their message because the message is the remedy — the exact
pip installfor the first, and which kind this build has no adapter for in the second.download_weightsstays declared on a machine without the extra. Whether torch is installed isnot a fact about the connection, and no pure function over domain values can see it. The action is
offered and the request is refused with the install command, which is #421's stated design and
design principle 9 — never a bare disabled control.
Deliberate exclusions
testaction. The remote endpoint contract is a later slice, and under the capabilitiescontract a declared action obliges every conforming client to render a control (Batch deletion: route, redeclaration, and UI #376).
(batch); Batch pre-labeling — unattended model annotation as a background job #425's atomicity requirement is neither implemented nor precluded here.
tests/architecture/test_capability_reachability.pystays batches-onlyfor the reason its own docstring gives — it requires an MCP tool as well as a route — so the
declared-⇔-reachable half for connections is proved over HTTP in
tests/server/test_inference.py.setup_statealready exists; this slice adds no column and no table.Mutation verification
Both rules were broken deliberately, with the anchor asserted present-exactly-once beforehand, and
reverted by exact recorded diff (
git apply -R). The tree was committed before the first mutationand verified clean after each revert.
suppressed(...)call fromregions_fromtests/inference/test_provider.py::test_duplicate_detections_are_suppressed_before_they_leave_the_adapterdownload_weightsgateCONNECTION_GATES[DOWNLOAD_WEIGHTS]toEVERY_SETUP_STATEtests/kernel/test_capabilities.py::test_download_weights_is_declared_on_exactly_one_squareTest plan — staged gate, exit codes verbatim
Run in stages because the harness kills long commands; pytest stages derived from
ls tests/at runtime. Toolchain: Node 24.19.0 + pnpm 10.30.2 (the pinned pair).
ruff check .ruff format --check .mypy src/visionsetlint-imports(4 contracts kept, 0 broken)pytest tests/architecture tests/fixtures tests/formats tests/inference tests/jobs tests/packaging tests/scripts tests/test_versioning.pypytestkernel part A (annotation … capabilities)pytestkernel part B (concurrency … ingest_service)pytestkernel part C (job_concurrency … project_service)pytestkernel part D (release … workspace_service)pytest tests/clipytest tests/serverpytest tests/mcppytest tests/examplesexport_openapi.py+git diff --exit-code openapi.jsonexport_mcp_tools.py --checkpnpm generate:client:checkpnpm version:checkpnpm -r buildpnpm testpnpm -r lintCI=1 playwright testCI=1 playwright test -c playwright.cycle.config.tsThe two non-zero stages are the same pre-existing environment gap and are not caused by this
branch:
ffmpeg/ffprobeare not installed on this machine, so every failure isMediaToolUnavailable. Ten failures in total — 9 intests/kernel/test_video_processor.pyandtests/kernel/test_source_service.py, 1 intests/server/test_sources.py. Identical set, identicaltest ids, to the ones recorded on #430 against clean
origin/mainat08e1483. CI has ffmpeg andis the arbiter.
One flake, named because a reader of a transcript would otherwise see it. Kernel part B first
came back with 91 errors, all
FileNotFoundError: /tmp/pytest-of-anaya/pytest-60— pytest's ownnumbered-tmpdir garbage collection removing the running session's root, not a code failure. Re-run
alone: 345 passed, exit 0. That is the number in the table.
The lockfile
uv.lockgains eight packages (accelerate, hf-xet, huggingface-hub, regex, safetensors, tokenizers,tqdm, transformers) and no incidental upgrades. torch and numpy were already locked as
transitive dependencies of the
yologroup.The cool-down was applied and it caught something:
bash scripts/cooldown.sh uv lockselectedhuggingface-hub 1.26.0, while an unrestricted resolve took1.27.0, published 2026-08-07 —inside the three-day window. The lock carries 1.26.0.
Worth knowing for the next person:
uv lockunder the wrapper writesexclude-newerinto thelockfile itself, and
uv sync --lockedthen refuses it with "Ignoring existing lockfile due toremoval of global exclude newer" — which is what CI runs. So the sequence used here was: lock under
the cutoff to learn which versions the rule permits, lock without it, then
uv lock -P "huggingface-hub==1.26.0"to hold the one version that differed. The result satisfies the cool-downand
uv sync --lockedpasses. Every added package's newest wheelupload-timein the committedlock is older than the cutoff; this was checked rather than assumed.
Found, not fixed
ffmpeg/ffprobeare absent on this development machine, which makes 10 media tests unrunnablelocally. An environment gap rather than a defect, recorded here because a reader of this gate
record should not have to rediscover why two stages are non-zero. Unchanged since feat(inference): InferenceConnection entity, persistence, wire, REST, CLI (#421 slice 1) #430.
scripts/cooldown.shdocumentsuv lockas a supported wrapped command, but a lockfile producedthat way is rejected by
uv sync --locked. The workaround above is in this PR; the script'sguidance is not touched, because fixing it is not this slice's scope.