Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
784ef4b
refactor: make a source id polymorphic, keeping the shape the file used
jspaezp Aug 28, 2026
1fb6274
feat(timsquery): propagate DIA-NN's own precursor name into results
jspaezp Aug 28, 2026
b3744af
fix(calib_dash): carry the id as text, since an id is no longer alway…
jspaezp Aug 28, 2026
a75ae0a
fix: close the gaps a review found in the id propagation
jspaezp Aug 28, 2026
e1c7572
fix(timsquery): the DIA-NN ids never reached the arena
jspaezp Aug 28, 2026
26c8632
refactor(timsquery): one source-id setter instead of three
jspaezp Aug 28, 2026
1bb7dc1
docs: correct the format-version list, the shape claim, and stale com…
jspaezp Aug 28, 2026
17f82e6
fix(timsquery): make a dropped column a compile error, and shrink dec…
jspaezp Aug 28, 2026
f964c48
style: use the imports that are already in scope
jspaezp Aug 28, 2026
d1efdb7
fix(calib_dash): compare calibrant identity by hash, restoring the bu…
jspaezp Aug 28, 2026
52d84bd
fix(timsquery): name the row that breaks a mixed-shape library, and p…
jspaezp Aug 28, 2026
2598991
refactor(timsquery): intern decoy groups behind an opaque code
jspaezp Aug 28, 2026
b4ba007
refactor(timsseek): stop the id at the arena, carry handles instead
jspaezp Aug 28, 2026
6451904
fix(timsquery): name the precursor that costs a library its ids
jspaezp Aug 28, 2026
ab67e5f
refactor(timsquery): build a flyweight from the handle that already n…
jspaezp Aug 28, 2026
1feff7c
chore: address the second review round's mechanical findings
jspaezp Aug 28, 2026
5518c2a
refactor(timsquery): push a row's id with the row
jspaezp Aug 28, 2026
6d88c00
docs: trim the comments flagged in review, and stop the placeholder a…
jspaezp Aug 28, 2026
5f87304
style: replace every em dash with `--`
jspaezp Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ data; a query is the act of looking.

**Library**:
A collection of targets that also carries expected or observed fragment
intensities. Intensities are what make it a library without them it is targets.
intensities. Intensities are what make it a library -- without them it is targets.
Note this is narrower than the field's usage, where "spectral library" covers
anything mapping analytes to m/z values.
_Avoid_: spectral library (ambiguous), speclib
Expand All @@ -37,7 +37,7 @@ determinism, so it is never caller-supplied.
_Avoid_: id, library_id, row id

**Source id**:
What the source file called a precursor the JSON target payload's `id`,
What the source file called a precursor -- the JSON target payload's `id`,
mzSpecLib's `<Spectrum=N>` key, DIA-NN's `transition_group_id`. Opaque: carried
through and echoed back, never used to address anything. Absent in some formats.
_Avoid_: id, library id
Expand All @@ -56,7 +56,7 @@ discovery rate. Either shipped by the library or generated as a mass shift.
A target and its decoy variants, competing as a unit so exactly one survives.

**Variant**:
One member of a decoy group the target itself, or one of its mass-shifted
One member of a decoy group -- the target itself, or one of its mass-shifted
decoys. A stored row expands into several scored variants, so "one row" is not
"one result".

Expand All @@ -68,7 +68,7 @@ contents: whether sequences are available, whether fragment labels carry ion
chemistry, how isotopes are derived, how decoys are obtained.

**Graceful degradation**:
Proceeding with a capability absent rather than failing skipping FDR when there
Proceeding with a capability absent rather than failing -- skipping FDR when there
are no decoys, skipping sequence-dependent scores when sequences are unavailable.
The gate is per-score, not per-run; only some scores need sequences.

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ parquet = { version = "59.2" }
arrow = { version = "59.2", default-features = false }

# Set as the global allocator on windows and musl, whose own allocators are
# slower here musl's serialises on one lock. Every binary that wants it repeats
# slower here -- musl's serialises on one lock. Every binary that wants it repeats
# the same `cfg(any(target_os = "windows", target_env = "musl"))` gate. Only the
# deployables carry it: the CLIs and the viewer. The rest are dev utilities and
# may run slow on those targets.
Expand Down
16 changes: 8 additions & 8 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ Run any with `--help` for the full flag list.
| Feature | Crate | Effect | Use case | Enable |
|---------|-------|--------|----------|--------|
| `parallel` / `rayon` | `timsseek_cli` / `timsseek` | Rayon parallel scoring | Default; fastest wall-time | On by default |
| `instrumentation` | `timsseek_cli` / `timsseek` | `tracing-profile` perfetto spans | Perf tracing. **Requires `--no-default-features`** the perfetto backend captures only the main thread, so rayon worker spans are dropped entirely. Run serial or traces for the hot path are empty. | `--features instrumentation --no-default-features` |
| `instrumentation` | `timsseek_cli` / `timsseek` | `tracing-profile` perfetto spans | Perf tracing. **Requires `--no-default-features`** -- the perfetto backend captures only the main thread, so rayon worker spans are dropped entirely. Run serial or traces for the hot path are empty. | `--features instrumentation --no-default-features` |
| `track-alloc` | `timsseek_cli` | Global allocator tracking via `alloc_track` | Binary prints per-phase allocation deltas to stderr: `[alloc] <phase> d_bytes=... d_live=... churn=... peak=... hist=...`. Detect churn + memory regressions. Dev-only; do not ship. | `--features track-alloc` |
| `dashboard` | `timsseek_cli` / `rescore_dash` | Ratatui TUI of a rescoring run: score separation, per-feature histograms, FDR and calibration curves | Interactive dev inspection. Dev-only; also needs `TIMSSEEK_RESCORE_DASHBOARD` at runtime (below). | `--features dashboard` |
| `calib-dashboard` | `timsseek_cli` | Pulls in `calib_dash`, wiring an interactive terminal dashboard into Phase 1/2 of RT calibration | Step through Phase 1 prescore batches, watch the calibration curve/DP path converge, inspect the Phase 2 fit and derived tolerances. Does nothing on its own also requires `TIMSSEEK_CALIB_DASHBOARD=1` at runtime (see Env vars). Dev-only; do not ship. | `--features calib-dashboard` |
| `query-instr` | `timscentroid` | Per-peak atomic counters in `IndexedPeakGroup::for_each_peak` | Filter-funnel shape + pass rates. ~10× wall-time inflation funnel counts only, not timing. | `-p timscentroid --features query-instr` |
| `calib-dashboard` | `timsseek_cli` | Pulls in `calib_dash`, wiring an interactive terminal dashboard into Phase 1/2 of RT calibration | Step through Phase 1 prescore batches, watch the calibration curve/DP path converge, inspect the Phase 2 fit and derived tolerances. Does nothing on its own -- also requires `TIMSSEEK_CALIB_DASHBOARD=1` at runtime (see Env vars). Dev-only; do not ship. | `--features calib-dashboard` |
| `query-instr` | `timscentroid` | Per-peak atomic counters in `IndexedPeakGroup::for_each_peak` | Filter-funnel shape + pass rates. ~10× wall-time inflation -- funnel counts only, not timing. | `-p timscentroid --features query-instr` |
| `aws` / `gcp` / `azure` | `timscentroid` | `object_store` cloud backends | Read `.d` / speclib from cloud | `--features aws` (etc.) |

## Env vars
Expand All @@ -44,13 +44,13 @@ Not shown by `--help`. Read directly via `std::env::var` / `var_os`.

`task --list-all` enumerates everything. Non-obvious ones:

- `task test`, `task fmt`, `task clippy` `task fmt` runs nightly rustfmt + ruff. Do not use `cargo fmt` (stable silently drops nightly-only opts).
- `task speclib:build -- <args>` wrapper around `speclib_build`.
- `task speclib:local-koina` / `task speclib:stop-koina` local Koina docker. First run downloads all models (~10-30 min).
- `task docker` cross-builds linux/amd64 images.
- `task test`, `task fmt`, `task clippy` -- `task fmt` runs nightly rustfmt + ruff. Do not use `cargo fmt` (stable silently drops nightly-only opts).
- `task speclib:build -- <args>` -- wrapper around `speclib_build`.
- `task speclib:local-koina` / `task speclib:stop-koina` -- local Koina docker. First run downloads all models (~10-30 min).
- `task docker` -- cross-builds linux/amd64 images.
- `task license_check`, `task todos`, `task bumpver`, `task build_python`.

Per-crate: `rust/timsseek/Taskfile.yml` adds a watch loop (`task timsseek`) rebuild + test + fmt + clippy on source change.
Per-crate: `rust/timsseek/Taskfile.yml` adds a watch loop (`task timsseek`) -- rebuild + test + fmt + clippy on source change.

## S3 staging

Expand Down
8 changes: 4 additions & 4 deletions example_speclib_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# Reference configuration for speclib_build.
# CLI flags override any value set here.
# All fields are optional omit a section or key to use the compiled-in default.
# All fields are optional -- omit a section or key to use the compiled-in default.

# ── Output ────────────────────────────────────────────────────────────────────
# Path for the output spectral library (msgpack + zstd).
Expand Down Expand Up @@ -41,9 +41,9 @@ max = 4
# ── Decoys ────────────────────────────────────────────────────────────────────
[decoys]
# Decoy generation strategy.
# none no decoys (target-only library)
# reverse reverse the amino-acid sequence
# edge_mutate mutate N- and C-terminal residues
# none -- no decoys (target-only library)
# reverse -- reverse the amino-acid sequence
# edge_mutate -- mutate N- and C-terminal residues
strategy = "none"

# ── Prediction ────────────────────────────────────────────────────────────────
Expand Down
42 changes: 21 additions & 21 deletions python/timsquery_pyo3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ result = index.query_chromatogram(eg, tolerance)
result.fragment_intensities # shape (n_fragments, n_cycles), dtype float32
result.precursor_intensities # shape (n_precursors, n_cycles), dtype float32

result.fragment_labels # [(label, mz), ...] row order matches the array
result.fragment_labels # [(label, mz), ...] -- row order matches the array
result.precursor_labels # [(isotope_offset, mz), ...]
result.rt_range_ms # (start_ms, end_ms)
result.num_cycles # number of RT points
Expand All @@ -79,7 +79,7 @@ Avoid repeated allocations by reusing a `ChromatogramResult` across queries.
The internal `Vec<f32>` capacity grows to the largest elution group and stays there.

```python
result = index.query_chromatogram(eg1, tolerance) # first query allocates
result = index.query_chromatogram(eg1, tolerance) # first query -- allocates

index.query_chromatogram_into(result, eg2, tolerance) # reuses allocation
index.query_chromatogram_into(result, eg3, tolerance) # same allocation
Expand All @@ -89,11 +89,11 @@ index.query_chromatogram_into(result, eg3, tolerance) # same allocation

For large-scale workloads, stream elution groups from any Python iterator.
Internally uses chunked rayon parallelism and reuses collector allocations
across chunks after the first chunk, allocations settle and only `memcpy`
across chunks -- after the first chunk, allocations settle and only `memcpy`
into numpy remains.

```python
# Any iterable works generator, list, map, etc.
# Any iterable works -- generator, list, map, etc.
eg_iter = (make_eg(row) for row in dataframe.itertuples())

# Shared tolerance
Expand All @@ -113,7 +113,7 @@ for arrays in index.query_chromatograms_iter(eg_iter, tol_iter, chunk_size=256):
```

`ChromatogramArrays` is a lightweight frozen object that owns its numpy arrays.
The iterator's internal collector pool is never exposed it just keeps reusing
The iterator's internal collector pool is never exposed -- it just keeps reusing
the same Rust-side buffers across chunks.

## Spectral queries
Expand All @@ -122,8 +122,8 @@ the same Rust-side buffers across chunks.

```python
result = index.query_spectrum(eg, tolerance)
result.precursor_intensities # list[float] one total intensity per precursor
result.fragment_intensities # list[float] one total intensity per fragment
result.precursor_intensities # list[float] -- one total intensity per precursor
result.fragment_intensities # list[float] -- one total intensity per fragment
result.precursor_labels # list[(isotope_offset, mz)]
result.fragment_labels # list[(label, mz)]
result.id # int
Expand All @@ -141,9 +141,9 @@ result.id # int
```

Each stats tuple contains:
- `weight` total accumulated intensity
- `mean_mz` intensity-weighted mean m/z (NaN if no peaks found)
- `mean_mobility` intensity-weighted mean ion mobility in 1/K0 (NaN if no peaks found)
- `weight` -- total accumulated intensity
- `mean_mz` -- intensity-weighted mean m/z (NaN if no peaks found)
- `mean_mobility` -- intensity-weighted mean ion mobility in 1/K0 (NaN if no peaks found)

## Tolerance reference

Expand Down Expand Up @@ -172,10 +172,10 @@ tol = tol.with_quad(tq.PyQuadTolerance.absolute(0.2, 0.2))
## Lazy vs eager loading

```python
# Eager (default): loads entire index into memory faster queries
# Eager (default): loads entire index into memory -- faster queries
index = tq.PyTimsIndex("experiment.d")

# Lazy: loads from cached .idx on demand faster startup, lower memory
# Lazy: loads from cached .idx on demand -- faster startup, lower memory
index = tq.PyTimsIndex("experiment.d.idx", prefer_lazy=True)

index.is_lazy # bool
Expand All @@ -186,7 +186,7 @@ index.is_lazy # bool
```python
index.num_cycles # total MS1 cycles in the acquisition
index.rt_range_ms # (start_ms, end_ms)
index.rt_values_ms # list[int] RT in ms for every cycle index
index.rt_values_ms # list[int] -- RT in ms for every cycle index

# Convert between seconds and cycle indices
idx = index.rt_seconds_to_cycle_index(300.0) # nearest cycle index
Expand All @@ -211,18 +211,18 @@ rt_axis = np.array(index.rt_values_ms, dtype=np.float32) / 1000.0 # seconds

## Roadmap

- [x] **Aggregator reuse** `query_chromatogram_into` reuses a `ChromatogramCollector`
- [x] **Aggregator reuse** -- `query_chromatogram_into` reuses a `ChromatogramCollector`
allocation across queries, avoiding repeated allocation.
- [x] **SpectralCollector** `query_spectrum` (summed f32) and `query_mz_mobility`
- [x] **SpectralCollector** -- `query_spectrum` (summed f32) and `query_mz_mobility`
(intensity-weighted mean m/z + mobility) per ion.
- [ ] **PointIntensityAggregator** single scalar total intensity per elution group.
- [ ] **IonAnnot key type** support `IonAnnot` fragment labels alongside `usize`,
- [ ] **PointIntensityAggregator** -- single scalar total intensity per elution group.
- [ ] **IonAnnot key type** -- support `IonAnnot` fragment labels alongside `usize`,
enabling richer annotation round-trips between Python and Rust.
- [ ] **Zero-copy array access** return numpy views backed by Rust-owned memory
- [ ] **Zero-copy array access** -- return numpy views backed by Rust-owned memory
instead of copying, for large-scale workloads.
- [x] **CycleToRTMapping exposure** `rt_seconds_to_cycle_index`, `cycle_index_to_rt_ms`,
- [x] **CycleToRTMapping exposure** -- `rt_seconds_to_cycle_index`, `cycle_index_to_rt_ms`,
`rt_values_ms`, `num_cycles`, `rt_range_ms` on `PyTimsIndex`.
- [ ] **Library file I/O** read DIA-NN / Spectronaut libraries directly into
- [ ] **Library file I/O** -- read DIA-NN / Spectronaut libraries directly into
lists of `PyElutionGroup`, removing boilerplate on the Python side.
- [x] **Streaming queries** `query_chromatograms_iter` streams from any Python
- [x] **Streaming queries** -- `query_chromatograms_iter` streams from any Python
iterator with chunked rayon parallelism and internal collector reuse.
8 changes: 4 additions & 4 deletions python/timsquery_pyo3/examples/streaming_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@

This example demonstrates the three query modes in timsquery_pyo3:

1. Single query one elution group at a time
2. Aggregator reuse reuse allocations across sequential queries
3. Streaming iterator iterator-in, iterator-out with chunked parallelism
1. Single query -- one elution group at a time
2. Aggregator reuse -- reuse allocations across sequential queries
3. Streaming iterator -- iterator-in, iterator-out with chunked parallelism

Usage:
uv run examples/streaming_example.py <path_to_experiment.d>
Expand Down Expand Up @@ -106,7 +106,7 @@ def main():
print(f" loaded in {time.perf_counter() - t0:.2f}s ({index})")

# ------------------------------------------------------------------
# Set up tolerances narrow search window
# Set up tolerances -- narrow search window
# ------------------------------------------------------------------
tolerance = tq.PyTolerance(
mz=tq.PyMzTolerance.ppm(10.0, 10.0),
Expand Down
4 changes: 2 additions & 2 deletions python/timsquery_pyo3/src/chromatogram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ impl PyChromatogramResult {
}

#[getter]
fn id(&self) -> u64 {
self.collector.id
fn id<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
crate::source_id_to_py(py, &self.collector.id)
}

fn __repr__(&self) -> String {
Expand Down
6 changes: 3 additions & 3 deletions python/timsquery_pyo3/src/elution_group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use timsquery::tinyvec::tiny_vec;
/// An elution group defines a query target: one precursor and its fragments.
///
/// NOTE: Fragment labels are `usize` only in this binding. This is a deliberate
/// simplification the Rust side is generic over `T: KeyLike` but we monomorphize
/// simplification -- the Rust side is generic over `T: KeyLike` but we monomorphize
/// to `usize` here for a clean Python interface. Other key types (e.g. `IonAnnot`)
/// may be added in future versions.
#[pyclass(skip_from_py_object)]
Expand Down Expand Up @@ -60,8 +60,8 @@ impl PyElutionGroup {
}

#[getter]
fn id(&self) -> u64 {
self.inner.id()
fn id<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
crate::source_id_to_py(py, &self.inner.id().to_owned_id())
}

#[getter]
Expand Down
12 changes: 8 additions & 4 deletions python/timsquery_pyo3/src/iterator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@ pub enum ToleranceSource {
/// Lightweight result yielded by the streaming iterator.
///
/// Owns materialized numpy arrays and metadata. The iterator's internal
/// collector pool is never exposed it reuses Rust-side buffers across chunks.
/// collector pool is never exposed -- it reuses Rust-side buffers across chunks.
#[pyclass(frozen)]
pub struct PyChromatogramArrays {
#[pyo3(get)]
id: u64,
id: timsquery::models::OwnedSourceId,
precursor_intensities: Py<PyAny>,
fragment_intensities: Py<PyAny>,
#[pyo3(get)]
Expand All @@ -42,6 +41,11 @@ pub struct PyChromatogramArrays {

#[pymethods]
impl PyChromatogramArrays {
#[getter]
fn id<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
crate::source_id_to_py(py, &self.id)
}

#[getter]
fn precursor_intensities<'py>(&self, py: Python<'py>) -> Bound<'py, PyAny> {
self.precursor_intensities.clone_ref(py).into_bound(py)
Expand Down Expand Up @@ -72,7 +76,7 @@ fn extract_arrays(
let rt = collector.rt_range_milis();

Ok(PyChromatogramArrays {
id: collector.id,
id: collector.id.clone(),
precursor_intensities: prec_np.into_any().unbind(),
fragment_intensities: frag_np.into_any().unbind(),
precursor_labels: collector
Expand Down
15 changes: 15 additions & 0 deletions python/timsquery_pyo3/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ mod spectrum;
mod tolerance;

use pyo3::prelude::*;
use timsquery::models::OwnedSourceId;

/// A source id keeps the shape the library gave it, so Python sees an `int`
/// for a numeric id and a `str` for a text one (DIA-NN's
/// `transition_group_id`) rather than one coerced into the other.
pub(crate) fn source_id_to_py<'py>(
py: Python<'py>,
id: &OwnedSourceId,
) -> PyResult<Bound<'py, PyAny>> {
use pyo3::IntoPyObject;
match id {
OwnedSourceId::Numeric(n) => Ok(n.into_pyobject(py)?.into_any()),
OwnedSourceId::Text(s) => Ok(s.into_pyobject(py)?.into_any()),
}
}

#[pymodule]
fn timsquery_pyo3(m: &Bound<'_, PyModule>) -> PyResult<()> {
Expand Down
10 changes: 5 additions & 5 deletions python/timsquery_pyo3/src/spectrum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use timsquery::{
SpectralCollector,
};

/// Result of a spectral query total summed intensity per ion.
/// Result of a spectral query -- total summed intensity per ion.
///
/// Each precursor/fragment gets a single f32 intensity value (summed
/// across all matching peaks within the tolerance window).
Expand Down Expand Up @@ -61,8 +61,8 @@ impl PySpectralResult {

/// The elution group id.
#[getter]
fn id(&self) -> u64 {
self.collector.id
fn id<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
crate::source_id_to_py(py, &self.collector.id)
}

fn __repr__(&self) -> String {
Expand Down Expand Up @@ -153,8 +153,8 @@ impl PyMzMobilityResult {

/// The elution group id.
#[getter]
fn id(&self) -> u64 {
self.collector.id
fn id<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
crate::source_id_to_py(py, &self.collector.id)
}

fn __repr__(&self) -> String {
Expand Down
Loading
Loading