Skip to content

Fix infer_cbeta corrupting the cached inferred_cbeta array#361

Open
winklemad wants to merge 1 commit into
Biohub:mainfrom
winklemad:fix-infer-cbeta-cache-mutation
Open

Fix infer_cbeta corrupting the cached inferred_cbeta array#361
winklemad wants to merge 1 commit into
Biohub:mainfrom
winklemad:fix-infer-cbeta-cache-mutation

Conversation

@winklemad

Copy link
Copy Markdown

No linked issue — found by reading the code.

The bug

ProteinChain.infer_cbeta() builds a new chain whose glycine CB positions are NaN (unless infer_cbeta_for_glycine=True). To do so it takes the cached inferred_cbeta array by reference and NaN-fills the glycine rows in place:

inferred_cbeta_positions = self.inferred_cbeta       # cached_property -> shared array
if not infer_cbeta_for_glycine:
    inferred_cbeta_positions[np.array(list(self.sequence)) == "G", :] = np.nan

Since inferred_cbeta is a @cached_property, this permanently corrupts the original chain's cached array, and everything derived from it (e.g. pdist_CB, which is squareform(pdist(self.inferred_cbeta))). So the value of inferred_cbeta / pdist_CB silently depends on whether infer_cbeta() was ever called:

chain.inferred_cbeta[gly_idx]   # -> real inferred CB coordinate
chain.infer_cbeta()             # default call; intended to be side-effect-free
chain.inferred_cbeta[gly_idx]   # -> [nan, nan, nan]   (cache mutated)

Why it's a bug (not intended)

  • The same method already copies the other arrays before mutating them: atom37_positions = self.atom37_positions.copy() / atom37_mask = self.atom37_mask.copy().
  • The sibling ProteinComplex.infer_cbeta computes inferred_cbeta_positions into a fresh local, so it isn't affected.

Fix

Copy the array before the in-place NaN-fill (self.inferred_cbeta.copy()). The returned chain is unchanged (glycine CB still masked by default, kept with infer_cbeta_for_glycine=True); only the mutation of the shared cache is removed.

Testing

Added esm/utils/structure/protein_chain_test.py::test_infer_cbeta_does_not_mutate_cached_inferred_cbeta, asserting that inferred_cbeta and pdist_CB are unchanged after calling infer_cbeta(). It fails before this change and passes after. ruff check / ruff format are clean.

ProteinChain.infer_cbeta() takes the cached `inferred_cbeta` array by reference
and NaN-fills the glycine rows in place to build a new chain. Because
`inferred_cbeta` is a cached_property, this permanently corrupts the original
chain's cached array -- and everything derived from it, e.g. `pdist_CB` -- so the
value of `inferred_cbeta` / `pdist_CB` depends on whether `infer_cbeta()` was ever
called on the chain.

The same method already copies `atom37_positions` / `atom37_mask` before mutating
them, and the sibling `ProteinComplex.infer_cbeta` computes a fresh array. Copy
the array before the in-place NaN-fill. Added a regression test.
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