feat(micromzpaf): pack IonAnnot into a u32 and add neutral losses - #104
Merged
Conversation
IonAnnot becomes a packed `u32` instead of a struct of fields, so equality is one word compare and `(IonAnnot, f32)` stays 8 bytes. A spectral library carries one annotation per fragment, so the type is replicated millions of times in a loaded arena. The new capacity is spent on the annotations an mzSpecLib reader needs: neutral losses, internal fragments, bare immonium ions, and the mass-error suffix. Losses are keyed by atomic composition rather than by spelling, because libraries write the same chemical loss different ways (`-CH3SOH` in NIST, `-CH4OS` in SpectraST) and fragment labels must be unique within a precursor. Losses outside the table are rejected, never coerced onto a nearby representable ion. Drops the rustyms dependency: `from_fragment` had no callers. `UnknownIonCounter` replaces the counter three readers each hand-rolled. Skyline's used `saturating_add`, so past 255 unknown ions it reissued `?255` and made every later peak carrying that label unreachable. `IonSeriesTerminality` is gone -- nothing called `terminality()`. `Series` takes its place in the re-export and resolves the standalone-series-enum TODO in `FragmentLabel`.
Apex-finder bench
Sensitivity + timing across canonical scenarioscommit ab85106 |
jspaezp
commented
Aug 28, 2026
| use crate::IonParsingError; | ||
|
|
||
| /// Slots in [`Composition`], in the order [`Composition::new`] takes them. | ||
| const C: usize = 0; |
Collaborator
Author
There was a problem hiding this comment.
can we save this by making it a struct?
jspaezp
commented
Aug 28, 2026
| /// from C/H/N/O/S/P, and keeping it to six `u8`s makes equality a single | ||
| /// 6-byte compare during the parse-time table lookup. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | ||
| pub(crate) struct Composition([u8; 6]); |
Collaborator
Author
There was a problem hiding this comment.
Q: why not a struct?
Collaborator
Author
There was a problem hiding this comment.
the slot could even be indexed on the enum ... like get_mut(&mut self, char) ??
Review follow-ups, all mechanical. The crate doc led with a false claim: the predecessor was already 4 bytes, so `size_of` pins nothing on its own. The argument is against the alternative -- bolting a loss byte onto the old struct would have made a 5-byte key that pads to a 12-byte tuple, growing timsseek's inline storage 104 -> 156. The test now asserts the added fields cost no space rather than restating a size that never changed. Isotope offsets become unsigned. `ISOTOPE_MIN = -7` let `Display` emit `y5+-2i`, which is not mzPAF and reparsed happily -- so a library could round-trip through serde into text no other reader accepts, contradicting the crate's own doc. Rejecting negatives makes that spelling unreachable and frees the sign bit, widening the useful range from 0..=7 to 0..=15. `const` assertions replace the prose contract: total width, abutting shifts, two internal endpoints per payload, the immonium alphabet, both zigzag bounds, and that the loss table has not outgrown `LOSS_BITS` (`pack` masks the discriminant, so a 64th loss would decode as a different one). `TABLE` becomes the index for `from_discriminant` and `canonical`, deleting a 13-arm hand-mirrored match and turning two linear scans into `TABLE.get`. The drift the old round-trip test guarded against is now one invariant. `IonAnnot` loses `Ord`. The packed word sorts by ordinal, then loss, then isotope, then charge, then series, which is not an order anyone means; `KeyLike` never required it, and the three downstream `PartialOrd` derives had no call sites. `UnknownIonCounter` loses `Copy`/`Clone`: a forked uniqueness counter reissues labels, which is the bug it exists to prevent. `ElutionGroupInput::try_fill_labels_annot` mints through that counter instead of casting an index, so running past the label space errors rather than wrapping into duplicates; it also no longer stamps isotope 1 onto every placeholder. `try_fill_labels_u8` rejects >255 fragments for the same reason. `ParsingError.context` was an `Option` that was never `None`, and two sites reported the wrong thing: a bare `I` as a *modified* immonium, and `p1` as an out-of-range ordinal for a series that takes none. Fixes the two `cargo doc` failures: public docs linked private items.
jspaezp
commented
Aug 28, 2026
Collaborator
Author
There was a problem hiding this comment.
Q: why not update the examples with the things that are representable now?
jspaezp
commented
Aug 28, 2026
| //! at all. Because a bit field truncates rather than wrapping loudly, every | ||
| //! constructor range-checks -- see [`IonAnnot::try_new`]. | ||
| //! | ||
| //! # mzPAF compliance |
Collaborator
Author
There was a problem hiding this comment.
pretty over-stated .. and not that useful TBH
jspaezp
commented
Aug 28, 2026
| //! internal fragments, immonium ions -- fit *without* growing the type. | ||
| //! | ||
| //! # mzPAF Format Compliance | ||
| //! That is the whole argument, and it is about the alternative rather than |
Collaborator
Author
There was a problem hiding this comment.
"That is the whole argument, and it is about the alternative rather than" is useless in this comment
PR review notes. `Composition` becomes a struct of named `u8`s instead of `[u8; 6]` plus six index constants, so `TABLE` reads as chemistry: `H2O` is `h: 2, o: 1` rather than `Composition::new(0, 2, 0, 1, 0, 0)`, where a transposition is invisible on review and would alias one loss onto another. The symbol-to-field mapping now lives once, in `count_mut`, which the parser indexes instead of keeping its own list of slots. Rows use a local `C!` macro: a plain struct literal has to spell all six counts, which is the positional noise the struct is meant to remove, and `..ZERO` is not allowed bare in a `const` item. Crate doc: drop the sentence announcing what the argument is instead of making it, and retitle "mzPAF compliance" -- this parses a subset, so the heading overstated it. The examples now show a neutral loss with its two spellings, an internal fragment, an immonium ion, the mass-error suffix, and two rejections, rather than two variations on `b12`.
Reader errors said "DIA-NN precursor parsing error" and nothing else. The `From<IonParsingError>` impl that flattened them had no way to see the row, so it is replaced by a `map_err` adaptor that stamps one on, and the three `*ReadingError`s now carry the precursor error instead of discarding it. A bad row aborts the whole load, so the row index is the only handle the user gets. `CHARGE_MAX` is 8, not 7. The bias means the field holds `-7..=8`; capping at 7 rejected a charge the encoding can represent. The `const` assertion is what keeps this honest, and it is why `CHARGE_MIN` cannot follow suit: `-8` zigzags to 17 and truncates to 1, decoding as charge 0. The `.speclib` fragment charge is a raw wire byte and was cast with `as i8`, turning anything above 127 into a plausible negative charge. It is range-checked now. The sibling `typ()` is masked `& 0x7F` for DIA-NN flag bits; if this byte carries flags too, this is where it surfaces (#105). The isotope-offset failure said the situation "should never happen" instead of what the limit is. It now names the fragment and the representable range. `TryFrom<&str>` still drops the mass-error suffix -- the suffix belongs to one observed peak, not to the ion, and storing it would make two `b12`s unequal -- but it warns once per process rather than doing it silently. `cargo doc --workspace` now passes and has a `task doc` target. It was failing in five crates: ten links to private items, and ten "broken" links that were never links at all (`[U:4]`, `["Carbamidomethyl@C"]`, `[0,1]`, `[Apply]`). Also drops `sort_vecs_by_first!`, which had no callers outside its own tests.
`Display for IonSeriesOrdinal` emitted `m2:11` and `IA`, but the code parsing those forms was inlined in `IonAnnot::parse_ion` -- an inverse pair split across a type boundary. Likewise `INTERNAL_POS_MAX` was checked in `IonAnnot`, 400 lines from the 6-bit packing that makes 63 the right bound; the `debug_assert` in `pack` was a debug-only backstop for an invariant two unrelated functions maintained by hand. `series` now owns all three: the spelling (`Display` next to `parse`), the payload layout (`to_parts` next to `from_parts`), and the bounds that layout imposes (`try_internal`, `try_immonium`). It exports `KIND_BITS`/`PAYLOAD_BITS` so the word can be assembled without knowing how they are filled, and asserts its own payload bounds. `to_parts` is total now -- every arm masks to its own width, so the immonium `- b'A'` cannot underflow for a variant built by hand. `IonAnnot` keeps two constructors instead of four: `new(series, loss, charge, isotope)`, and `try_new(char, ordinal, charge, isotope)` for the shape a file reader actually has. `try_new_with_loss`, `try_new_internal` and `try_new_immonium` are gone. `parse_ion` is now the three suffixes peeled in the one order that is unambiguous, then a delegation, and the module doc draws which delimiter belongs to which stage -- previously that ordering was load-bearing and only the `-` case said so. `lib.rs` drops 1210 -> 705 lines, with `error.rs` and `parse.rs` taking the rest. Tests moved with their subjects: the packing and spelling round trips are in `series`, where they can see the encoding they are pinning. The constructors are `try_internal`/`try_immonium` rather than `internal`/`immonium` because a constructor sharing a name with its variant is ambiguous to rustdoc, and reads ambiguously to a person too.
…ping them The `.speclib` reader discarded every fragment carrying a neutral loss -- 152 on the pinned fixture -- because `IonAnnot` could not represent one. It can now, so the only thing missing was DIA-NN's loss-code numbering, which is undocumented. Measured it instead of guessing. A lossy fragment and its no-loss sibling differ by exactly the loss, so `(no_loss_mz - lossy_mz) * charge` is the neutral mass. On the fixture, code 1 gives 18.011 across 47 pairs and code 2 gives 17.027 across 37: water and ammonia, with nothing else within half a dalton. The two account for all 152. `loss_codes_are_water_and_ammonia` re-derives that from the fixture rather than trusting the table, and fails on any code this build does not map -- so a DIA-NN version that renumbers them stops the load instead of relabelling peaks. An unmapped code is still dropped and counted, because a wrong loss puts a real m/z on a label that collides with a different real fragment.
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.
PR 2 of the #93 carve-up. Hard prerequisite for the mzSpecLib reader (PR 5), which cannot parse its fixtures'
-H2O/m3:7annotations without these constructors.Why packed
IonAnnotbecomes a packedu32so that the annotations an mzSpecLib-shaped library needs -- neutral losses, internal fragments, immonium ions -- fit without growing the type.The argument is about the alternative, not about the predecessor. The old representation was already 4 bytes (a 2-byte series+ordinal enum plus two
i8s), so this is not a shrink. But bolting alossfield onto that struct would have cost three bytes, not one: a 5-byte key paired with anf32pads to a 12-byte tuple, which at timsseek's inline capacity of 13 growsExpectedIntensities's inlineTinyVecstorage from 104 to 156 bytes. Packed, the tuple stays 8 bytes and the loss rides in bits nobody was using.Neutral losses are keyed by composition, not spelling
Libraries write the same chemical loss different ways (
-CH3SOHin NIST,-CH4OSin SpectraST). Since fragment labels must be unique within a precursor and lookup is first-match, keying on the string would hide a genuine duplicate behind two spellings. A loss outside the table is rejected rather than coerced onto a nearby representable ion, so a loss peak's m/z can never land on the barey5label.Side effects worth calling out
UnknownIonCounterreplaces a counter four sites each hand-rolled. Skyline's usedsaturating_addandElutionGroupInput's cast an index, so past 255 both reissued a label already handed out -- making every later peak carrying it unreachable. Both are now errors.IonSeriesTerminalityis deleted --terminality()had no callers.Seriestakes its slot in the re-export and resolves theFragmentLabelTODO that was waiting on exactly that type.IonAnnotis deliberately notOrd. The packed word sorts by ordinal, then loss, then isotope, then charge, then series -- not an order anyone means.KeyLikenever required it and the three downstreamPartialOrdderives had no call sites.Displayemity5+-2i, which is not mzPAF and reparsed happily -- so a library could round-trip through serde into text no other mzPAF reader accepts, contradicting this crate's own docs. Rejecting negatives makes that unreachable and frees the sign bit, widening the range from0..=7to0..=15.i8. Corpus maximum is 3. Every constructor range-checks, because a bit field truncates silently.rustyms leaves micromzpaf;
from_fragmenthad no callers. It stays a dependency of timsseek and speclib_build_cli, which is the mzcore migration's problem.Not done here
diann_speclib_io.rsis the one reader holding neutral-loss data and it still drops it (152 fragments on the pinned fixture), because DIA-NN's loss code byte needs a numbering this PR does not have. Guessing it would mislabel peaks, which is worse than dropping them. Filed as #105; the comment there no longer claimsIonAnnotcannot represent losses.split_mass_error/MassErroralso have no caller yet -- they ship with the parser they belong to, and PR 5 is the first consumer.Verification
cargo test --workspace, both feature-gatedcargo checks,-p timsseek --no-default-features,-p calibrt -p calib_dash,-p timscentroid --features mzdataall green.cargo +nightly fmt --checkclean. Clippy holds at 7, thetoo_many_argumentsset tracked in #32.RUSTDOCFLAGS="-D warnings" cargo doc -p micromzpafnow passes -- it failed onmain.The layout is self-checking:
constassertions cover total width, abutting shifts, two internal endpoints per payload, the immonium alphabet, both zigzag bounds, and that the loss table has not outgrownLOSS_BITS.