Skip to content

A second architecture answers a point prompt - #599

Open
JArmandoAnaya wants to merge 7 commits into
mainfrom
feat/sam3-point-segmentation
Open

A second architecture answers a point prompt#599
JArmandoAnaya wants to merge 7 commits into
mainfrom
feat/sam3-point-segmentation

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

facebook/sam3 becomes a second driver of the interactive point-segmentation capability that
already exists, alongside SAM 2. It is not a new capability: ModelCapability.POINT_SUGGEST is
unchanged, the dashboard gains no section, the annotator gains no control, and a connection
pointed at SAM 3 becomes a candidate for the suggest tool on the same terms as a SAM 2 one. The
scope and the licence treatment are the decision recorded on the SAM 3 provider issue (#595).

openapi.json, the generated client and VERSION are byte-identical. No migration, no new kernel
service, model, error or event, nothing added to ERROR_RULES, and no CI job added or renamed —
so the main ruleset needs no edit.

The task's premise was wrong in one load-bearing way

The work was specified against Sam3Model and Sam3Processor. Those are the text-and-concept
path
, which this issue puts out of scope. Point prompts run through Sam3TrackerModel and
Sam3TrackerProcessor
, loaded from the same repository with no subfolder. The library's own
auto-maps are unambiguous:

CONFIG_MAPPING_NAMES:     sam3 -> Sam3Config         sam3_tracker -> Sam3TrackerConfig
PROCESSOR_MAPPING_NAMES:  sam3 -> Sam3Processor      sam3_tracker -> Sam3TrackerProcessor
MODEL_MAPPING_NAMES:      sam3 -> Sam3Model          sam3_tracker -> Sam3TrackerModel

The consequence is that AutoProcessor cannot be used here. A repository publishing the whole
model declares the concept processor, which takes text and has no input_points argument at all,
so resolving there would hand the adapter something no click can be expressed to — and the failure
would land inside a call rather than in a refusal.

Why the change is small

Resolution is family-driven: SEGMENTER_FAMILIES is a set of model_type strings read from a
model's own downloaded config, and CAPABILITY_BY_FAMILY is derived from it, so an adapter and its
capability declaration are the same edit. And the interface has no model-centric surface — the unit
is a connection, the dashboard groups by the wire's capabilities, and the annotator's picker
selects a connection filtered on point_suggest, remembering the choice per project already.

So there is no new adapter class, no new capability, no registration table and no new component.
providers.py needed one argument threaded through and nothing else.

The tracker is a straight port of SAM 2's image path, compared in the installed source rather than
taken from documentation. Every signature the adapter depends on is identical:
get_image_embeddings(pixel_values, **kwargs), the full forward keyword list,
post_process_masks(masks, original_sizes, …), a processor __call__ that accepts
original_sizes without images, and outputs carrying pred_masks and iou_scores. That is why
the embedding cache, the decode path, the fp16 guard and the mask-to-shape pipeline are untouched
and only two class names vary.

sam_provider._CLASSES holds them, one row per family, keyed by exactly the members of
SEGMENTER_FAMILIES and held to it by a test. SAM 2 keeps AutoProcessor on purpose: the
checkpoint the form suggests declares processor_class: Sam2VideoProcessor, so that call already
returns the video processor and that is shipped behaviour, not an oversight to tidy.

The one behavioural fix

Fetching weights for a repository whose terms have not been accepted re-raised the hub client's own
text, which opens with a status line and a request id:

401 Client Error. (Request ID: Root=1-6a8074a4-3bc3584f53a6fc7333db2091;f55535d6-…)

Cannot access gated repo for url https://huggingface.co/facebook/sam3/resolve/main/config.json.
Access to model facebook/sam3 is restricted. You must have access to it and be authenticated to access it. Please log in.

That reached a person verbatim on a failed job. It is now two sentences — what happened, and what
to do — and the translation lives in one helper both download and measure route through rather
than at the one call site that prompted it.

Two details decide how it is written, and both were measured:

  • A gated repository answers 401, not the 403 the word suggests — and so does a repository
    that does not exist.
    So the case is told by the client's exception class and never by the
    status code; a branch on the number sends somebody with a typo'd model id to request access to a
    model nobody publishes. There is a test for exactly that discrimination.
  • GatedRepoError inherits from RepositoryNotFoundError, so a handler naming the parent
    first silently swallows the gated case — the ordering trap the media adapter records for
    UnidentifiedImageError under OSError and the persistence adapter for IntegrityError under
    DatabaseError.

No new error class: LocalInferenceUnavailable already carries expose_message, so the sentence
reaches a person unchanged and nothing moves on the wire.

Measurements that changed the design

  • Reading a size needs no access. The hub answers model_info for a gated repository
    unauthenticated, so the form prices a download before anybody decides whether to go and ask for
    it. An earlier draft of this work assumed the opposite.
  • The download is 6.9 GB, not the ~3.4 GB the model is. The repository publishes its weights
    twice, as a checkpoint and as safetensors, and fetching a revision fetches all of it. The curated
    entry carries the figure that describes the disk.
  • The transformers floor moves >=4.44 to >=5.0, probed against the wheels: 4.57.6 ships
    neither the sam3 nor the sam3_tracker module, 5.0.0 ships both. 5.0.0 is also where naming the
    processor stops being a preference — sam3_tracker is absent from the auto processor map in that
    release and gained an entry later. The bump is a major one for everybody carrying the extra,
    including SAM 2 and Grounding DINO users who gain nothing from it; what it mostly does is make the
    floor honest, since uv.lock has resolved 5.x for as long as the extra has existed.

A defect found by mutation rather than by reading

The access line resolved its entry through curatedEntry, which compares the revision as well as
the model id. Replacing that with a lookup keyed on the model id alone left every test green, which
said the distinction was unverified — and asking which of the two behaviours was correct showed the
mutation was the fix. An access gate belongs to the repository: pinning a different commit of a
gated model does not exempt anybody from its terms, so the line vanishing when the revision was
edited was hiding a requirement that still applied. A test now drives the edit dialog of a stored
connection naming the gated model at another commit.

The dev stack could not have taken the remedy

docker/compose.yaml's api service passed no token, so the failure mode was the bad one: the
refusal names HF_TOKEN, the reader sets it on the host, the container never sees it, and the same
refusal comes back unchanged. A refusal naming a remedy nobody in that position can take is the
mistake NotAWorkspace is the standing example of. One forwarded variable, empty when the host has
none, verified through docker compose config in both directions.

Documentation that had recorded the opposite

docs/architecture/backend/inference.md carried a curation rule excluding custom-gated licences
alongside copyleft ones, and a section recording this model as blocked on two counts. Both are now
decided the other way, so the rules state the position that replaced them rather than leaving a
reader to find the catalog disagreeing with the prose. The new rule is that a gated model may be
curated and may never be the default, and there is a test holding the default to a model anybody
can fetch.

Found, not fixed

  • Two annotate.spec.ts scenarios fail under the local gate's ten workers — the tracked
    local-only flake, cf. Two annotate.spec.ts scenarios fail only under the local gate's ten workers #550. Evidence below.
  • The cycle suite has no inference coverage at all. frontend/app/cycle/cycle.spec.ts never
    exercises auto-labeling, so "a suggestion works end to end against a real server" is not gated
    anywhere. Out of scope here, worth its own issue.
  • Device memory has no refusal path. _device.resolved covers a device being absent, not an
    allocation failing mid-forward. SAM 3 is roughly ten times the parameters of the balanced SAM 2
    rung, which makes an out-of-memory during a forward pass reachable in a way it was not before.
    Nothing here changes it and no refusal names it.
  • polars and polars-runtime-32 are yanked in uv.lock. Pre-existing, surfaced as a warning
    by any uv lock; untouched here.

Test plan

New coverage: the family register's two spellings and the exclusion of nested config halves; the
class table's agreement with SEGMENTER_FAMILIES and the per-family classes; that no load reaches
the network; the gated refusal from both download and measure; that a repository which is not
there is not reported as a licence to accept; that a client too old to name the error still
reports the failure; the catalog's access note and its absence from the default; the access line
appearing while a model is chosen, disappearing when the choice moves back, and surviving a
different pinned revision; and one chromium scenario reading the line in the real dialog.

Twelve mutations were applied and reverted by recorded patch, each asserted present-once before and
present after, with a clean-tree check between cases. Every one turned a named test red. The
harness itself was rewritten partway through: a zsh version using heredocs inside a function
silently failed to apply its mutations while reporting the replacement absent, which is the
false-calm failure the protocol warns about, so it was rebuilt in Python where there is no shell
quoting to get wrong.

The gate was run in stages against the ~10-minute ceiling. Exit lines verbatim:

check.sh: PASSED  ran=python  skipped=frontend,generated,browser,docs
check.sh: PASSED  ran=frontend,generated,docs  skipped=python,browser

Frontend suites: frontend/annotator 1039 passed (1039), frontend/ui-core 1013 passed (1013).
Browser stage, this branch:

  2 failed
    [chromium] › e2e/annotate.spec.ts:1489:1 › selecting on the canvas scrolls the object's row into view
    [chromium] › e2e/annotate.spec.ts:2587:1 › a frame goes out for review, comes back, and is accepted the second time
  270 passed (3.8m)

and the real-server cycle run beside it: 1 passed (44.7s).

The two failures were red at the merge-base

Reproduced by me, in this environment, on unmodified main at the merge-base 9bf9266, with the
command #550 gives. It is a flake, so it takes repetition to show — three runs:

run 1:  92 passed (1.4m)

run 2:  1 failed
          [chromium] › e2e/annotate.spec.ts:1489:1 › selecting on the canvas scrolls the object's row into view
        1 flaky
          [chromium] › e2e/annotate.spec.ts:2587:1 › a frame goes out for review, comes back, and is accepted the second time
        90 passed (1.5m)

run 3:  1 flaky
          [chromium] › e2e/annotate.spec.ts:1489:1 › selecting on the canvas scrolls the object's row into view
        91 passed (1.6m)

The same file on this branch, same command: 1 failed, 1 flaky, 90 passed (1.6m) — the same
two scenario names.

The diff does not touch what those scenarios exercise. They walk asset navigation and a canvas
scroll position; the only annotator file in this diff is SuggestPanel.tsx, and the change there is
one sentence of copy in the blocker shown when no connection can answer a click. annotate.spec.ts
itself is untouched. Worth knowing for anybody reading a red gate on their own branch: adding any
scenario anywhere reshuffles what runs beside what across the ten workers, so a branch touching
nothing in that file can still flip it — two consecutive full browser runs on this branch were green
before a scenario was added to inference.spec.ts, a different file. A fresh reproduction is
recorded on #550.

What is not verified

No forward pass was ever run. Two independent blockers, both stated rather than worked around:
the repository is gated and no token was available in this session, and this machine cannot install
the runtime at all — torch publishes no macOS x86_64 wheel at the locked version, so
uv sync --extra local-inference fails outright.

The consequence is one open question: what facebook/sam3's config.json declares as its
model_type
. The family is deliberately read from a model's own config rather than guessed from
its name, so rather than assume, both registered spellings are served — sam3 for a repository
publishing the whole model and sam3_tracker for one publishing only the promptable half. The code
is correct either way, and the reading is still owed. #595 stays open until somebody with access has
confirmed it and run a click end to end.

The resolver picks an adapter from the family a model's own config declares, so
registering a second point-prompt architecture is an entry in the segmenter
family register rather than a new adapter. SAM 3 declares `sam3` when a
repository publishes the whole model and `sam3_tracker` when it publishes only
the promptable half; both are served here, and the capability each one grants is
derived from the register as before.

What could not be derived is the pair of `transformers` classes a family loads
through, so `sam_provider` gains a table. SAM 2 keeps `AutoProcessor`, which
resolves correctly for the repositories it is pointed at. SAM 3 cannot: a
repository publishing the whole model declares the concept processor, which takes
text and has no `input_points` argument, so resolving there would hand the adapter
something no click can be expressed to and the failure would land inside a call
rather than in a refusal. Everything below the loader is untouched, because the
tracker agrees with SAM 2 signature for signature on the encode, the forward and
the mask post-processing.

Fetching weights gains one behaviour. A repository whose terms have not been
accepted was reported by re-raising the hub client's own text, which opens with a
status line and a request id; it now says what happened and what to do, and the
transport stays out of it. The case is told from a mistyped model id by the
exception's class and never by its status code, because both answer 401 and only
the class separates them.

cf. #595
… be accepted

Adding SAM 3 to the point-prompt ladder puts an entry in the list that nobody can
simply download, which is a thing the list had not had to say before. `CuratedModel`
gains an optional `access` pair — one sentence and the page where it is cleared —
and the form states it while the model is being chosen rather than leaving it to
the refusal a download would eventually produce. A person who has not been granted
access now reads that before creating anything.

The entry's size is the hub's figure for the pinned revision, as every entry's is,
and it is roughly twice the model it installs: the repository publishes its weights
as both a checkpoint and safetensors, and fetching a revision fetches all of it.
What lands on the disk is the number that belongs beside a download.

The module's own note said both groups were Apache-2.0, which this makes false, so
it now states the exception and why the entry is offered anyway. Leaving a gated
model out of a curated list does not spare anybody its terms; it means the people
who want it type the id in from somewhere else, having read nothing.

The annotator's not-capable blocker named the SAM 2 family as what a click needs.
It now names what the model has to do, because more than one architecture does it
and prose repeating a register is the copy that goes stale without any build
failing.

cf. #595
…revision

The access line resolved its entry through `curatedEntry`, which compares the
revision as well as the model id. That is the right question for the select, where
it answers "is this row showing exactly this curated entry", and the wrong one
here: pinning a different commit of a gated model does not exempt anybody from its
terms, so the line vanished while the requirement still applied.

Found by mutation rather than by reading. Replacing the lookup with one keyed on
the model id alone left every test green, which said the distinction was
unverified — and looking at which of the two behaviours was correct showed the
mutation was the fix. The test that now pins it drives the edit dialog of a stored
connection naming the gated model at another commit.

cf. #595
…ching

The curation rules excluded custom-gated licenses alongside copyleft ones, and a
section recorded `facebook/sam3` as blocked on two counts. Both are now decided the
other way and the model ships, so the rules state the position that replaced them
rather than leaving a reader to infer it from the catalog disagreeing with the
prose.

The reference documentation gains the two steps a gated model needs — request
access, set HF_TOKEN — and says where the gate is not: reading a size needs
neither, which is what lets the form price a download before anybody asks for one,
and running weights already in a workspace needs neither, which is what keeps a
copied workspace working.

Two measurements are written down because both contradict the assumption a reader
would otherwise make: the hub sizes a gated repository unauthenticated, and a gated
repository answers 401 rather than 403 — the same status a repository that does not
exist answers, so the two are told apart by the client's exception class and never
by the code.

cf. #595
…ves in

Measured against the wheels rather than inferred: 4.57.6 ships neither the `sam3`
nor the `sam3_tracker` module and 5.0.0 ships both, exporting
`Sam3TrackerProcessor` and `Sam3TrackerModel` at the top level, which is what the
adapter names.

5.0.0 is also where naming the processor rather than resolving it stops being a
preference and becomes the only thing that works: `sam3_tracker` is absent from the
auto processor map in that release, so `AutoProcessor` has no answer for it at all.
It gained one later. The adapter's table works on both.

The bump is a major one for everybody carrying the extra, including the SAM 2 and
Grounding DINO users who gain nothing from it. What it mostly does is make the
floor honest: `uv.lock` has resolved 5.x for as long as the extra has existed, so
`>=4.44` was a compatibility claim no run has ever tested.

cf. #595
… is reachable

The api service passed no token, so weights that have to be asked for could not be
fetched from the dev stack at all. The failure mode is the bad one: the refusal
names `HF_TOKEN`, the reader sets it on the host, the container never sees it, and
the identical refusal comes back with nothing about it changed. A refusal naming a
remedy nobody in that position can take is the mistake `NotAWorkspace` is the
standing example of.

One forwarded variable, empty when the host has none, which is what every ungated
model already expects. Verified through `docker compose config` in both directions:
absent it renders empty, set it renders the value.

cf. #595
…ctually opens

The conditional is already held in jsdom. What that layer cannot say is anything
about the journey: that the sentence appears in the real dialog, above the real
size line, before the control that would fetch anything is reachable. This opens
Inference in chromium, picks the gated entry and reads the line and its link.

cf. #595
@JArmandoAnaya JArmandoAnaya added this to the 0.1.0 milestone Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant