Skip to content

perf: remove the serial hot spots, and make a run reproducible from its config - #486

Merged
theGreatHerrLebert merged 14 commits into
mainfrom
perf/serial-hotspots
Sep 17, 2026
Merged

theGreatHerrLebert merged 14 commits into
mainfrom
perf/serial-hotspots

Conversation

@theGreatHerrLebert

@theGreatHerrLebert theGreatHerrLebert commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

Removes the serial hot spots in the simulation pipeline, and fixes a
reproducibility defect found while verifying the speedups.

Releases imspy-connector 0.4.4, imspy-core 0.4.4, imspy-simulation 0.4.5.

The Bruker SDK was forcing everything onto one thread

tims_index_to_mz is not safe to call concurrently on one handle. rustdf handled
that by serialising: whenever use_bruker_sdk was set, which is the default in
every config, get_pasef_fragments and get_preprocessed_pasef_fragments ignored
num_threads entirely, and the DIA noise overlay and TDF writer ran frame by frame.
Symptom that surfaced it: a seeded parallel overlay gave m/z differing by up to
4e-5 Th on ~8 % of peaks, in 3/8 runs at 4 threads and 7/8 at 32.

Parallel regions now read through BrukerFormulaConverter, which evaluates the
calibration curves the .d file itself carries and is Sync.

It is only substituted where it is provably exact. sdk_free_converter() is
gated on is_sdk_exact(): every frame must resolve to a real calibration row — no
silent fallback for NULL T1/T2 or an unsupported row — and every MzCalibration
row must be ModelType 1, the only model this converter reproduces bit-for-bit.

MzCalibration ModelType agreement with the SDK writer output
1 exact, 0 of 1,200,000 arbitrary m/z differ byte-identical
2 ~2 ppm (C8..C14 fine correction not modelled) 11.5 % of m/z land in a neighbouring TOF bin

Mobility is exact under both; only the m/z axis is affected. ModelType 2 files stay
on the serial SDK path. Note this is per calibration, not per instrument or software
version — the same instrument on the same timsControl version produced both.

Batched TDF writer

Per-step profile over 700 real frames: SDK conversion 56 %, interleave 31 %, zstd
5 % — the phase named "convert+compress+write" was never compression-bound.
TDFWriter.write_frames now hands a batch to rustdf, which converts, dedups
(scan, tof), interleaves and compresses over a rayon pool. Only the append and the
running byte offset stay sequential.

Passing numpy arrays straight through is what makes it pay: the pre-existing
get_data_for_compression_par took Python lists, and the .tolist() marshalling
alone was 21 % of writer time.

Output is byte-identical to the per-frame path (700/700 frames at 1, 4 and 16
threads). Writing the same frames both ways and comparing whole .d folders: the
Frames table matches in all 18 columns including the TimsId offsets, and reading
both back gives 600/600 identical frames over 2.55 M peaks through the Bruker SDK
reader and the SDK-free reader. The files are equivalent, not bit-identical: 4 bytes
of 8.6 MB differ inside the zstd stream, decompressing to identical payloads,
because the Python zstd module and the Rust zstd crate link different libzstd
builds.

Also fixed here: zstd::encode_all omits the decompressed size from the frame
header, and the simple API every existing .d was written with then refuses to read
the frame. Uses zstd::bulk::compress.

Batched sage digest

generate_indexed_database() sets up sage's parallel index build, and it was called
once per protein — ~3 000 core-seconds on a 120-core machine for a stage that ran
faster with 4 threads. Now one database per 2 000-protein chunk. Peptide sets per
protein are unchanged: sage attaches every protein a peptide occurs in.

A run was not reproducible from its config

Two runs of an identical config with the same sample_seed shared 11 of ~1690
peptides
. Two independent causes:

  1. The global NumPy RNG was never seeded. sample_seed reached only the pandas
    down-sample and the noise overlay. Everything deciding what gets simulated used
    bare np.random.* — peptide selection and abundance, EMG retention-time shape,
    ion-mobility and scan variance, phosphosite choice, the decoy draw — 16 call sites
    in timsim/jobs/ alone.
  2. Both digest paths returned a set, and the sampler draws from it with
    np.random.choice. Set iteration order follows Python's per-process string hash
    seed, so the draw varied even once the RNG was seeded. Both now return sorted lists.

Every intermediate table is now bit-identical between runs — proteins, peptides,
ions, fragment_ions, frames, scans — and stays identical across thread counts (4 vs
16). Datasets generated before this cannot be reproduced by re-running their config;
they never could.

The m/z jitter is seeded too. add_mz_noise_uniform/add_mz_noise_normal drew from
rand::thread_rng, so which thread built a frame decided its jitter, and a shifted m/z
occasionally crossed a TOF bin boundary and changed the dedup. mscore gains _with_rng variants,
mscore::simulation::noise_rng derives a stream from a master seed plus the identifiers already in
scope, and the four sim builders carry a noise_seed and use one RNG per frame keyed by
(seed, frame_id). It defaults to 0, keeping the old behaviour for API users who never set it.

Two runs of an identical config now produce a byte-identical analysis.tdf_bin — same sha256
over 93 MB — with the Frames table and every ground-truth table identical, and the ground truth
stays identical across thread counts (4 vs 16).

Not covered: reproducibility across different hardware. Retention time and fragment intensity come
from GPU models, so a different GPU or toggling use_gpu may change the ground truth. Untested.

Measured

Intel Xeon w9-3595X, 120 logical cores, RTX 5090, 60 min dia-PASEF, HeLa digest,
reading the reference from disk. Full grid of 25 runs per version.

run 0.4.2 this PR
DIA 5k, 120 threads 352 s 123 s
DIA 50k, 120 threads 581 s 243 s
DIA 250k, 120 threads 1283 s 593 s
DIA 500k, 120 threads 2764 s 1387 s
DIA 50k, 4 threads 1027 s 722 s

Warm-up at 120 threads, 5k peptides: total 371 -> 90 s, frame assembly 238 -> 27 s,
of which the noise overlay is 87.4 -> 8.6 s and the writer 45.5 -> 13.8 s. The FASTA
stage went from ~3 000 to ~148 core-seconds. Replicates at 50k: CV 0.23 %.

Cost: peak RSS is up, 15 % at 250k and 23 % at 500k, because the parallel overlay
holds a batch of frames in flight. Capped runs still pass (250k completes under a
32 GB cap) but 500k now peaks at 64.8 GB uncapped.

Tests

195 Python passed / 15 skipped, 145 Rust passed. New coverage: batched-vs-per-protein
digest parity with and without decoys, digest output ordering, overlay determinism at
1/4/32 threads with the SDK enabled, SDK-vs-formula converter parity, writer
byte-identity at 1/4/16 threads, whole-.d-folder equivalence, the ragged-input and
mismatched-length error paths, the noise-stream derivation, and that the seeded jitter is
reproducible and stays inside the requested ppm window.

… telemetry

Profiling a 250k-peptide DIA run on a 120-core box showed ~90 % of wall time
does not scale with threads; frame assembly (70 % of wall) spent ~85 % of its
time on the main thread, dominated by the per-frame TDF writer.

- tdf.py: dedup of (scan, tof) cells via a packed uint64 key + 1-D np.unique
  instead of np.unique(axis=0) + lexsort (10-15x faster, byte-identical
  output); keep analysis.tdf_bin open for the whole run instead of
  open/append/close per frame (34k syscalls, expensive on NFS). Handle is
  closed in write_frame_meta_data() and re-opened transparently if needed.
- utility.py: python_list_to_json_string rounds the whole array once instead
  of a NumPy scalar round per element (called once per peptide/ion in four
  stages); output byte-identical, float32 inputs keep float32 rounding.
- assemble_frames.py: per-batch timers for build / noise / write, logged and
  returned.
- simulator.py: StageTracker records wall, CPU seconds (all threads), avg
  cores and peak RSS per stage, logs each transition, prints a stage table
  and writes timings.json next to synthetic_data.db. Sub-laps inside the
  FASTA stage (digest+rt_filter, sample_peptides).
- tests: parity tests against the previous implementations, handle
  lifecycle, tracker output.

Smoke run (5.7k frames, 2k peptides, 16 threads): assembly 54 s -> 38 s,
total 156 s -> 138 s; .d reads back with imspy.
The Bruker SDK's tims_index_to_mz is not safe to call concurrently on one
handle. rustdf handled that by dropping to a single thread whenever
use_bruker_sdk is set, which is the default: get_pasef_fragments and
get_preprocessed_pasef_fragments ignored num_threads entirely in SDK mode.

The formula converter evaluates the calibration curves the .d file itself
carries and is Sync. Measured against the SDK on a real reference over 73k
peaks: worst |dmz| 3.2e-11 Th, worst 6.7e-8 ppm, mobility within one ULP,
54% of peaks bit-identical. That is orders of magnitude below any search
tolerance, and below the 4e-5 Th run-to-run drift concurrent SDK calls
were producing.

So parallel regions now read through that converter instead of serialising:

- TimsLazyLoder / TimsInMemoryLoader gain get_frame_with_converter, taking
  the converter explicitly rather than using self.index_converter.
- TimsDataLoader gains sdk_free_converter() and raw_data_path().
- The two DDA PASEF paths and the DIA reference-noise overlay use it, and
  fall back to one thread only when the file carries no usable calibration
  tables.

Adds a seeded overlay_reference_noise on TimsDatasetDIA (rayon, per-frame
StdRng derived from seed and frame id) plus its connector and Python
bindings, and generate_random_sample_with_rng on TimsFrame.
… overlay

proteins_to_peptides_batched builds one sage indexed database per 2000
proteins instead of one per protein. generate_indexed_database sets up
sage's parallel index build, so calling it 20000 times burnt ~3000 core-s
on a 120-core box for a stage that ran faster with 4 threads. Peptide sets
per protein are unchanged: sage attaches every protein a peptide occurs in.

The DIA noise job now calls the batched, seeded overlay_reference_noise
when the connector provides it, passing sample_seed, and keeps the serial
per-frame loop as the fallback.

Smoke A/B, 2000 peptides, 5692-frame DIA reference, 16 threads, identical
config: wall 156 s released -> 138 s -> 119 s; frame assembly 54 -> 37.6 ->
21.6 s, of which the noise overlay is 18.9 -> 2.5 s. Output reads back with
imspy; peak counts differ by the reseeded noise sampler.

Tests cover batched-vs-per-protein digest parity with and without decoys,
overlay determinism across 1/4/32 threads with the SDK enabled (the case
that was flaky before the rustdf fix), and SDK vs formula converter parity.
The round-2 work and the rustdf SDK fix change behaviour and performance, so
they need version numbers of their own: the overnight timing grid ran against
a different code state that also called itself 0.4.3, which would have made the
manuscript's version reference ambiguous.

imspy-core moves too because the Python wrapper for the batched overlay lives
there; without it the noise job silently falls back to the serial per-frame
loop. imspy-simulation now requires >=0.4.3 of both so that cannot happen.
Profiling the TDF writer per step over 700 real frames (3.07M peaks) put 56 %
of its time in the Bruker SDK's mz->tof and 1/K0->scan calls, 31 % in the
interleave, and only 5 % in zstd — so the phase named "convert+compress+write"
was never compression-bound, and tuning the zstd level would have been wasted.

The conversions ran one frame at a time for the same reason the noise overlay
did: they went through the SDK, which is not safe to call concurrently. In this
direction the SDK-free converter is not merely close but exact — over 1,702,413
values, mz->tof and 1/K0->scan agree with the SDK bit for bit, because the
outputs are integers and the last-bit float drift never crosses a rounding
boundary.

TDFWriter.write_frames now hands a whole batch to rustdf, which converts,
dedups (scan, tof), interleaves and compresses across a rayon pool and returns
the payloads plus the statistics the Frames row needs. Only the append and the
running byte offset stay sequential. Passing numpy arrays straight through
matters: an earlier parallel interleave existed but took Python lists, and the
.tolist() marshalling alone was 21 % of writer time, which ate the gain.

Output is byte-identical to the per-frame path, 700/700 frames at 1, 4 and 16
threads, and a test pins that. Getting there caught a real bug: zstd::encode_all
omits the decompressed size from the frame header, and the simple API every
existing .d was written with then refuses to read the frame. Uses
zstd::bulk::compress, which writes it.

Writer throughput on 700 frames: 1.36 s per-frame Python, 0.32 s batched at one
thread, 0.056 s at 16 (24x). End-to-end smoke, 16 threads: the write phase falls
11.8 -> 2.5 s and frame assembly 21.6 -> 10.0 s.

Also removes the commented-out compress_frames/write_frames skeleton this
replaces; it was shelved as "needs debugging" and had omitted the dedup, which
would have corrupted output since mz->tof is not injective.
…ly exact

The exactness claim in the previous two commits rested
on a single fixture. It does not generalise, and the code's own doc comment said
so: BrukerFormulaConverter is bit-exact for MzCalibration ModelType 1, but
ModelType 2 omits the proprietary C8..C14 fine correction and lands ~2 ppm away.

Measured on the thunder blank, which is ModelType 2 and is a reference file the
timing grid uses: feeding it arbitrary m/z — what the writer actually receives —
11.5 % of values land in a neighbouring TOF bin. That changes the (scan, tof)
dedup and so the bytes written. The earlier test missed it twice over: its
fixture is ModelType 1, and it fed back m/z the SDK itself had produced, which
sit at bin centres and so are the case least likely to cross a rounding
boundary. Mobility is exact under both models; only the m/z axis is affected.

sdk_free_converter() now returns the converter only when is_sdk_exact() holds:
every frame resolves to a real calibration row — no silent fallback to the
default for NULL T1/T2, a missing Frames row, or an unsupported row — and every
m/z row it uses is ModelType 1. Otherwise callers stay on the SDK, serially.
Verified on real data: the ModelType-2 thunder blank is refused, the ModelType-1
dia blank accepted.

Also hardened here:
- build_compressed_frames validates that each frame's mz/mobility/intensity have
  equal length. A ragged triplet used to index out of bounds inside Rust; it now
  returns an error, as does a thread-pool or zstd failure instead of unwrapping.
- The Python fallback in write_frames no longer swallows every exception. Only
  the explicit UNAVAILABLE capability signal falls back to the per-frame path;
  anything else propagates rather than quietly writing a different file.

Tests: 193 Python passed / 15 skipped, 136 Rust passed.
The batched writer and the converter exactness gate change both behaviour and
output timing, so they need their own version numbers: the 0.4.4 timing grid
already ran against a different code state, and the manuscript cites a version.
The existing test compared compressed payloads in isolation. This writes the
same frames through both paths and compares what lands on disk: the Frames
table including the TimsId byte offsets, which depend on every preceding
frame's compressed length, and the decompressed content of every frame.

Running it on 600 frames turned up the one real nuance. The files are
equivalent but not byte-identical: 4 bytes in 8.6 MB differ, in 2 frames of
600, entirely inside the zstd stream. Those blobs decompress to identical
payloads, the compressed lengths match, and the Frames table is identical in
all 18 columns. The cause is that the Python zstd module and the Rust zstd
crate link different libzstd builds and can choose a different, equally valid
encoding at the same cost. Reading both files back frame by frame — through
the Bruker SDK and through the SDK-free reader — gives 600/600 identical
frames over 2.55M peaks in mz, tof, scan and intensity.

So the contract the test pins is the decompressed data and the table, not the
byte encoding.
Two runs of an identical config with the same sample_seed shared only 11 of
~1690 peptides. Same configuration, effectively unrelated datasets. The 1.4 %
peak-count difference that surfaced this was the mild symptom; the datasets
underneath had almost nothing in common.

Two independent causes, both upstream of anything seeded:

1. The global NumPy RNG was never seeded. sample_seed reached exactly two
   places — the pandas down-sample in simulator.py and (since 0.4.4) the
   reference-noise overlay. Everything deciding *what* gets simulated used bare
   np.random.*: peptide selection and abundance in simulate_peptides, the EMG
   retention-time shape, ion-mobility and scan variance, phosphosite choice,
   the decoy draw in digest_fasta. 16 call sites in jobs/ alone.

2. Both digest paths returned a `set` of peptide strings, and the sampler draws
   from it with np.random.choice. Set iteration order depends on Python's
   per-process string hash seed, so the draw differed run to run even with the
   RNG seeded — verified directly: same seed, three processes, three different
   selections. Both digests now return sorted lists.

With both fixes every intermediate table is bit-identical between two runs:
proteins, peptides, ions, fragment_ions, frames, scans. That is the ground
truth the benchmarking compares against, so benchmarks are now reproducible
from a config.

Known remaining gap: the written .d still differs by ~0.002 % of peaks (570 of
29 M, and 37 counts of summed intensity). That is the m/z jitter — mscore's
add_mz_noise_uniform/normal use rand::thread_rng at ~28 call sites across
mscore and rustdf/src/sim. A shifted m/z occasionally crosses a TOF bin
boundary and changes the dedup. Fixing it means threading a seeded per-spectrum
RNG through those call sites, as generate_random_sample_with_rng already does
for the overlay.

Note this changes which peptides a given config selects: datasets generated
before this cannot be reproduced by re-running their config, because they were
never reproducible to begin with.
The last source of run-to-run variation. add_mz_noise_uniform/normal drew from
rand::thread_rng, so which thread built a frame decided its jitter. Not
cosmetic: a shifted m/z occasionally crosses a TOF bin boundary, which changes
the (scan, tof) dedup and so the peaks written to analysis.tdf_bin. Two runs of
an identical config differed by ~0.002 % of peaks even with the ground truth
already reproducible.

mscore gains `_with_rng` variants of both, and `add_mz_noise` is now generic
over the RNG rather than pinned to ThreadRng. mscore::simulation::noise_rng
derives a stream from a master seed plus the stable identifiers already in
scope, so the draw does not depend on thread count or scheduling.

The four sim builders carry a `noise_seed` and create one RNG per frame, keyed
by (seed, frame_id): within a frame the spectra are built in a deterministic
order, and frames are independent. 24 call sites across dia, dda, precursor and
lazy_builder. `noise_seed` defaults to 0, which keeps the old thread_rng
behaviour for API users who never set it; TimSim sets it from the run's
sample_seed via create_frame_builder.

Two runs of an identical config now produce a byte-identical analysis.tdf_bin —
same sha256 over 93 MB — with the Frames table and every ground-truth table
identical too.

Tests: 38 mscore (5 for the stream derivation, 4 for the seeded jitter including
that the jitter stays inside the requested ppm window and that different frames
do not share a stream), 107 rustdf, 195 Python.
…c a worker

The 4-thread run of the timing grid crashed on a NaN comparison in mscore's
frame-occurrence search. It had passed in three earlier grid passes; seeding the
global NumPy RNG changed which values are drawn and one draw landed in a
degenerate region that was always reachable.

Root cause: `np.random.beta` returns exactly 0.0 often enough to matter (22 of
50 000 draws at alpha=0.01), and `k_lower_rt` defaults to 0, so a zero sigma or
lambda was reachable. `estimate_mu_from_mode_emg` divides by their product, so
the erfcx target became inf, which passed the `y > 0` assert, and the Newton
iteration returned NaN. That NaN flowed into the retention-time bounds, and
`calculate_frame_occurrence_emg` unwrapped a `None` from `partial_cmp` on NaN.

Three layers, root first:

- Sampling: sigma and the k/lambda partner are clamped to a floor of 1e-12.
  Neither is a physical peak at zero and the downstream maths requires positive
  values. Note even the smallest non-zero draw observed was 1e-321, subnormal,
  whose reciprocal is inf anyway.
- `erfcxinv`: the assert now requires finite as well as positive, so a
  degenerate parameter fails loudly rather than silently returning NaN. The
  Newton step is guarded where the derivative underflows: d/dx erfcx(x) tends to
  0 as x grows, which made the unguarded iteration NaN for y below ~1.1e-8. The
  start value is already the asymptote there, so keeping the current iterate is
  both safe and accurate — verified identical to machine precision across
  y in [1e-8, 1e2] wherever the old code produced a finite answer.
- mscore: non-finite bounds return an empty frame list, since a peptide with no
  finite elution window occupies no frames, and the closest-frame searches use
  `total_cmp` so they cannot panic whatever reaches them. The same
  `partial_cmp().unwrap()` hazard in the ion-mobility search is fixed too.

Being deterministic is what surfaced this: before seeding, it could strike any
run at low probability and look like bad luck.

Tests: 208 Python passed / 15 skipped, 145 Rust passed. The new Python test is
not vacuous — its beta parameters produce exact zeros, so it fails without the
clamp.
Profiling the thread sweep showed FASTA processing flat at ~47 s from 8 to 120
threads, 26 % of total wall time at 120 threads and the ceiling on any further
scaling. Inside it, `sample_peptides` was 33 s and identical at 50k and 250k
peptides, which pointed at work proportional to something other than the
requested complexity.

The cause was `calculate_peptide_mono_isotopic_mass` rebuilding the 2 073-entry
UniMod table, rebuilding the amino acid table and recompiling the UniMod regex
on every call. One mass calculation cost ~190 us; 250 000 peptides cost 48 s.
The same per-call `Regex::new` appeared in five more places across
chemistry/utility.rs and data/peptide.rs.

All of them are now `LazyLock` statics, built once. That alone takes 250 000
peptides from 47.7 s to 5.1 s (9.4x) and benefits every caller with no API
change. The deliberate sort that keeps the sum bitwise reproducible is
untouched.

On top of that the connector gains `py_peptide.mono_isotopic_masses`, a batched
rayon entry point, and `simulate_peptides` uses it: 0.51 s at 16 threads, 93x
against the original, with masses bitwise identical to the per-peptide path and
invariant across thread counts. It falls back to the per-peptide loop on an
older connector.

Note the submodule is an attribute of the extension module rather than an
importable path, so `from imspy_connector.py_peptide import ...` raises
ModuleNotFoundError; getting it off the module object is what makes the fast
path actually reachable. The first version of this commit had the broken form
and silently stayed on the slow path.

End-to-end smoke, 16 threads: the FASTA stage falls 79.9 -> 43.3 s, of which
`sample_peptides` is 76.8 -> 40.2 s.

Remaining in that stage, and now dominant: neural retention-time prediction runs
over the whole digested set to filter early eluters, before the run down-samples
to the requested complexity. Every grid config digests 250 000 peptides whatever
the target, so a 5k run predicts 250 000 retention times and discards 98 % of
them. Reordering those two steps changes which peptides survive the filter, so
it is a modelling decision rather than a mechanical fix, and is not done here.

Tests: 214 Python passed / 15 skipped, 147 Rust passed, including that batched
and per-peptide masses agree exactly and that modifications are still counted.
The digest produces `num_peptides_total` peptides regardless of the requested
complexity — every grid config digests 250 000 — and the neural retention-time
predictor ran over all of them so that the run could discard most of the output
immediately afterwards. A 5k run predicted 250 000 retention times to keep
5 000; the smoke config predicted 250 000 to keep 2 000.

`simulate_peptides` now takes the target count and thins the table before the
predictor rather than after it. The thinning carries a 1.3x margin because the
retention-time filter below removes part of the early-eluting population, and
without headroom the table could fall under what the caller asked for. That
filter is density-based — it thins the early bins down to the median bin count —
so it behaves the same on a uniformly thinned table as on the full one.

Runs at or above the digest target are untouched: the thinning only applies when
the table is larger than the request, so the 250k and 500k datasets take exactly
the path they did before.

End-to-end smoke, 16 threads, 2 000 peptides kept: the FASTA stage falls
43.3 -> 6.5 s, its sampling lap 40.2 -> 3.4 s, and total wall 72 -> 35 s.

This does change which peptides a small run selects, since the thinning now
happens before the retention-time filter rather than after it. Reproducibility
is unaffected: two runs of the same config still produce a byte-identical
analysis.tdf_bin and identical ground-truth tables.

Also fixes the accompanying log line, which subtracted the filtered length from
itself and so always reported "Excluded 0 peptides with low retention times".

Tests: 215 Python passed / 15 skipped.
…st tests

The ordering test read a fasta from a developer machine and had no skip guard, so it failed in
CI with FileNotFoundError. Ordering is a property of the digest, not of any particular proteome:
it now runs on four synthetic proteins defined in the module.

The parity test keeps the local HeLa subset when it is present and falls back to the same
synthetic proteins otherwise, so CI exercises the batched digest instead of skipping it.
@theGreatHerrLebert
theGreatHerrLebert merged commit fad938a into main Sep 17, 2026
5 checks passed
theGreatHerrLebert added a commit that referenced this pull request Sep 23, 2026
…ed CI (#487)

* test(imspy-predictors): make the GMM fit test deterministic

test_fit started its means at unseeded N(0, 1) draws. In about 4% of runs
(8 of 200 measured) all three components collapsed into one cluster and
'assert 1 > 1' failed, which is what stopped the Python 3.12 job on main
after #486 and skipped the imspy-simulation suite. Start the means at data
points under a fixed seed: 0 of 200 runs collapse.

* feat(imspy-simulation): depend on mzprov for output signing

emit_provenance is on by default, but mzprov was not installable from PyPI,
so a normal install silently skipped signing with a warning. mzprov 0.1.1 is
now on PyPI: declare it, drop the ImportError fallbacks, and keep signing
non-fatal. The .raw docstring's claim that mzprov cannot sign vendor .raw is
out of date and goes.

test_mzprov_signing runs each vendor path's signer (.d and mzML, sidecar and
embedded; .raw) against mzprov's fixtures and checks the result with mzprov's
verifier.

* docs(imspy-simulation): mzprov 0.1.2's default key location

mzprov 0.1.2 keeps new keys under ~/.config/mzprov/keys/ and uses an
existing ~/.config/timsim/keys/ key in place, so TimSim users keep their
signing identity. Require 0.1.2 and describe the default accordingly.
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