Repository navigation
fix(#0139): verify a soup member's bytes against the digest the pool recorded - #261
Draft
nilsmechtel wants to merge 2 commits into
Draft
nilsmechtel wants to merge 2 commits into
nilsmechtel wants to merge 2 commits into
Conversation
…rified The pool records a sha256 for every contribution's weights and no read path ever used it: merge admission read only `weights_path` and `n_bytes`, and the replica souped whatever bytes the URL returned. So a record written before the digest was banked merged identically to a good one, and the community manifest then asserted a digest over members whose own provenance was never established. Two clauses: - Admission excludes a contribution with no recorded digest (`pool.unverifiable_contributions`). Absent and `null` are the same answer. Closed per contribution, not per merge, so one legacy record cannot wedge a pool; the excluded ids go on the merge record and the RPC reply under a new `unverified` key rather than into `rejected`, which means "weighed and lost on merit". They are marked decided, since nothing will ever add a digest to an immutable committed record. - The replica hashes the bytes it downloaded and refuses a mismatch before the cache write (`pool.member_digest_refusal`, wired into `_fetch_member`), so a bad member cannot be served to a later merge as if it had been verified. One hash per member per replica — a cache hit skips it. Measured on europa: 2.56 s per 1.2 GiB, 0.47 GiB/s. The writers were already correct and are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…p the byte check The reporter re-probed the live pools and refuted the premise the filter rested on. All 8 contributions whose DEPOSIT journals lacked a digest carry one in the stored `pool_index.json`, on all three affected pools: 8/8 present, 8/8 exact against values derived independently. The absence was only ever in the client-side deposit record, never in the index the merge reads. Measured here to confirm there is no legacy window either: `append_contribution` was introduced by `eb1f748` (the commit that created the pool backend, so no pool predates it) and its index stub already carried `weights_sha256`. The later `6eec958` hit is the expression changing to `info["weights_sha256"]`, not the field being added. And every path reaching `_specs` comes from `by_id[cid]["weights_path"]` or `cand_path[cid]`, both built off `index["contributions"]` — so every soup member is a pool contribution and every pool contribution has a digest. So the filter, the `unverified` record key, and the decided-marking it needed guarded a state no version can produce. Removed rather than kept "just in case": a branch for an unreachable state is untested in production forever and makes the reachable path harder to read. What replaces it is stricter, not weaker. The digest map is now built with a bare subscript and `_specs` reads it with one, matching the existing idiom at entry.py:1828, so a corrupt index raises instead of degrading into an unverified soup. `member_digest_refusal` takes a required digest and has no falsy-skip path, because the one failure it exists to prevent — souping bytes nothing checked — must not be reachable as a silent no-op. Tests: the four filter cases are gone; two controls replace them. An empty digest must refuse rather than skip (the earlier draft returned None and would have passed everything else), and the comparison must be over the whole digest, not a prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch has not been deployed
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.
A soup member's bytes were never checked against the digest the pool recorded
for them.
pool_index.jsonstores aweights_sha256per contribution — the onlyfield that says which weights a contribution was — and no read path compared it
to anything. The replica souped whatever bytes the presigned URL returned, and the
community manifest published afterwards asserted a digest over members whose own
provenance had never been confirmed.
Backlog #139. Found via the round-2 model-soup campaign (
aged-mink-nvaylz).What changed
pool.member_digest_refusal(key, expected_sha256, content)returns a reasonstring — so the caller owns the exception type, as
soup_admissiondoes — and iswired into
CellposeRuntime._fetch_member, the single download point for everysoup member, shared by
score_soupandmaterialize_soupvia_build_soup.what the pool recorded never reaches the cache and cannot be served to a later
merge as if it had been verified.
only because the cache key is the immutable pool
weights_pathand the file iswritten via a temporary name, so a cached entry can only be bytes this method
already verified. Noted in the docstring.
entry.pypasses the expected digest down via aweights_path → weights_sha256map built off the index, attached as
sha256on each member spec.entry.py:1828. An index row without a digest raises here rather than degradinginto an unverified soup, and
member_digest_refusaltakes a required digest withno falsy-skip path — the one failure this exists to prevent must not be reachable
as a silent no-op.
A premise was refuted mid-implementation, and the first commit is the record
The first commit on this branch also excluded contributions with no recorded
digest, on the strength of a report that 8 of 92 campaign deposit rows carried
none. The reporter then re-probed the live pools and refuted their own finding:
all 8 carry a digest in the stored
pool_index.json, on all three affected pools,8/8 exact against independently derived values. The absence was only ever in the
client-side deposit journal, never in the index the merge reads.
Measured here to rule out a legacy window as well:
append_contributionintroduced byeb1f748— the commit that created the pool backend, so no pool predates itweights_sha256(line 1300)6eec958hitinfo["weights_sha256"], not the field being added_specsby_id[cid]["weights_path"]orcand_path[cid]— both built offindex["contributions"]So every soup member is a pool contribution and every pool contribution has a
digest. The second commit removes the filter, the
unverifiedrecord key and thedecided-marking it required. A branch for an unreachable state is untested in
production forever and makes the reachable path harder to read.
The net diff is 87 insertions, 4 deletions across four files.
Cost — measured, not assumed
These are 1.2 GiB checkpoints and the hash is over the whole file.
hashlib.sha256(content)does not copy the buffer, so #87's three-member-equivalents invariant (pool.py:214-218) is unchangedNot per scoring pass. A merge is
2*len(batch)+3passes and only the first use ofa member downloads.
Verification
Run in
ghcr.io/aicell-lab/bioengine-worker:0.16.58-dev1withpytestandpytest-asyncioinstalled. Without the plugin, 6@pytest.mark.asynciocasesreport as failures unrun — in both arms, so the count is unreadable otherwise.
origin/main+4 exactly, zero failures, identical skips (a pre-existing corpus fixture gated on
MODEL_FINETUNE_CORPUS_DIR, andtest_soup_math.pyneeding torch).Two mutation controls, each with the marker's uniqueness asserted (count 1) before
the edit:
member_digest_refusal→return Nonefirstgot == expected→got.startswith(expected)The second control is the one worth noting: it proves
test_the_whole_digest_is_compared_and_not_a_prefixcatches something no othertest here does.
test_matching_bytes_are_acceptedstayed green under the firstmutation, which is what makes it a control against a guard that just refuses
everything.
Honest coverage gap
Only the pure
pool.pyhelper is unit-tested. The wiring —_fetch_member'scall, the before-cache ordering, and the
sha256attachment in_specs— isnot covered by any test, because
aggregateneeds a live Hypha artifact and aGPU runtime. It was read and reasoned about, not executed. A reviewer should treat
it as unverified by CI.
Not in this PR
No version bump yet, deliberately.
apps/model-finetuneis gated byapp-version-check.yml, which requires the version to be already publishedbefore
gh pr ready, so bumping reserves a published version — and reserving outof order is what collided PR #257 with PR #249. Five other
apps/model-finetunePRs are open (#237, #239, #255, #256, #260) and backlog #127 is paused on the
landing order. Nothing has been uploaded or deployed: a paper campaign is live on
the production instance.
Out of scope: any writer change (already correct),
get_pool_state's projectiondropping the digest it has in hand (filed separately), making
schema_versiontrack semantics (#122/#114), and what the merge gate decides on (#121 owns
the scoring decomposition). This adds a provenance check to the load path; it does
not change the ranking.
🤖 Generated with Claude Code