Fix fragments with interleaved atom indices (#47) - #70
Conversation
PrimitiveInternalCoordinates represented each connected-component fragment by a numeric (min, max+1) window rather than its real atom set. When two fragments' atom indices interleave, these windows overlap, which crashed get_hybrid_indices with a bare RuntimeError (the reported bug), silently duplicated primitives in reorderPrimsByFrag (its primitive-matching loop had no early exit), and would have corrupted wilsonB's block-diagonal B-matrix assembly if patched naively. Fix: merge overlapping fragment windows into true, non-overlapping partitions of the atom range (_merge_block_windows) before they're written to block_info, and match primitives to a fragment by real membership (frag.L()) instead of numeric range. This keeps wilsonB, second_derivatives, and calcCg correct without modification, since their block-local Cartesian slicing only requires block_info windows to be true partitions, not that each block correspond to exactly one fragment. Also fixes two latent bugs in Rotator.derivative (slots.py) exposed once a block can span more than one fragment's Translation/Rotation primitives: it selected the wrong subset of the passed xyz array (previously masked because a block always held exactly one Rotator's atoms), and its cache-hit path returned a shape inconsistent with the freshly-computed path. Adds pyGSM/tests/test_interleaved_fragments.py, including a permutation-equivalence check that compares wilsonB and primitive values between a contiguous and an interleaved construction of the same molecule. Verified no change in output for ordinary (non-interleaved) geometries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sdd1NdoAcJEFbASDDohYkN
Real-scale validation from a downstream projectTwo Athena runs of asymmetric-aminoallylation's List GSM ensemble (185-atom reactant complexes, 2 non-covalently-bonded fragments whose atom-index membership genuinely varies per conformer — a mobile N–H···N proton) tested this fix at real scale, beyond this PR's own unit tests. Full detail in asymmetric-aminoallylation#3:
The remaining 82 failures are unrelated (24h walltime, an unrelated SE-GSM growth-stall bug, early xTB rejections, Hessian This also plausibly exercises the Not exercised by this validation (gaps worth knowing about, not blockers): 3+ fragment interleaving (this system is always exactly 2 fragments — only your own synthetic 3-fragment test covers that), the MECI/SEAM/ Generated by Claude Code |
Description
Fixes #47:
PrimitiveInternalCoordinatescrashed with a cryptic, message-lessRuntimeErrorwhen an input geometry had two disconnected molecular fragments whose atom indices interleave (e.g. fragment A = atoms {0,2}, fragment B = {1,3}) instead of falling into separate contiguous ranges. One real-world trigger is a catalyst whose ligands bind/dissociate over a reaction mechanism, or (the case that surfaced this again) a mobile-proton H-bonded pair where fragment membership depends on which atom currently holds the proton.Root cause
PrimitiveInternalCoordinatesrepresented each connected-component fragment as a numeric window(min(atom_idx), max(atom_idx)+1)instead of its real atom-index set (frag.L()intopology.py, already available everywhere it's needed). This window is only a valid stand-in for "the atoms in this fragment" when those indices happen to be contiguous. With interleaved fragments, two fragments' windows overlap, and several call sites that assumed windows exactly partition[0, natoms)broke in different ways:get_hybrid_indices(the reported crash site): re-expanded each fragment's(min, max)window back into arange()and concatenated across fragments to find "leftover" hybrid (pure-Cartesian) atoms. With overlapping windows this produces duplicate indices; the second.remove()of an already-claimed index raisedValueError, silently caught by a bareexcept:and re-raised as a bareraise RuntimeError— the exact cryptic error this issue reports.reorderPrimsByFrag— reached wheneveroptions['connect'] is False, which is the default, and live during SE-GSM growth (se_gsm.py). This turned out to be worse than "misassigns primitives to the wrong block": its primitive-matching loop iterates over all primitives once per block with no early exit, so a primitive whose atoms fall inside two overlapping windows gets appended toself.Internalstwice, silently, with no exception at all.wilsonB(called every optimization step to build the Wilson B-matrix): I initially assumed the fix here could be "pass the fullxyzarray instead of a block slice for interleaved blocks," but verified that's wrong —block_matrix.full_matrixassembles the final matrix viascipy.linalg.block_diag(*matlist), which assumes each block owns an exclusive, correctly-positioned column range. Feeding it a wrong-sized block would misalign columns even though the per-primitive derivative math itself would be numerically fine.Fix
Rather than patching each symptom, the fix restores the invariant everything else already assumes:
block_infowindows must always be true, non-overlapping, gap-free partitions of[0, natoms)._merge_block_windows, a small pure function that merges overlapping(sa, ea, sp, ep, kind)windows (standard sorted-interval-merge) into windows that are each exactly the true contiguous atom range they cover. This is correct because every raw window already comes from a disjoint atom set (connected components), so the union of all raw windows has no gaps — merging any overlapping chain therefore can't accidentally include atoms that don't belong. For ordinary, non-interleaved geometries,merged == rawexactly, so there's zero behavior or performance change in the common case.newMakePrimitivesandreorderPrimsByFrag, replacing the rawblock_infowith the merged partition.reorderPrimsByFrag's primitive-duplication bug independently by matching primitives to a fragment via real membership (frag.L()) instead of the numeric window — this alone fixes duplication regardless of the window-merge work.get_hybrid_indicesto build its atom list directly from each fragment's realfrag.L()instead of re-expanding a(min,max)window, and replaced the bareRuntimeErrorwith a descriptive one for the one case that should still be an error (an atom genuinely claimed by two fragments — a real topology inconsistency, not an interleaving artifact).block_infotuples grew a 5th field (kind,'reg'/'hyb') soappend_prim_to_block's hybrid-block detection (previously inferred from "exactly 3 primitives," which breaks once merged hybrid blocks can hold more) becomes an explicit tag instead of a heuristic.newMakePrimitives'saddcartbranch had the same contiguous-range assumption (for i in range(info[0], info[1])) when adding per-atom Cartesian primitives; fixed by mirroring the adjacentaddtrbranch, which already correctly iterates the fragment's real node list.Because
block_infowindows are guaranteed correct partitions after this fix,wilsonB,second_derivatives,calcCg,GMatrix, andGInverse_SVDneeded no changes at all — their block-local Cartesian slicing was never the actual problem, only the bookkeeping that decided block boundaries.Two additional bugs found while verifying against real geometries
Static analysis of the plan wasn't enough — running the fix against an actual interleaved geometry surfaced two more issues in
slots.py'sRotatorclass (used byRotationA/B/C, the translation/rotation primitives TRIC uses by default), both only reachable once a block can span more than one fragment's Translation/Rotation primitives:Rotator.derivativeselectedxsel = xyzdirectly instead ofxyz[relative_a, :]— there's even a# [relative_a, :]comment showing the correct slice was written and then disabled. This was silently correct before merging was possible, because a block always held exactly one Rotator's atoms (xyz==self.a's atoms already). Once two fragments share a merged block, this fed the whole block's atoms into a computation that expected only this primitive's own 2+ atoms, raising a shape-mismatch error.Rotator.derivative's cache-hit path returnedself.stored_deriv[relative_a]instead ofself.stored_deriv, a shape inconsistent with the freshly-computed path — again silently correct only whenrelative_ahappened to span the entire block. The maintainer had already left a comment flagging this exact cache as unreliable ("stored_derdoes not currently work in block-matrix formulism"), which is a good sign this was a real, previously-known-suspicious latent bug rather than something new.Both are one-line-ish fixes with comments explaining why the old code was silently correct before and why the new code is needed now.
Todos
get_hybrid_indices(crash site)reorderPrimsByFrag's primitive-duplication bugnewMakePrimitives'saddcartcontiguous-range bug_merge_block_windowsand wire it into both primitive-building methodsappend_prim_to_block's hybrid-block heuristic andadd_union_primitives's tuple unpacking for the new 5-tupleblock_infoRotator.derivativebugs found during verificationpyGSM/tests/test_interleaved_fragments.py): crash regression, no-duplicate-primitives,block_infopartition invariant, 3-fragment chained interleaving, and a permutation-equivalence check (same molecule built contiguous vs. interleaved,wilsonBand primitive values compared after permuting back)wilsonBoutput before/after on a synthetic case, andDelocalizedInternalCoordinatesconstruction +reorderPrimitives()confirmed working unchanged on a real molecule (diels_alder.xyz)Questions
master...claude/fragment-indices-error-clarity-rd5s93the branch naming/base you'd want, or should this target a different base branch?second_derivatives/calcCg(which the window-merge fix also covers) don't appear to have a live caller in the current GSM/optimizer driver flow — I didn't add direct tests for them beyond the fact that they share the now-fixedblock_infoinvariant. Flag if you know of a call path I should exercise directly.Status
pytest pyGSM/tests/test_interleaved_fragments.py), and existing lightweight tests (test_pygsm.py, plus manualDelocalizedInternalCoordinates/reorderPrimitives()construction ondiels_alder.xyz) show no regression. I could not runtest_basic_mecp.py's full optimization test in this environment (noxtbbinary available), so CI running that would be good extra confidence.Generated by Claude Code