feat(inference): a weights integrity check that re-reads every byte and purges damage - #475
Merged
Conversation
…es damage `download_weights` at `ready` proves a snapshot is complete and answers from an index without opening a file, so a copy that is present and wrong passes it for ever. `check_integrity` is the second action over the same files: it reads every byte and compares it against what the hub published for the pinned revision. Per file, and that is the trap this could have shipped with. The hub publishes `lfs.sha256` for LFS-tracked files and only a git object id — SHA-1 over `blob <len>\0` and the contents — for small ones, side by side inside one revision. Hashing a config the weights' way would report damage over an intact snapshot for ever, and the remedy names deletion. Damage purges the offending blobs and *then* stands the connection down. Only that order works: a cache hit is returned unread, so leaving the bytes in place would let the next download hand back the same damaged file. A metadata fetch that fails is not a verdict — no purge, no state change. Own capability row, narrow in state as well as in kind: the first connection gate that is not total in `setup_state`, because at `not_set_up` there is no snapshot to read. Route answers 202; `visionset inference check-integrity` blocks and reports phases. MCP has no inference surface at HEAD and gains none here.
…it proves **Verify weights** covered two readings of `download_weights` and could only be honest about one. It becomes **Check for missing files**, beside the new **Check files are undamaged** — completeness against integrity, fast against every byte. `docs/inference.md` carries the same two sentences in a table, so the page and the menu cannot drift. The row runs two independent polls rather than one shared run state, which is what keeps a slow check from reading as a stalled download. A failed check is the one refusal on this screen that has already acted: the damaged files are gone and the connection is back to Not set up before the job row says so, so the settle-invalidation lands the row there and the prose names the download that is now a real transfer.
This was referenced Aug 10, 2026
JArmandoAnaya
added a commit
that referenced
this pull request
Aug 10, 2026
…496) The check still held a client-side job id, so a reload lost one in flight and a check started from a terminal was invisible to every browser — the failure #492 removed from the download, still present on the other action over the same files. The connection now carries `integrity_check` beside `download`, through the same discovery rather than a copy of it: one job type constant and one payload key in the domain, one `ConnectionJob.of` body that identifies a run, and one queue read that answers both kinds for every row on a screen's poll path. The two shapes name different units because they count different things. A transfer measures bytes off the disk; a check owns its loop and knows how many files the revision holds before it opens the first one, so it reports files — determinate, from the counts `jobs/integrity.py` was already reporting. Neither borrows the other's name at any layer, which is why the wire carries two shapes rather than one with a discriminator. No new `ConnectionSetupState` member, for the download's reason verbatim: the state flip is the last statement, so a third member reopens the crash window that ordering closes. #475's semantics are untouched — purge-first stands, and a hub that cannot be reached is still a failure with prose, no verdict, no purge and no state change. What changes is that the sentence outlives the request. `allowed_actions` is unchanged and pinned. A connection with a check in flight is still `ready`, so it still declares `check_integrity` and `download_weights`; nothing in the kernel refuses a second concurrent run. Making the declaration job-aware would give `connection_actions` a third dimension, which is a design decision rather than a consequence of this one.
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.
download_weightsatreadyproves a cached snapshot is complete, and it proves that byconsulting the download library's index without opening a file. So a copy that is present and
damaged — truncated by a filesystem error, rotted on a failing disk, edited in place — passes it
for ever, and surfaces much later inside a model load, in a sentence about tensors, on a connection
the row still calls
Ready.check_integrityis the second action over the same files, and itanswers by reading every byte.
Implements the
Decision (Armando, 2026-08-09)on the issue in full: digest re-read, purge thennot_set_up, separate wire action. Naming was left to the implementer; see below.Step 0 — what the hub actually publishes, verified before anything was designed
Read off the locked
huggingface_hub1.26.0 (uv.lock; note the ambient conda python on thisbox is 1.11.0 and is not what ships) and confirmed against the hub itself on two real repositories.
There is no single digest per file. The hub publishes what its own storage keeps, and that
differs by how the file is stored:
model.safetensors,*.pt)lfs.sha256config.json, tokenizer JSON,.gitattributes)blob_idb"blob <len>\0"then the contentsBoth kinds appear side by side inside one revision, so the selection is per file and never per
repository. Hashing a config with plain SHA-256 and comparing it to a git OID would fail on a
perfectly intact file, every time, for as long as the check existed — and the remedy it names
deletes the weights. That is the trap the dispatch called out, and it is why
PublishedDigestcarries which kind it is rather than just a string.
Measured on
facebook/sam2.1-hiera-tinyandhf-internal-testing/tiny-random-gpt2: every filecarries one of the two, so nothing is skipped. A file the hub gives neither for is refused, not
passed —
published_digestsraises, because reporting "intact" about a snapshot nine files out often were checked in is a guarantee made out of a gap.
Both computations were then reproduced against real downloaded bytes: 3 LFS files by sha256 and 7
non-LFS by git OID, all ten matching.
A second finding, which changed a test. An LFS file's
blob_idis the object id of itspointer text, not of its contents — git only ever stored the pointer. Verified on all three LFS
files of the tiny repo. The first version of the fixture reused the content's OID for both fields,
which would have let the wrong-digest mutation survive;
01c1a03fixes it, and the mutation islethal afterwards (see the table).
Cache mechanics, and what a purge has to reach
The cache is content-addressed:
blobs/<digest>withsnapshots/<commit>/<name>symlinking intoit. Verified end to end, in this order:
snapshot_download— thecorrupt bytes come back. This is the premise the whole decision rests on, now measured rather
than inferred.
re-download — the corrupt bytes come back again, because the library re-links a surviving blob
rather than re-fetching. So a purge must remove the blob and the entry.
trees/<commit>.jsonthe 1.x cache also keeps does not resurrect anything.Finding 2 is modelled in the test fake (
9f6c6d1), which is what makes "purge the symlink only"a red test rather than a green one.
Naming
check_integrity. The table's grammar is verb + object (download_weights,create_correction,edit_membership,submit_for_review), and this fits it.verify_weightswasthe obvious parallel and is the one thing it could not be called: Verify weights is the existing
UI label for
download_weightsatready, so the new action would have taken the old one's namewhile meaning something stronger. Naming the property is what separates them.
Kernel
ConnectionAction.CHECK_INTEGRITY, declared between the download and the housekeeping.CONNECTION_GATESstops being total in state, which is the notable part. Every connection gatewas
EVERY_SETUP_STATEuntil now; this one isCHECKABLE_STATES—readyonly — because atnot_set_upthere is no snapshot to read, so the action is not merely useless there butunanswerable.
WEIGHT_HOLDING_TYPESis the kind half, now named once and shared withdownload_weightsrather than spelled out twice.require_checkable(the gate, derived fromconnection_actions, never from a second reading) andrecord_weights_missing(theready -> not_set_upedge).InferenceConnectionNotCheckablegets its own code rather than reusingNOT_DOWNLOADABLE, because the two arrivals have different remedies: anhttpconnection is toldto stop asking; a
localone atnot_set_upis told to download first, which makes the identicalrequest succeed. One code for two next steps would be the wrong economy.
CONNECTION_MOVESand no transition table, and that survives the second edgearriving. Neither
not_set_up -> readynorready -> not_set_upis a move somebody performs —nobody asks to make a connection not set up, they ask for the files to be checked. Both are what
an operation over the cache leaves behind, written in the same transaction.
Purge before the state write — and the one place this PR does not do what the dispatch asked
The decision says purge first, and the mechanics above say why: a cache hit is returned unread, so a
connection sent back to
not_set_upwith the damage still on disk would be "repaired" by a downloadthat hands the same file back and lands at
Readycarrying it. The remedy would launder the fault.The dispatch also asked that "purge and state commit together" and that "no path leaves a purged
blob with a
readyconnection". That is not available, and I did not pretend otherwise. One writeis a filesystem unlink and the other is a SQLite commit; nothing brackets them. The ordering only
chooses which way the crash window falls:
readywith a missing file.That is an incomplete snapshot, exactly the condition
download_weightsalready repairs, and thenext run of either action fixes it for real.
not_set_upwith the corrupt file cached,and the download somebody then runs restores it to
Ready. Silent, and unrecoverable by anycontrol this product offers.
Gate 1 says the issue wins on conflicts, and the issue is explicit about purge-first; the second
ordering would also defeat the feature's whole purpose. So the invariant tested is the one that
actually holds and actually matters: no window leaves corrupt bytes reachable by a connection a
download would return to
ready.test_the_purge_happens_before_the_connection_is_stood_downobserves the ordering rather than assuming it. Never-half-ready is untouched either way —
setup_statehas two values, the failure path writes one of them.A crash before the verdict changes nothing at all: every read happens before the first unlink.
Not a verdict
A metadata fetch needs the network, and a network that is not there is not evidence about the files.
The lookup happens before anything is read, so a failure raises with prose, purges nothing and moves
no state. Answering "damaged" because a laptop was on a train would destroy a healthy cache;
answering "intact" would be a guarantee made out of nothing.
Wire, CLI, MCP
POST /inference/connections/{id}/check-integrity→ 202 +Location, the launch-and-pollcontract the download route already answers with. Its job reports
processed/totalas files— a real total, which the download handler cannot give because it hands its transfer to a library
that owns the progress bar. A succeeded job's result carries
files_checkedandbytes_read.openapi.jsonand the TS client regenerated, both committed.visionset inference check-integrity— blocks, reports phases on stderr,--json. Its--jsondocument and the job's result are the same two keys because both call
IntegrityReport.counts();it is not a
visionset.wireprojection because that module pairs with a server model and no routepublishes this shape (the route publishes a job).
one action would be the odd asymmetry rather than parity.
UI
Verify weights covered two readings of one action and could only ever be honest about one. The
overflow now carries both, each named by what it proves:
download_weightsatreadycheck_integritydocs/inference.mdcarries the same table, so the page and the menu cannot drift.useWeightsRunnow takes its mutation as a parameter, so the row runs two independent polls.One shared run state would light both controls and make the slow check read as a stalled download.
The settled-job invalidation from #470 covers the new transition, which is the direction it had not
been asked to handle: a failed check moves the row
Ready -> Not set up, and the row re-reads onsettle rather than on a reload. A failed integrity check is the one refusal on this screen that has
already acted — the files are purged and the connection stood down before the job row says so —
so its prose describes a state the workspace is already in and names the remedy the row now offers.
Tests
tests/inference/test_integrity.py(new, 17): the hub is faked and the cache is real. Thatsplit is the point — the thing being purged is a directory of blobs with symlinks into it, and a
purge that deleted only the link would pass any test that faked the filesystem too. The git-object
rule is checked against two published constants (the empty blob and
b"hello\n") rather thanagainst a second copy of the rule written in the test file, which would only prove that two
identical mistakes agree.
Covered: per-file digest selection; the config-hashed-as-weights failure, pinned directly; an
intact snapshot leaving
readyuntouched with its counts; one wrong byte → named, purged, stooddown; the purge proving a re-download re-fetches; a missing file as damage; an unreadable file as
damage; purge idempotence; metadata failure changing nothing; a file the hub will not digest;
ordering; and the three refusal squares.
Elsewhere: capability contract tests gain
check_integrity's invoke/refuse closures and thenow-conditional gate assertion; the pinned action set updated by hand (
test_the_actions_a_ connection_cannot_yet_be_asked_for_are_not_declared), which is what makes arriving in it adecision; route tests for 202, the damage transition, the three 409s and no-job-on-refusal; CLI
tests;
SHIPPEDjob types; the error-code table; and five ui-core tests.Mutation verification
Each applied to the committed tree, anchor asserted present before and replacement after, run, then
reverted by its exact edit with a clean tree confirmed.
digest_ofstops hashing theblob <len>\0headerpurgeremoves the symlink and leaves the blobCHECK_INTEGRITYgated onEVERY_SETUP_STATEThe two the dispatch asked to be named are the first two rows. The fourth is the one that found a
gap: it initially survived, because the fake was kinder than the library. Verifying finding 2 above
against the real
snapshot_downloadand modelling it in the fake is what turned it red.Gate
Run in stages under the harness's ~10-minute ceiling; pytest split by the directories
ls tests/reported at run time. The machine was free (load average 2.07 on 20 cores), so this is the full
gate split and not the saturation fallback. Every exit code verbatim.
pytest tests/architecturepytest tests/clipytest tests/examplespytest tests/formatspytest tests/inferencepytest tests/jobspytest tests/kernelpytest tests/mcppytest tests/packagingpytest tests/server tests/test_versioning.pyruff check .ruff format --check .(344 files)mypy src/visionset(152 files)lint-imports(4 kept, 0 broken)pnpm -r buildpnpm testpnpm -r lint(incl. all three annotator boundary gates)check.sh generated(openapi drift, client drift, mcp reference, version sync)check.sh browser— annotator + app e2e, then the real-server cycletests/scriptsholds only.mjsnode gates, which run underpnpm test, so it is absent aboverather than reported as exit 5. No baseline-proof exception was used or needed.
One red on the way, worth recording:
pnpm -r lintcaught astring[]where the wire's actionunion was wanted, in a test file. vitest does not typecheck, so the tests had been green over it —
aa3df7ctypes the pinned list offConnectioninstead.Boundaries
visionset.kernelgained no import ofvisionset.inference,visionset.jobsor a deliverypackage;
visionset.inferenceimports no delivery package. Nothing infrontend/annotator/src/core/was touched. No route, tab, screen, nav entry or entry point moves,so the sitemap is unchanged.
openapi.jsonand the generated client are regenerated rather thanhand-edited, and the drift gates agree.
Found, not fixed
processed=1, total=1for a transfer of gigabytes. Theintegrity handler shows a real total is reachable when the loop is ours; the download's is not,
because the library owns the transfer. Recorded rather than changed — the bounds put the download
job out of scope beyond what the purge interaction required, and it required nothing.
#466(the four inference request models do not forbid unknown fields) is untouched.Closes #471.