Skip to content

fix(#0139): verify a soup member's bytes against the digest the pool recorded - #261

Draft
nilsmechtel wants to merge 2 commits into
mainfrom
fix/0139-merge-refuses-unverified-digest
Draft

nilsmechtel wants to merge 2 commits into
mainfrom
fix/0139-merge-refuses-unverified-digest

Conversation

@nilsmechtel

@nilsmechtel nilsmechtel commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

A soup member's bytes were never checked against the digest the pool recorded
for them. pool_index.json stores a weights_sha256 per contribution — the only
field 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 reason
string — so the caller owns the exception type, as soup_admission does — and is
wired into CellposeRuntime._fetch_member, the single download point for every
soup member, shared by score_soup and materialize_soup via _build_soup.

  • The check runs before the cache write, so a member that does not hash to
    what the pool recorded never reaches the cache and cannot be served to a later
    merge as if it had been verified.
  • A cache hit is trusted on the strength of the check that admitted it. Sound
    only because the cache key is the immutable pool weights_path and the file is
    written via a temporary name, so a cached entry can only be bytes this method
    already verified. Noted in the docstring.
  • entry.py passes the expected digest down via a weights_path → weights_sha256
    map built off the index, attached as sha256 on each member spec.
  • Both lookups are bare subscripts, matching the existing idiom at
    entry.py:1828. An index row without a digest raises here rather than degrading
    into an unverified soup, and member_digest_refusal takes a required digest with
    no 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_contribution introduced by eb1f748 — the commit that created the pool backend, so no pool predates it
its index stub at that commit already carried weights_sha256 (line 1300)
the later 6eec958 hit the expression changing to info["weights_sha256"], not the field being added
every path reaching _specs 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. The second commit removes the filter, the unverified record key and the
decided-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.

sha256 over 1.2 GiB on europa 2.56 s (0.47 GiB/s)
frequency once per member per replica — a cache hit skips it
allocation none; hashlib.sha256(content) does not copy the buffer, so #87's three-member-equivalents invariant (pool.py:214-218) is unchanged

Not per scoring pass. A merge is 2*len(batch)+3 passes and only the first use of
a member downloads.

Verification

Run in ghcr.io/aicell-lab/bioengine-worker:0.16.58-dev1 with pytest and
pytest-asyncio installed. Without the plugin, 6 @pytest.mark.asyncio cases
report as failures unrun — in both arms, so the count is unreadable otherwise.

arm result
full app suite, this branch 77 passed, 2 skipped
full app suite, origin/main 73 passed, 2 skipped

+4 exactly, zero failures, identical skips (a pre-existing corpus fixture gated on
MODEL_FINETUNE_CORPUS_DIR, and test_soup_math.py needing torch).

Two mutation controls, each with the marker's uniqueness asserted (count 1) before
the edit:

mutation result
member_digest_refusal → return None first 3 failed — exactly the three refusal assertions
got == expected → got.startswith(expected) 1 failed — exactly the prefix control, nothing else

The second control is the one worth noting: it proves
test_the_whole_digest_is_compared_and_not_a_prefix catches something no other
test here does. test_matching_bytes_are_accepted stayed green under the first
mutation, which is what makes it a control against a guard that just refuses
everything.

Honest coverage gap

Only the pure pool.py helper is unit-tested. The wiring — _fetch_member's
call, the before-cache ordering, and the sha256 attachment in _specs — is
not covered by any test, because aggregate needs a live Hypha artifact and a
GPU 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-finetune is gated by
app-version-check.yml, which requires the version to be already published
before gh pr ready, so bumping reserves a published version — and reserving out
of order is what collided PR #257 with PR #249. Five other apps/model-finetune
PRs 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 projection
dropping the digest it has in hand (filed separately), making schema_version
track 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

nilsmechtel and others added 2 commits October 27, 2026 21:45
…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>
@nilsmechtel nilsmechtel changed the title fix(#0139): refuse to soup a contribution whose weights digest is unverified fix(#0139): verify a soup member's bytes against the digest the pool recorded Oct 27, 2026

This branch has not been deployed

No deployments
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