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
9 changes: 5 additions & 4 deletions frontend/ui-core/src/annotator/AnnotationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,10 +372,11 @@ export interface AnnotationPageProps {
* Where somebody goes to set up a model connection, if the app has such a
* screen (#424, D6).
*
* Optional, and it is expected to be absent for now: the Inference surface
* waits on #421's open rail question, and `ui-core` imports no router. Absent,
* the suggest tool's panel still says what is missing and simply renders no
* control — a host that cannot honour one renders none rather than a dead one.
* Optional because `ui-core` imports no router and cannot know whether its
* host has such a screen — the app does, and wires this to the Inference
* section. Absent, the suggest tool's panel still says what is missing and
* simply renders no control: a host that cannot honour one renders none
* rather than a dead one.
*/
readonly onConfigureInference?: () => void;
}
Expand Down
1 change: 0 additions & 1 deletion frontend/ui-core/src/data/inferenceQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ export interface SuggestionOut {
readonly region?: SuggestedRegion | null;
}

export type ConnectionAction = components["schemas"]["ConnectionAction"];
export type ConnectionType = components["schemas"]["ConnectionType"];
export type ConnectionSetupState = components["schemas"]["ConnectionSetupState"];
export type DownloadSizeOut = components["schemas"]["DownloadSizeOut"];
Expand Down
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,12 @@ dependencies = [
#
# The canonical spelling is `local-inference`; it supersedes the earlier
# `autolabel` framing. Version floors rather than pins: this is a user's
# environment and their CUDA build decides most of it. `uv.lock` does not carry
# extras, so nothing here is resolved by a `uv sync` in this repository.
# environment and their CUDA build decides most of it. `uv.lock` does record the
# extra — once as a member list, once per member under `requires-dist` — so
# editing these lines without re-running `uv lock` leaves it stale and
# `uv lock --check` fails on the result. What a plain `uv sync` does not do is
# *install* it: that takes `--extra local-inference`, which is how two gigabytes
# of CUDA wheels stay out of a developer's environment by default.
#
# `torchvision` is here because SAM 2's image processor imports it, and nothing
# else in this list brings it: `Sam2ImageProcessor` refuses to construct without
Expand Down
9 changes: 4 additions & 5 deletions src/visionset/inference/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@
**Importing this package imports nothing heavy.** Every reference to torch,
transformers, accelerate and huggingface_hub is inside a function, and
torchvision — which nothing here names, and ``transformers`` imports for us — is
held to the same line — see
``_extra`` for why that is load-bearing rather than tidy — so a base install
starts a server, runs a worker and imports this module without the optional
runtime present. ``tests/architecture/test_optional_runtime.py`` proves it in a
fresh interpreter.
held to the same line. See ``_extra`` for why that is load-bearing rather than
tidy: a base install starts a server, runs a worker and imports this module
without the optional runtime present, and
``tests/architecture/test_optional_runtime.py`` proves it in a fresh interpreter.

**There is no plugin registry, and resolution happens in two steps.** #418's
recorded decision is that adapters are instantiated from user-created model
Expand Down
4 changes: 2 additions & 2 deletions src/visionset/inference/sam_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def points_and_labels(prompt: PointPrompt) -> tuple[list[list[float]], list[int]
return points, labels


def best_of(iou_scores: list[float], masks: list[Any]) -> tuple[int, float]:
def best_of(iou_scores: list[float]) -> tuple[int, float]:
"""Which of the multi-mask answers to offer, and how sure it is.

A segmenter of this family answers a single click with several masks at
Expand Down Expand Up @@ -256,7 +256,7 @@ def _regions(
outputs.pred_masks, original_sizes=[list(size)], binarize=True
)[0]
scores = [float(value) for value in outputs.iou_scores.flatten().tolist()]
chosen, confidence = best_of(scores, lifted)
chosen, confidence = best_of(scores)
if confidence < minimum_confidence:
return ()
mask = lifted.reshape(-1, *lifted.shape[-2:])[chosen]
Expand Down
7 changes: 3 additions & 4 deletions tests/architecture/test_optional_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
huggingface_hub in the `local-inference` extra, and `visionset/inference/_extra.py`
reaches every one of them from inside a function — torchvision by never naming it
at all, since it is `transformers` that imports it. Neither of those is
self-enforcing: a single
module-level `import torch` anywhere under `visionset.inference` would leave the
metadata unchanged and make roughly two gigabytes of CUDA wheels a condition of
starting the server.
self-enforcing: a single module-level `import torch` anywhere under
`visionset.inference` would leave the metadata unchanged and make roughly two
gigabytes of CUDA wheels a condition of starting the server.

It is a condition of *starting* rather than of running a model because
`visionset.jobs` imports its handler modules at package import to populate the
Expand Down
62 changes: 7 additions & 55 deletions tests/inference/test_provider.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""The composition root's refusals, and the adapter's tensor-to-domain half.
"""What the runtime's presence decides, and the adapter's tensor-to-domain half.

Two subjects, both reachable without the optional runtime and without a GPU:
Two subjects, both reachable without a GPU:

- `provider_for`, which answers "can this connection predict here?" and must
refuse with a sentence rather than a `None` or a stack trace;
- the half of `provider_for` that turns on whether the optional runtime is
installed here — the refusal that names the install command when it is not,
and structural conformance to the port when it is. The refusals that turn on
the *connection* instead are `test_providers.py`'s, beside the resolution
they belong to;
- `regions_from`, the conversion the adapter does after a forward — the one part
of running a model that can be wrong in a way no weights are needed to see.

Expand Down Expand Up @@ -35,8 +38,6 @@
TextPrompt,
)
from visionset.kernel.errors import (
InferenceConnectionNotRunnable,
InferenceConnectionNotSetUp,
LocalInferenceUnavailable,
UnsupportedPrompt,
)
Expand All @@ -59,58 +60,9 @@ def local(
)


def remote() -> InferenceConnection:
return InferenceConnection(
name="remote",
connection_type=ConnectionType.HTTP,
model_id="some/model",
model_revision="abc123",
endpoint_url="https://example.invalid/predict",
setup_state=ConnectionSetupState.READY,
)


# --- resolving a connection to something that can answer ----------------------


def test_a_local_connection_without_weights_is_refused_by_state(tmp_path: Path) -> None:
"""And the message names the action that fixes it.

"Not set up" alone tells an operator what they already knew; the remedy is
the point, and it is a real one — the identical call succeeds after the
download.
"""
with pytest.raises(InferenceConnectionNotSetUp) as raised:
provider_for(local(), workspace_root=tmp_path)
assert "download_weights" in str(raised.value)


def test_an_http_connection_is_refused_because_this_build_has_no_adapter(
tmp_path: Path,
) -> None:
"""A different refusal from the one above, deliberately.

No state change fixes this and no wait helps — the adapter that would speak
to an endpoint is a later slice — so it must not be the error whose whole
meaning is "change the state and resubmit".
"""
with pytest.raises(InferenceConnectionNotRunnable) as raised:
provider_for(remote(), workspace_root=tmp_path)
assert "http" in str(raised.value)


def test_the_connections_own_state_is_reported_before_the_machines(tmp_path: Path) -> None:
"""Order of the two checks, and it matters to whoever reads the answer.

A not-set-up connection on a machine with no extra has two problems. Telling
somebody to run a `pip install` when what they needed was a download sends
them to the wrong place; the state is the one they can act on from where they
are standing.
"""
with pytest.raises(InferenceConnectionNotSetUp):
provider_for(local(), workspace_root=tmp_path)


@pytest.mark.skipif(EXTRA_INSTALLED, reason="the local runtime is installed here")
def test_a_ready_connection_without_the_runtime_names_the_install_command(
tmp_path: Path,
Expand Down
4 changes: 2 additions & 2 deletions tests/inference/test_sam_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,11 @@ def test_a_prompt_with_no_negatives_still_labels_every_point() -> None:

def test_the_highest_scoring_mask_is_the_one_offered() -> None:
"""A click is ambiguous about scale; the model's own IoU head is what resolves it."""
assert best_of([0.2, 0.91, 0.5], [[], [], []]) == (1, 0.91)
assert best_of([0.2, 0.91, 0.5]) == (1, 0.91)


def test_a_score_outside_the_domains_bounds_is_clamped_rather_than_refused() -> None:
assert best_of([1.0000001], [[]])[1] == 1.0
assert best_of([1.0000001])[1] == 1.0


# --- what it refuses ----------------------------------------------------------
Expand Down
Loading