zipstrain 1.2.2__tar.gz → 1.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {zipstrain-1.2.2 → zipstrain-1.2.3}/PKG-INFO +1 -1
  2. {zipstrain-1.2.2 → zipstrain-1.2.3}/pyproject.toml +1 -1
  3. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/cli.py +11 -0
  4. zipstrain-1.2.3/src/zipstrain/matrix_gemm.py +750 -0
  5. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/matrix_pairs.py +52 -0
  6. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/utils.py +72 -21
  7. {zipstrain-1.2.2 → zipstrain-1.2.3}/LICENSE +0 -0
  8. {zipstrain-1.2.2 → zipstrain-1.2.3}/README.md +0 -0
  9. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/__init__.py +0 -0
  10. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/build_db.py +0 -0
  11. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/compare.py +0 -0
  12. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/database.py +0 -0
  13. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/healthcheck.py +0 -0
  14. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/mapping.py +0 -0
  15. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/matrix_workflow.py +0 -0
  16. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/profile.py +0 -0
  17. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/profile_backends.py +0 -0
  18. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/resource_limits.py +0 -0
  19. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/run_logger.py +0 -0
  20. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/task_manager.py +0 -0
  21. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/visualize.py +0 -0
  22. {zipstrain-1.2.2 → zipstrain-1.2.3}/src/zipstrain/zipstrain.code-workspace +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zipstrain
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary:
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "zipstrain"
3
- version = "1.2.2"
3
+ version = "1.2.3"
4
4
  description = ""
5
5
  authors = [
6
6
  {name = "ParsaGhadermazi",email = "54489047+ParsaGhadermazi@users.noreply.github.com"}
@@ -882,6 +882,14 @@ def append_matrix_db(profile_dir, matrix_db_file, memory_limit_gb, export_batch_
882
882
  show_default=True,
883
883
  help="Compute backend. Torch backends use CPU/CUDA/MPS depending on selection.",
884
884
  )
885
+ @click.option(
886
+ '--compare-kernel',
887
+ type=click.Choice(mp.MATRIX_COMPARE_KERNELS),
888
+ default="pairwise",
889
+ show_default=True,
890
+ help="Comparison algorithm. 'gemm' computes genome-level popANI/conANI for whole sample blocks "
891
+ "with matrix multiplications (much faster; requires --calculate ani). 'pairwise' supports all metrics.",
892
+ )
885
893
  def matrix_compare(
886
894
  matrix_db_file,
887
895
  output_file,
@@ -895,6 +903,7 @@ def matrix_compare(
895
903
  writer_executor,
896
904
  calculate,
897
905
  backend,
906
+ compare_kernel,
898
907
  ):
899
908
  """
900
909
  Run resumable matrix compare on all non-redundant, non-self sample pairs.
@@ -955,6 +964,7 @@ def matrix_compare(
955
964
  calculate=calculate,
956
965
  emit_writer_logs=False,
957
966
  progress_callback=_progress_callback,
967
+ compare_kernel=compare_kernel,
958
968
  )
959
969
  else:
960
970
  summary = mp.matrix_compare(
@@ -972,6 +982,7 @@ def matrix_compare(
972
982
  calculate=calculate,
973
983
  emit_writer_logs=True,
974
984
  progress_callback=None,
985
+ compare_kernel=compare_kernel,
975
986
  )
976
987
  click.echo(
977
988
  f"wrote={summary.output_file} "
@@ -0,0 +1,750 @@
1
+ """Blocked-GEMM genome-level popANI / conANI for HDF5 matrix stores.
2
+
3
+ The pairwise kernels in :mod:`zipstrain.matrix_pairs` compare one anchor
4
+ sample against blocks of targets position by position. That is bandwidth
5
+ bound: every anchor/target pair re-reads both rows. For genome-level ANI the
6
+ per-pair quantities are plain sums over positions, so a whole block of
7
+ sample pairs can be computed with dense matrix multiplications instead.
8
+
9
+ Every supported matrix layout is reduced to a 4-bit allele mask per position
10
+ (bit ``b`` set when allele ``b`` is present after the build-time coverage
11
+ filter; for conANI, when allele ``b`` is a consensus allele). With ``X`` the
12
+ ``samples x positions`` mask matrix and ``C = (X != 0)``:
13
+
14
+ ``total_positions = C @ C.T``
15
+ positions covered in both samples.
16
+
17
+ ``share_allele_pos = total_positions - mismatch``
18
+ a covered-in-both position is a mismatch when the two masks share no bit.
19
+ Mismatches can only happen at *informative* positions, where the covered
20
+ samples of the whole cohort do not share a common allele. On those
21
+ positions ``shared = sum_b A_b @ A_b.T`` corrected by inclusion-exclusion
22
+ over multi-allele masks (``A_m = (X & m) == m``), and
23
+ ``mismatch = C_I @ C_I.T - shared_I``.
24
+
25
+ All GEMMs run in float32, which is exact for integer sums below ``2**24``;
26
+ partial sums are flushed into int64 host accumulators before that bound
27
+ can be reached. Results are bit-identical to the pairwise kernel.
28
+
29
+ Only genome-level ANI is produced; IBS (``max_consecutive_length``) and
30
+ gene-level ANI depend on position order or per-gene masks and must use the
31
+ pairwise kernel.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import itertools
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Callable, Optional
40
+
41
+ import duckdb
42
+ import numpy as np
43
+ import pyarrow as pa
44
+
45
+ from zipstrain import matrix_pairs as mp
46
+
47
+ GEMM_SUPPORTED_ANI_KINDS = ("popani", "conani")
48
+ #: float32 represents every integer up to 2**24 exactly.
49
+ GEMM_EXACT_FP32_LIMIT = 1 << 24
50
+ #: Default number of positions multiplied per GEMM call.
51
+ GEMM_POSITION_CHUNK_DEFAULT = 32_768
52
+ #: Largest sample block considered by the planner. Diagonal block pairs are
53
+ #: computed in full, so larger blocks add ~n*B/2 redundant pair counts.
54
+ GEMM_MAX_BLOCK_SAMPLES = 4_096
55
+ #: Bound raw count reads and their mask-conversion temporaries independently
56
+ #: of the size of the final, one-byte-per-position mask cache.
57
+ GEMM_READ_BUFFER_BYTES_DEFAULT = 128 * 1024**2
58
+
59
+ _ALLELE_BITS = tuple(int(bit) for bit in mp.BITMASK_BASE_BITS)
60
+ # Inclusion-exclusion terms for |{b : b in mask_i and b in mask_j}| > 0:
61
+ # + singles - pairs + triples - quad. Each entry is (sign, combined mask).
62
+ _COMBO_TERMS = tuple(
63
+ (sign, int(sum(combo)))
64
+ for size, sign in ((2, -1.0), (3, 1.0), (4, -1.0))
65
+ for combo in itertools.combinations(_ALLELE_BITS, size)
66
+ )
67
+ _POPCOUNT = np.array([bin(value).count("1") for value in range(256)], dtype=np.uint8)
68
+ _FULL_MASK = np.uint8(sum(_ALLELE_BITS))
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class GemmPlan:
73
+ """Sample block size and position chunk used by the GEMM kernel."""
74
+
75
+ block_samples: int
76
+ position_chunk: int
77
+ cache_all_rows: bool
78
+
79
+
80
+ def rows_to_allele_bitmask(
81
+ rows: np.ndarray,
82
+ *,
83
+ matrix_value_semantics: str,
84
+ ani_kind: str,
85
+ ) -> np.ndarray:
86
+ """Convert a batch of stored matrix rows to ``(B, L)`` uint8 allele masks.
87
+
88
+ ``rows`` is what ``_Hdf5GenomeMatrixNumpyDataset._load_batch_numpy``
89
+ returns: ``(B, L)`` packed bitmasks, or ``(B, L, 4)`` counts / presence.
90
+ """
91
+ if matrix_value_semantics == mp.BITMASK_MATRIX_VALUE_SEMANTICS:
92
+ if ani_kind != "popani":
93
+ raise ValueError("Bitmask matrix stores support only popANI.")
94
+ return np.ascontiguousarray(rows, dtype=np.uint8)
95
+ if rows.ndim != 3 or rows.shape[-1] != len(_ALLELE_BITS):
96
+ raise ValueError(f"Expected (samples, positions, 4) matrix rows, got shape {rows.shape}.")
97
+ if ani_kind == "conani":
98
+ if matrix_value_semantics != mp.COUNT_MATRIX_VALUE_SEMANTICS:
99
+ raise ValueError("conANI requires a count matrix store.")
100
+ maxima = rows.max(axis=2, keepdims=True)
101
+ selected = (rows == maxima) & (maxima > 0)
102
+ elif ani_kind == "popani":
103
+ if matrix_value_semantics not in (
104
+ mp.COUNT_MATRIX_VALUE_SEMANTICS,
105
+ mp.FILTERED_PRESENCE_MATRIX_VALUE_SEMANTICS,
106
+ ):
107
+ raise ValueError(f"Unsupported matrix value semantics {matrix_value_semantics!r}.")
108
+ selected = rows > 0
109
+ else:
110
+ raise ValueError(f"compare_kernel='gemm' does not support ani_method {ani_kind!r}.")
111
+ out = np.zeros(rows.shape[:2], dtype=np.uint8)
112
+ for channel, bit in enumerate(_ALLELE_BITS):
113
+ out |= selected[:, :, channel].astype(np.uint8) * np.uint8(bit)
114
+ return out
115
+
116
+
117
+ def _plan_gemm_blocks(
118
+ *,
119
+ sample_count: int,
120
+ matrix_length: int,
121
+ memory_limit_bytes: int,
122
+ device_memory_bytes: Optional[int] = None,
123
+ informative_count: Optional[int] = None,
124
+ ) -> GemmPlan:
125
+ """Pick the largest sample block that fits the memory budget.
126
+
127
+ The whole genome matrix is cached on the host when it fits in a third of
128
+ the budget; row blocks are then zero-copy views. Otherwise two uint8 row
129
+ blocks are resident. Each block also keeps a copy of its informative
130
+ columns. Compute uses ``B x W`` float32 tiles (uploaded per position
131
+ chunk for torch backends) and three ``B x B`` accumulators, plus int64
132
+ host copies of the accumulators.
133
+ """
134
+ length = int(matrix_length)
135
+ width = max(1, min(length, GEMM_POSITION_CHUNK_DEFAULT))
136
+ informative = length if informative_count is None else int(informative_count)
137
+ budget = int(memory_limit_bytes * 0.7)
138
+ full_matrix = int(sample_count) * length
139
+ cache_all = full_matrix <= memory_limit_bytes // 3
140
+ if cache_all:
141
+ budget -= full_matrix
142
+ device_budget = None if device_memory_bytes is None else int(device_memory_bytes * 0.7)
143
+
144
+ def host_bytes(block: int) -> int:
145
+ rows = 0 if cache_all else 2 * block * length
146
+ return rows + 2 * block * informative + 12 * block * width * 4 + 3 * block * block * 12
147
+
148
+ def device_bytes(block: int) -> int:
149
+ return 12 * block * width * 4 + 3 * block * block * 4
150
+
151
+ block = max(1, min(int(sample_count), GEMM_MAX_BLOCK_SAMPLES))
152
+ while block > 1 and (
153
+ host_bytes(block) > budget
154
+ or (device_budget is not None and device_bytes(block) > device_budget)
155
+ ):
156
+ block = max(1, block // 2) if block > 64 else block - 1
157
+ return GemmPlan(block_samples=block, position_chunk=width, cache_all_rows=cache_all)
158
+
159
+
160
+ class _GemmOps:
161
+ """Tiny numpy/torch abstraction for the handful of tensor ops we need."""
162
+
163
+ def __init__(self, compute_backend) -> None:
164
+ self.kind = compute_backend.kind
165
+ self.torch = compute_backend.torch
166
+ self.device = compute_backend.device
167
+ self._saved_precision = None
168
+ self._saved_tf32 = None
169
+ self._product = None
170
+
171
+ def __enter__(self) -> "_GemmOps":
172
+ if self.kind == "torch":
173
+ torch = self.torch
174
+ # TF32 / reduced precision would break exactness.
175
+ self._saved_precision = torch.get_float32_matmul_precision()
176
+ torch.set_float32_matmul_precision("highest")
177
+ if hasattr(torch.backends, "cuda"):
178
+ self._saved_tf32 = torch.backends.cuda.matmul.allow_tf32
179
+ torch.backends.cuda.matmul.allow_tf32 = False
180
+ return self
181
+
182
+ def __exit__(self, *exc) -> None:
183
+ if self.kind == "torch":
184
+ self.torch.set_float32_matmul_precision(self._saved_precision)
185
+ if self._saved_tf32 is not None:
186
+ self.torch.backends.cuda.matmul.allow_tf32 = self._saved_tf32
187
+
188
+ def tile(self, host_block: np.ndarray, start: int, stop: int):
189
+ """Columns ``start:stop`` of a host uint8 block, on the compute device.
190
+
191
+ Torch backends upload one contiguous ``B x W`` tile at a time, so the
192
+ device never holds whole-genome row blocks.
193
+ """
194
+ view = host_block[:, start:stop]
195
+ if self.kind == "numpy":
196
+ return view
197
+ return self.torch.from_numpy(np.ascontiguousarray(view)).to(self.device)
198
+
199
+ def zeros(self, rows: int, cols: int):
200
+ if self.kind == "numpy":
201
+ return np.zeros((rows, cols), dtype=np.float32)
202
+ return self.torch.zeros((rows, cols), dtype=self.torch.float32, device=self.device)
203
+
204
+ def covered(self, tile):
205
+ if self.kind == "numpy":
206
+ return (tile != 0).astype(np.float32)
207
+ return (tile != 0).to(self.torch.float32)
208
+
209
+ def has_bit(self, tile, bit: int):
210
+ if self.kind == "numpy":
211
+ return ((tile & np.uint8(bit)) != 0).astype(np.float32)
212
+ return ((tile & bit) != 0).to(self.torch.float32)
213
+
214
+ def has_all_bits(self, tile, bits: int):
215
+ if self.kind == "numpy":
216
+ return ((tile & np.uint8(bits)) == np.uint8(bits)).astype(np.float32)
217
+ return ((tile & bits) == bits).to(self.torch.float32)
218
+
219
+ def addmm_(self, acc, left, right, alpha: float = 1.0) -> None:
220
+ """``acc += alpha * left @ right.T`` in float32."""
221
+ if self.kind == "numpy":
222
+ if self._product is None or self._product.shape != acc.shape:
223
+ self._product = np.empty(acc.shape, dtype=np.float32)
224
+ np.matmul(left, right.T, out=self._product)
225
+ if alpha == 1.0:
226
+ acc += self._product
227
+ else:
228
+ acc -= self._product if alpha == -1.0 else -np.float32(alpha) * self._product
229
+ return
230
+ acc.addmm_(left, right.T, alpha=alpha)
231
+
232
+ def to_int64(self, acc) -> np.ndarray:
233
+ if self.kind == "numpy":
234
+ return np.rint(acc).astype(np.int64)
235
+ return self.torch.round(acc).to(self.torch.int64).cpu().numpy()
236
+
237
+ def device_memory_bytes(self) -> Optional[int]:
238
+ if self.kind == "torch" and str(self.device).startswith("cuda"):
239
+ free, _total = self.torch.cuda.mem_get_info(self.torch.device(self.device))
240
+ return int(free)
241
+ return None
242
+
243
+
244
+ class _ExactAccumulator:
245
+ """float32 device accumulator with periodic exact flushes to int64."""
246
+
247
+ def __init__(self, ops: _GemmOps, rows: int, cols: int, magnitude_per_position: int) -> None:
248
+ self.ops = ops
249
+ self.rows = rows
250
+ self.cols = cols
251
+ self.magnitude = int(magnitude_per_position)
252
+ self.acc = ops.zeros(rows, cols)
253
+ self.host = np.zeros((rows, cols), dtype=np.int64)
254
+ self.pending_positions = 0
255
+
256
+ def reserve(self, positions: int) -> None:
257
+ """Flush first if adding ``positions`` could exceed exact float32 range."""
258
+ if (self.pending_positions + positions) * self.magnitude >= GEMM_EXACT_FP32_LIMIT:
259
+ self.flush()
260
+ self.pending_positions += positions
261
+
262
+ def flush(self) -> None:
263
+ if self.pending_positions == 0:
264
+ return
265
+ self.host += self.ops.to_int64(self.acc)
266
+ self.acc = self.ops.zeros(self.rows, self.cols)
267
+ self.pending_positions = 0
268
+
269
+ def result(self) -> np.ndarray:
270
+ self.flush()
271
+ return self.host
272
+
273
+
274
+ def _block_pair_counts(
275
+ ops: _GemmOps,
276
+ *,
277
+ block_i,
278
+ block_j,
279
+ inf_i,
280
+ inf_j,
281
+ multi_positions: np.ndarray,
282
+ matrix_length: int,
283
+ position_chunk: int,
284
+ ) -> tuple[np.ndarray, np.ndarray]:
285
+ """Return int64 ``(total_positions, share_allele_pos)`` for two row blocks.
286
+
287
+ All blocks are host uint8 arrays. ``inf_i`` / ``inf_j`` are the
288
+ informative-position columns of the blocks; ``multi_positions`` indexes
289
+ the informative columns that carry at least one multi-allele mask anywhere
290
+ in the cohort.
291
+ """
292
+ rows_i = int(block_i.shape[0])
293
+ rows_j = int(block_j.shape[0])
294
+ width = max(1, min(int(position_chunk), GEMM_EXACT_FP32_LIMIT // 8))
295
+ # Diagonal block pairs multiply a block with itself: convert each tile once.
296
+ same = block_i is block_j
297
+
298
+ def tiles(host_i, host_j, start, stop):
299
+ tile_i = ops.tile(host_i, start, stop)
300
+ return tile_i, (tile_i if same else ops.tile(host_j, start, stop))
301
+
302
+ def pair(convert, tile_i, tile_j, *args):
303
+ left = convert(tile_i, *args)
304
+ return left, (left if same else convert(tile_j, *args))
305
+
306
+ total = _ExactAccumulator(ops, rows_i, rows_j, magnitude_per_position=1)
307
+ for start in range(0, matrix_length, width):
308
+ stop = min(start + width, matrix_length)
309
+ total.reserve(stop - start)
310
+ ops.addmm_(total.acc, *pair(ops.covered, *tiles(block_i, block_j, start, stop)))
311
+ totals = total.result()
312
+
313
+ informative = int(inf_i.shape[1])
314
+ if informative == 0:
315
+ return totals, totals.copy()
316
+ # mismatch = covered_both_I - shared_I; inclusion-exclusion partial sums
317
+ # are bounded by 4 per position (4 single-bit terms of magnitude 1).
318
+ mismatch = _ExactAccumulator(ops, rows_i, rows_j, magnitude_per_position=5)
319
+ for start in range(0, informative, width):
320
+ stop = min(start + width, informative)
321
+ tile_i, tile_j = tiles(inf_i, inf_j, start, stop)
322
+ mismatch.reserve(stop - start)
323
+ ops.addmm_(mismatch.acc, *pair(ops.covered, tile_i, tile_j))
324
+ for bit in _ALLELE_BITS:
325
+ ops.addmm_(mismatch.acc, *pair(ops.has_bit, tile_i, tile_j, bit), alpha=-1.0)
326
+ if multi_positions.size:
327
+ multi_i = np.ascontiguousarray(inf_i[:, multi_positions])
328
+ multi_j = multi_i if same else np.ascontiguousarray(inf_j[:, multi_positions])
329
+ for start in range(0, int(multi_positions.size), width):
330
+ stop = min(start + width, int(multi_positions.size))
331
+ tile_i, tile_j = tiles(multi_i, multi_j, start, stop)
332
+ # 11 combo terms of magnitude <= 1 each per position.
333
+ mismatch.reserve((stop - start) * 3)
334
+ for sign, bits in _COMBO_TERMS:
335
+ # shared gets ``sign`` -> mismatch gets ``-sign``.
336
+ ops.addmm_(mismatch.acc, *pair(ops.has_all_bits, tile_i, tile_j, bits), alpha=-sign)
337
+ mismatches = mismatch.result()
338
+ return totals, totals - mismatches
339
+
340
+
341
+ class _GenomeRows:
342
+ """Loads (and optionally caches) allele-mask row blocks for one genome."""
343
+
344
+ def __init__(
345
+ self,
346
+ *,
347
+ matrix_db_file: Path,
348
+ spec,
349
+ dtype_name: str,
350
+ matrix_value_semantics: str,
351
+ ani_kind: str,
352
+ sample_count: int,
353
+ cache_all_rows: bool,
354
+ read_memory_limit_bytes: int = GEMM_READ_BUFFER_BYTES_DEFAULT,
355
+ ) -> None:
356
+ self.dataset = mp._Hdf5GenomeMatrixNumpyDataset(
357
+ matrix_hdf5_file=matrix_db_file,
358
+ genome_idx=spec.genome_idx,
359
+ matrix_length=spec.matrix_length,
360
+ dtype_name=dtype_name,
361
+ matrix_value_semantics=matrix_value_semantics,
362
+ )
363
+ self.semantics = matrix_value_semantics
364
+ self.ani_kind = ani_kind
365
+ self.sample_count = sample_count
366
+ self.matrix_length = int(spec.matrix_length)
367
+ self.read_memory_limit_bytes = int(read_memory_limit_bytes)
368
+ if self.read_memory_limit_bytes < 1:
369
+ raise ValueError("read_memory_limit_bytes must be >= 1")
370
+ self._cache: Optional[np.ndarray] = None
371
+ if cache_all_rows:
372
+ try:
373
+ self._cache = self._read(0, sample_count)
374
+ except BaseException:
375
+ self.dataset.close()
376
+ raise
377
+
378
+ def _read(self, start: int, stop: int) -> np.ndarray:
379
+ dataset = self.dataset
380
+ dataset._ensure_open()
381
+ if self.semantics == mp.BITMASK_MATRIX_VALUE_SEMANTICS:
382
+ if dataset._matrix_dataset is not None:
383
+ return np.asarray(dataset._matrix_dataset[start:stop, ...], dtype=np.uint8)
384
+ return dataset._load_batch_numpy(np.arange(start, stop, dtype=np.int64))
385
+
386
+ # The planner budgets for packed masks. Reading all count rows before
387
+ # converting them would temporarily require 8--32 times as much memory.
388
+ # Include raw counts, consensus maxima, selection masks and conversion
389
+ # temporaries in a separate bounded workspace, including uncached reads.
390
+ itemsize = np.dtype(dataset.numpy_dtype).itemsize
391
+ bytes_per_position = 5 * itemsize + 16
392
+ if dataset._matrix_dataset is None:
393
+ # Sparse expansion also reads up to four int64 indices and four
394
+ # values per position alongside the dense count buffer.
395
+ bytes_per_position = max(bytes_per_position, 8 * itemsize + 32)
396
+ workspace_bytes = min(self.read_memory_limit_bytes, GEMM_READ_BUFFER_BYTES_DEFAULT)
397
+ positions_per_read = workspace_bytes // bytes_per_position
398
+ if positions_per_read < 1:
399
+ raise MemoryError("GEMM memory budget cannot fit one allele-count position.")
400
+ if dataset._matrix_dataset is None and positions_per_read < self.matrix_length:
401
+ if self.matrix_length * bytes_per_position > self.read_memory_limit_bytes:
402
+ raise MemoryError(
403
+ "GEMM memory budget cannot fit one expanded sparse count row plus "
404
+ "mask-conversion buffers. Increase --memory-limit-gb."
405
+ )
406
+ # Sparse rows expand as a whole. Permit one row above the preferred
407
+ # 128 MiB chunk size when it still fits the reserved workspace.
408
+ positions_per_read = self.matrix_length
409
+ batch_samples = max(1, positions_per_read // max(1, self.matrix_length))
410
+ batch_positions = max(1, min(self.matrix_length, positions_per_read))
411
+ masks = np.empty((stop - start, self.matrix_length), dtype=np.uint8)
412
+ for row_start in range(start, stop, batch_samples):
413
+ row_stop = min(row_start + batch_samples, stop)
414
+ for col_start in range(0, self.matrix_length, batch_positions):
415
+ col_stop = min(col_start + batch_positions, self.matrix_length)
416
+ if dataset._matrix_dataset is not None:
417
+ raw = np.asarray(
418
+ dataset._matrix_dataset[row_start:row_stop, col_start:col_stop, :],
419
+ dtype=dataset.numpy_dtype,
420
+ )
421
+ else:
422
+ raw = dataset._load_batch_numpy(np.arange(row_start, row_stop, dtype=np.int64))
423
+ masks[row_start - start:row_stop - start, col_start:col_stop] = rows_to_allele_bitmask(
424
+ raw, matrix_value_semantics=self.semantics, ani_kind=self.ani_kind,
425
+ )
426
+ del raw
427
+ return masks
428
+
429
+ def block(self, start: int, stop: int) -> np.ndarray:
430
+ if self._cache is not None:
431
+ return self._cache[start:stop]
432
+ return self._read(start, stop)
433
+
434
+ def close(self) -> None:
435
+ self._cache = None
436
+ self.dataset.close()
437
+
438
+
439
+ def _informative_positions(rows: _GenomeRows, block_samples: int) -> tuple[np.ndarray, np.ndarray]:
440
+ """Cohort-wide informative positions and, among them, multi-allele positions.
441
+
442
+ A position is informative when at least two samples cover it and the
443
+ covered masks share no common allele bit. Non-informative positions can
444
+ never be mismatches for any pair, so restricting the mismatch GEMMs to the
445
+ informative set is exact.
446
+ """
447
+ length = rows.matrix_length
448
+ common = np.full(length, _FULL_MASK, dtype=np.uint8)
449
+ covered_count = np.zeros(length, dtype=np.int32)
450
+ any_multi = np.zeros(length, dtype=bool)
451
+ for start in range(0, rows.sample_count, block_samples):
452
+ block = rows.block(start, min(start + block_samples, rows.sample_count))
453
+ # Column chunks keep the boolean/uint8 temporaries to ~256 MB.
454
+ width = max(1, (1 << 28) // max(1, block.shape[0]))
455
+ for col in range(0, length, width):
456
+ tile = block[:, col:col + width]
457
+ covered = tile != 0
458
+ common[col:col + width] &= np.bitwise_and.reduce(np.where(covered, tile, _FULL_MASK), axis=0)
459
+ covered_count[col:col + width] += covered.sum(axis=0, dtype=np.int32)
460
+ any_multi[col:col + width] |= (_POPCOUNT[tile] >= 2).any(axis=0)
461
+ informative = np.flatnonzero((common == 0) & (covered_count >= 2))
462
+ multi = np.flatnonzero(any_multi[informative])
463
+ return informative.astype(np.int64), multi.astype(np.int64)
464
+
465
+
466
+ def _completed_by_block_pair(
467
+ completed_pairs: set[tuple[int, int]],
468
+ row_of_sample: dict[int, int],
469
+ block_samples: int,
470
+ ) -> dict[tuple[int, int], list[tuple[int, int]]]:
471
+ buckets: dict[tuple[int, int], list[tuple[int, int]]] = {}
472
+ for sample_a, sample_b in completed_pairs:
473
+ row_a = row_of_sample.get(int(sample_a))
474
+ row_b = row_of_sample.get(int(sample_b))
475
+ if row_a is None or row_b is None or row_a == row_b:
476
+ continue
477
+ if row_a > row_b:
478
+ row_a, row_b = row_b, row_a
479
+ key = (row_a // block_samples, row_b // block_samples)
480
+ buckets.setdefault(key, []).append((row_a, row_b))
481
+ return buckets
482
+
483
+
484
+ def _insert_completed_arrays(
485
+ compare_conn: duckdb.DuckDBPyConnection,
486
+ sample_idx_1: np.ndarray,
487
+ sample_idx_2: np.ndarray,
488
+ genome_idx: int,
489
+ ) -> None:
490
+ if sample_idx_1.size == 0:
491
+ return
492
+ table = pa.table(
493
+ {
494
+ "sample_idx_1": pa.array(sample_idx_1, type=pa.int64()),
495
+ "sample_idx_2": pa.array(sample_idx_2, type=pa.int64()),
496
+ "genome_idx": pa.array(np.full(sample_idx_1.size, genome_idx, dtype=np.int64), type=pa.int64()),
497
+ }
498
+ )
499
+ compare_conn.register("_matrix_compare_completed_pair_genome_batch", table)
500
+ try:
501
+ compare_conn.execute(
502
+ """
503
+ INSERT OR REPLACE INTO matrix_compare_completed_pair_genomes
504
+ SELECT DISTINCT sample_idx_1, sample_idx_2, genome_idx
505
+ FROM _matrix_compare_completed_pair_genome_batch
506
+ """
507
+ )
508
+ finally:
509
+ compare_conn.unregister("_matrix_compare_completed_pair_genome_batch")
510
+
511
+
512
+ def _result_table(
513
+ *,
514
+ compare_id_1: np.ndarray,
515
+ compare_id_2: np.ndarray,
516
+ names_1: np.ndarray,
517
+ names_2: np.ndarray,
518
+ genome_id: int,
519
+ genome: str,
520
+ totals: np.ndarray,
521
+ shared: np.ndarray,
522
+ ) -> pa.Table:
523
+ """Arrow rows in ``matrix_compare_result_db_schema`` order.
524
+
525
+ Pairs are canonicalized like the pairwise kernel: ``sample_idx_1`` is the
526
+ smaller compare id and ``sample_1`` its name.
527
+ """
528
+ swap = compare_id_1 > compare_id_2
529
+ first_id = np.where(swap, compare_id_2, compare_id_1)
530
+ second_id = np.where(swap, compare_id_1, compare_id_2)
531
+ first_name = np.where(swap, names_2, names_1)
532
+ second_name = np.where(swap, names_1, names_2)
533
+ rows = int(totals.size)
534
+ return pa.Table.from_arrays(
535
+ [
536
+ pa.array(first_id, type=pa.int64()),
537
+ pa.array(second_id, type=pa.int64()),
538
+ pa.array(first_name.tolist(), type=pa.string()),
539
+ pa.array(second_name.tolist(), type=pa.string()),
540
+ pa.array(np.full(rows, genome_id, dtype=np.int64), type=pa.int64()),
541
+ pa.array([genome] * rows, type=pa.string()),
542
+ pa.array(totals, type=pa.int64()),
543
+ pa.array(shared, type=pa.int64()),
544
+ pa.array(shared / totals * 100.0, type=pa.float64()),
545
+ pa.nulls(rows, type=pa.int64()),
546
+ ],
547
+ schema=mp.matrix_compare_result_db_schema(),
548
+ )
549
+
550
+
551
+ def matrix_compare_gemm(
552
+ *,
553
+ matrix_db_file: Path,
554
+ output_file: Path,
555
+ compare_conn: duckdb.DuckDBPyConnection,
556
+ compare_ids,
557
+ samples: list[tuple[int, str]],
558
+ genomes: list,
559
+ genome_scaffolds: list,
560
+ completed_pairs_by_genome: dict[int, set[tuple[int, int]]],
561
+ requested_pairs: int,
562
+ total_work: int,
563
+ metadata: dict[str, str],
564
+ matrix_value_semantics: str,
565
+ ani_method: str,
566
+ compute_backend,
567
+ memory_limit_bytes: int,
568
+ memory_limit_gb: float,
569
+ min_cov: int,
570
+ genome_scope: Optional[str],
571
+ emit_writer_logs: bool,
572
+ run_start_time: float,
573
+ progress_callback: Optional[Callable[[dict[str, object]], None]],
574
+ ):
575
+ """Run the GEMM kernel on a prepared compare DB; see module docstring.
576
+
577
+ Called by :func:`zipstrain.matrix_pairs.matrix_compare` after the compare
578
+ DB, id catalog and resume state are prepared. Each sample-block pair is
579
+ written in one transaction (result rows with ``total_positions > 0`` plus
580
+ completion keys for every pair in the block pair), so interrupted runs
581
+ resume exactly like the pairwise kernel.
582
+ """
583
+ ani_kind, _cos = mp.parse_matrix_ani_method(ani_method)
584
+ if ani_kind not in GEMM_SUPPORTED_ANI_KINDS:
585
+ raise ValueError(
586
+ f"compare_kernel='gemm' supports ani_method popani or conani, not {ani_method!r}."
587
+ )
588
+ dtype_name = mp._matrix_dtype_name_from_metadata(metadata)
589
+ sample_count = len(samples)
590
+ sample_matrix_ids = np.array([int(sample_idx) for sample_idx, _name in samples], dtype=np.int64)
591
+ sample_names = np.array([str(name) for _idx, name in samples], dtype=object)
592
+ compare_sample_ids = np.asarray(compare_ids.samples, dtype=np.int64)[sample_matrix_ids]
593
+ row_of_sample = {int(sample_idx): row for row, sample_idx in enumerate(sample_matrix_ids.tolist())}
594
+
595
+ completed_work = 0
596
+ written_rows = 0
597
+ block_pairs_done = 0
598
+ sample_blocks_total = 0
599
+
600
+ with _GemmOps(compute_backend) as ops:
601
+ device_memory = ops.device_memory_bytes()
602
+ for spec in genomes:
603
+ completed = completed_pairs_by_genome.get(spec.genome_idx, set())
604
+ if len(completed) >= sample_count * (sample_count - 1) // 2:
605
+ continue
606
+ # Conservative plan (informative count unknown) for the cache decision
607
+ # and the informative-position pass, then the final plan.
608
+ plan = _plan_gemm_blocks(
609
+ sample_count=sample_count,
610
+ matrix_length=spec.matrix_length,
611
+ memory_limit_bytes=memory_limit_bytes,
612
+ device_memory_bytes=device_memory,
613
+ )
614
+ compare_genome_id = int(compare_ids.genomes[spec.genome_idx])
615
+ rows = _GenomeRows(
616
+ matrix_db_file=matrix_db_file,
617
+ spec=spec,
618
+ dtype_name=dtype_name,
619
+ matrix_value_semantics=matrix_value_semantics,
620
+ ani_kind=ani_kind,
621
+ sample_count=sample_count,
622
+ cache_all_rows=plan.cache_all_rows,
623
+ # The planner leaves 30% headroom; use at most 20% for raw
624
+ # count reads and conversion, normally in <=128 MiB chunks.
625
+ read_memory_limit_bytes=max(1, memory_limit_bytes // 5),
626
+ )
627
+ try:
628
+ informative, multi = _informative_positions(rows, plan.block_samples)
629
+ plan = _plan_gemm_blocks(
630
+ sample_count=sample_count,
631
+ matrix_length=spec.matrix_length,
632
+ memory_limit_bytes=memory_limit_bytes,
633
+ device_memory_bytes=device_memory,
634
+ informative_count=int(informative.size),
635
+ )
636
+ block = plan.block_samples
637
+ starts = list(range(0, sample_count, block))
638
+ sample_blocks_total += len(starts)
639
+ done_buckets = _completed_by_block_pair(completed, row_of_sample, block)
640
+ for bi, start_i in enumerate(starts):
641
+ stop_i = min(start_i + block, sample_count)
642
+ host_i = rows.block(start_i, stop_i)
643
+ inf_i = np.ascontiguousarray(host_i[:, informative])
644
+ for bj in range(bi, len(starts)):
645
+ start_j = starts[bj]
646
+ stop_j = min(start_j + block, sample_count)
647
+ pending = np.ones((stop_i - start_i, stop_j - start_j), dtype=bool)
648
+ if bi == bj:
649
+ pending = np.triu(pending, k=1)
650
+ for row_a, row_b in done_buckets.get((bi, bj), ()):
651
+ pending[row_a - start_i, row_b - start_j] = False
652
+ if not pending.any():
653
+ continue
654
+ if bi == bj:
655
+ host_j, inf_j = host_i, inf_i
656
+ else:
657
+ host_j = rows.block(start_j, stop_j)
658
+ inf_j = np.ascontiguousarray(host_j[:, informative])
659
+ totals, shared = _block_pair_counts(
660
+ ops,
661
+ block_i=host_i,
662
+ block_j=host_j,
663
+ inf_i=inf_i,
664
+ inf_j=inf_j,
665
+ multi_positions=multi,
666
+ matrix_length=int(spec.matrix_length),
667
+ position_chunk=plan.position_chunk,
668
+ )
669
+ local_i, local_j = np.nonzero(pending)
670
+ global_i = local_i + start_i
671
+ global_j = local_j + start_j
672
+ pair_totals = totals[local_i, local_j]
673
+ pair_shared = shared[local_i, local_j]
674
+ ids_i = compare_sample_ids[global_i]
675
+ ids_j = compare_sample_ids[global_j]
676
+ keep = pair_totals > 0
677
+ table = _result_table(
678
+ compare_id_1=ids_i[keep],
679
+ compare_id_2=ids_j[keep],
680
+ names_1=sample_names[global_i[keep]],
681
+ names_2=sample_names[global_j[keep]],
682
+ genome_id=compare_genome_id,
683
+ genome=spec.genome,
684
+ totals=pair_totals[keep],
685
+ shared=pair_shared[keep],
686
+ )
687
+ compare_conn.commit()
688
+ compare_conn.execute("BEGIN")
689
+ try:
690
+ mp._insert_matrix_compare_result_table(compare_conn, table)
691
+ _insert_completed_arrays(
692
+ compare_conn,
693
+ np.minimum(ids_i, ids_j),
694
+ np.maximum(ids_i, ids_j),
695
+ compare_genome_id,
696
+ )
697
+ compare_conn.execute("COMMIT")
698
+ except Exception:
699
+ compare_conn.execute("ROLLBACK")
700
+ raise
701
+ batch_pairs = int(local_i.size)
702
+ completed_work += batch_pairs
703
+ written_rows += table.num_rows
704
+ block_pairs_done += 1
705
+ anchor_name = str(sample_names[start_i])
706
+ if emit_writer_logs:
707
+ mp._emit_matrix_compare_writer_log(
708
+ start_time=run_start_time,
709
+ completed=completed_work,
710
+ total=total_work,
711
+ batch_pairs=batch_pairs,
712
+ batch_rows=table.num_rows,
713
+ anchor_name=anchor_name,
714
+ genome=spec.genome,
715
+ targets_completed=stop_j,
716
+ targets_total=sample_count,
717
+ target_chunks=block_pairs_done,
718
+ )
719
+ if progress_callback is not None:
720
+ progress_callback(
721
+ {
722
+ "phase": "advance",
723
+ "completed": completed_work,
724
+ "total": total_work,
725
+ "anchor_name": anchor_name,
726
+ "genome": spec.genome,
727
+ "scaffold": "",
728
+ "targets_completed": stop_j,
729
+ "targets_total": sample_count,
730
+ "target_chunks": block_pairs_done,
731
+ }
732
+ )
733
+ finally:
734
+ rows.close()
735
+
736
+ return mp.MatrixCompareSummary(
737
+ output_file=output_file,
738
+ requested_pairs=requested_pairs,
739
+ written_rows=written_rows,
740
+ scaffold_count=len(genome_scaffolds),
741
+ genome_count=len(genomes),
742
+ anchor_groups=sample_blocks_total,
743
+ target_chunks=block_pairs_done,
744
+ min_cov=min_cov,
745
+ genome_scope=genome_scope or metadata.get("genome_scope", "all"),
746
+ backend=compute_backend.kind,
747
+ device=compute_backend.device,
748
+ memory_limit_gb=memory_limit_gb,
749
+ ani_method=ani_method,
750
+ )
@@ -52,6 +52,7 @@ CURRENT_MATRIX_HDF5_BITMASK_SPARSE_LAYOUT = "per_genome_sample_major_sparse_bitm
52
52
  MATRIX_HDF5_FILE_VERSION = "1"
53
53
  MATRIX_PAIR_BACKENDS = ("numpy", "torch", "torch-cpu", "torch-cuda", "torch-mps")
54
54
  MATRIX_IO_EXECUTOR_KINDS = ("thread", "process")
55
+ MATRIX_COMPARE_KERNELS = ("pairwise", "gemm")
55
56
  MATRIX_COMPARE_PIPELINE_DEPTH = 2
56
57
  MATRIX_COMPARE_WRITE_MAX_ROWS = 250_000
57
58
  MATRIX_COMPARE_WRITE_MAX_BYTES = 32 * 1024 * 1024
@@ -5771,12 +5772,22 @@ def matrix_compare(
5771
5772
  calculate: Optional[str] = "all",
5772
5773
  emit_writer_logs: bool = False,
5773
5774
  progress_callback: Optional[CompareProgressCallback] = None,
5775
+ compare_kernel: str = "pairwise",
5774
5776
  ) -> MatrixCompareSummary:
5775
5777
  """Run resumable all-vs-all matrix comparison on the current matrix store.
5776
5778
 
5777
5779
  Results are written into a DuckDB compare database. Re-running the same
5778
5780
  command against an existing compare database resumes from the remaining
5779
5781
  uncompleted pairs.
5782
+
5783
+ ``compare_kernel`` selects the comparison algorithm. ``"pairwise"`` (the
5784
+ default) compares one anchor against target blocks and supports every
5785
+ metric. ``"gemm"`` computes genome-level popANI/conANI for whole blocks
5786
+ of sample pairs with dense matrix multiplications (see
5787
+ :mod:`zipstrain.matrix_gemm`); it requires ``calculate="ani"`` and gives
5788
+ identical ``total_positions``/``share_allele_pos``/``genome_ani`` values.
5789
+ Anchor/target queue, transfer-batch and executor options are ignored by
5790
+ the GEMM kernel.
5780
5791
  """
5781
5792
  if min_cov is not None and min_cov < 1:
5782
5793
  raise ValueError("min_cov must be >= 1")
@@ -5795,6 +5806,15 @@ def matrix_compare(
5795
5806
  raise ValueError(
5796
5807
  f"writer_executor_kind must be one of {', '.join(MATRIX_IO_EXECUTOR_KINDS)}"
5797
5808
  )
5809
+ if compare_kernel not in MATRIX_COMPARE_KERNELS:
5810
+ raise ValueError(
5811
+ f"compare_kernel must be one of {', '.join(MATRIX_COMPARE_KERNELS)}"
5812
+ )
5813
+ if compare_kernel == "gemm" and normalized_ani_method not in ("popani", "conani"):
5814
+ raise ValueError(
5815
+ "compare_kernel='gemm' supports ani_method popani or conani; "
5816
+ f"use compare_kernel='pairwise' for {normalized_ani_method}."
5817
+ )
5798
5818
  matrix_db_file = matrix_db_file.resolve()
5799
5819
  output_file = output_file.resolve()
5800
5820
  output_file.parent.mkdir(parents=True, exist_ok=True)
@@ -5860,6 +5880,11 @@ def matrix_compare(
5860
5880
  "Gene ANI was requested, but this matrix store does not contain gene annotations. "
5861
5881
  "Rebuild it with --gene-range-table."
5862
5882
  )
5883
+ if compare_kernel == "gemm" and tuple(calculations) != ("ani",):
5884
+ raise ValueError(
5885
+ "compare_kernel='gemm' computes genome-level ANI only; pass calculate='ani' "
5886
+ f"(requested: {'+'.join(calculations)}). IBS and gene ANI need compare_kernel='pairwise'."
5887
+ )
5863
5888
  compare_conn, compare_ids = _prepare_matrix_compare_db(
5864
5889
  output_file=output_file,
5865
5890
  matrix_metadata=metadata,
@@ -5893,6 +5918,33 @@ def matrix_compare(
5893
5918
  ani_method=normalized_ani_method,
5894
5919
  )
5895
5920
 
5921
+ if compare_kernel == "gemm":
5922
+ from zipstrain import matrix_gemm
5923
+
5924
+ return matrix_gemm.matrix_compare_gemm(
5925
+ matrix_db_file=matrix_db_file,
5926
+ output_file=output_file,
5927
+ compare_conn=compare_conn,
5928
+ compare_ids=compare_ids,
5929
+ samples=samples,
5930
+ genomes=genomes,
5931
+ genome_scaffolds=genome_scaffolds,
5932
+ completed_pairs_by_genome=completed_pairs_by_genome,
5933
+ requested_pairs=requested_pairs,
5934
+ total_work=total_work,
5935
+ metadata=metadata,
5936
+ matrix_value_semantics=matrix_value_semantics,
5937
+ ani_method=normalized_ani_method,
5938
+ compute_backend=compute_backend,
5939
+ memory_limit_bytes=memory_limit_bytes,
5940
+ memory_limit_gb=memory_limit_gb,
5941
+ min_cov=min_cov,
5942
+ genome_scope=genome_scope,
5943
+ emit_writer_logs=emit_writer_logs,
5944
+ run_start_time=run_start_time,
5945
+ progress_callback=progress_callback,
5946
+ )
5947
+
5896
5948
  if compute_backend.kind == "torch":
5897
5949
  compare_conn.close()
5898
5950
  compare_conn = None
@@ -771,18 +771,20 @@ def process_read_location(output_file:str, batch_size:int=10000)->None:
771
771
  writer.close()
772
772
 
773
773
 
774
- def extract_genome_length(stb: pl.LazyFrame, bed_table: pl.LazyFrame) -> pl.LazyFrame:
774
+ def extract_scaffold_length(bed_table: pl.LazyFrame) -> pl.LazyFrame:
775
775
  """
776
- Extract the genome length information from the scaffold-to-genome mapping table.
776
+ Extract per-scaffold lengths from a BED table.
777
+
778
+ ``make_the_bed`` can split one long scaffold across several BED rows, so the
779
+ per-row spans are summed back together per scaffold.
777
780
 
778
781
  Parameters:
779
- stb (pl.LazyFrame): Scaffold-to-bin mapping table.
780
782
  bed_table (pl.LazyFrame): BED table containing genomic regions.
781
783
 
782
784
  Returns:
783
- pl.LazyFrame: A LazyFrame containing the genome lengths.
785
+ pl.LazyFrame: A LazyFrame with one row per scaffold and its total length.
784
786
  """
785
- lf= bed_table.select(
787
+ return bed_table.select(
786
788
  pl.col("scaffold"),
787
789
  (pl.col("end") - pl.col("start")).alias("scaffold_length")
788
790
  ).group_by("scaffold").agg(
@@ -790,7 +792,20 @@ def extract_genome_length(stb: pl.LazyFrame, bed_table: pl.LazyFrame) -> pl.Lazy
790
792
  ).select(
791
793
  pl.col("scaffold").alias("scaffold"),
792
794
  pl.col("scaffold_length")
793
- ).join(
795
+ )
796
+
797
+ def extract_genome_length(stb: pl.LazyFrame, bed_table: pl.LazyFrame) -> pl.LazyFrame:
798
+ """
799
+ Extract the genome length information from the scaffold-to-genome mapping table.
800
+
801
+ Parameters:
802
+ stb (pl.LazyFrame): Scaffold-to-bin mapping table.
803
+ bed_table (pl.LazyFrame): BED table containing genomic regions.
804
+
805
+ Returns:
806
+ pl.LazyFrame: A LazyFrame containing the genome lengths.
807
+ """
808
+ lf= extract_scaffold_length(bed_table).join(
794
809
  stb.select(
795
810
  pl.col("scaffold").alias("scaffold"),
796
811
  pl.col("genome").alias("genome")
@@ -1469,6 +1484,8 @@ def get_genome_gaps(
1469
1484
  read_loc_table: pl.LazyFrame,
1470
1485
  stb: pl.LazyFrame,
1471
1486
  genome_length: pl.LazyFrame,
1487
+ scaffold_length: pl.LazyFrame,
1488
+ avg_read_length: int = 0,
1472
1489
  )-> pl.LazyFrame:
1473
1490
  # The read-location table can be much larger than the profile itself. Stream it to a
1474
1491
  # temporary parquet and let DuckDB handle the global sort/window with disk spill.
@@ -1486,6 +1503,10 @@ def get_genome_gaps(
1486
1503
  "genome_length_src",
1487
1504
  genome_length.select("genome", "genome_length").collect().to_arrow(),
1488
1505
  )
1506
+ conn.register(
1507
+ "scaffold_length_src",
1508
+ scaffold_length.select("scaffold", "scaffold_length").collect().to_arrow(),
1509
+ )
1489
1510
  read_loc_sql = _duckdb_quote_sql_string(str(read_loc_path))
1490
1511
  table = conn.execute(
1491
1512
  f"""
@@ -1507,19 +1528,47 @@ def get_genome_gaps(
1507
1528
  CAST(genome_length AS DOUBLE) AS genome_length
1508
1529
  FROM genome_length_src
1509
1530
  ),
1531
+ scaffold_length AS (
1532
+ SELECT
1533
+ CAST(scaffold AS VARCHAR) AS scaffold,
1534
+ CAST(scaffold_length AS BIGINT) AS scaffold_length
1535
+ FROM scaffold_length_src
1536
+ ),
1537
+ positions AS (
1538
+ SELECT scaffold, loc FROM read_loc
1539
+ UNION ALL
1540
+ -- End-of-scaffold sentinel, one per covered scaffold. Metapresence
1541
+ -- appends (sequence_length - avg_read_length) to its position list so a
1542
+ -- trailing uncovered region is charged as a gap; without it a genome
1543
+ -- whose reads stop early looks perfectly uniform.
1544
+ SELECT
1545
+ rs.scaffold,
1546
+ GREATEST(sl.scaffold_length - {int(avg_read_length)}, 0)::BIGINT AS loc
1547
+ FROM (SELECT DISTINCT scaffold FROM read_loc) rs
1548
+ INNER JOIN scaffold_length sl ON rs.scaffold = sl.scaffold
1549
+ ),
1510
1550
  mapped AS (
1511
1551
  SELECT
1512
- rl.scaffold,
1513
- rl.loc,
1514
- rl.loc - LAG(rl.loc) OVER (PARTITION BY rl.scaffold ORDER BY rl.loc) AS gap_length,
1552
+ p.scaffold,
1553
+ p.loc,
1554
+ p.loc - LAG(p.loc) OVER (PARTITION BY p.scaffold ORDER BY p.loc) AS gap_length,
1515
1555
  stb.genome
1556
+ FROM positions p
1557
+ LEFT JOIN stb ON p.scaffold = stb.scaffold
1558
+ ),
1559
+ read_count AS (
1560
+ -- Sentinels must not inflate reads_mapped, so count real reads only.
1561
+ SELECT
1562
+ stb.genome,
1563
+ COUNT(*)::BIGINT AS rn
1516
1564
  FROM read_loc rl
1517
1565
  LEFT JOIN stb ON rl.scaffold = stb.scaffold
1566
+ GROUP BY stb.genome
1518
1567
  ),
1519
1568
  delta AS (
1520
1569
  SELECT
1521
1570
  m.genome,
1522
- COUNT(*)::BIGINT AS rn,
1571
+ COUNT(m.gap_length)::BIGINT AS gap_count,
1523
1572
  AVG(m.gap_length)::DOUBLE AS gap_mean,
1524
1573
  STDDEV_SAMP(m.gap_length)::DOUBLE AS gap_std,
1525
1574
  ROUND(gl.genome_length / COUNT(*))::DOUBLE AS delta
@@ -1532,23 +1581,24 @@ def get_genome_gaps(
1532
1581
  m.genome,
1533
1582
  m.gap_length,
1534
1583
  d.delta,
1535
- d.rn,
1584
+ d.gap_count,
1536
1585
  d.gap_mean,
1537
1586
  d.gap_std,
1538
- (COUNT(*)::DOUBLE / NULLIF(d.rn - 1, 0)) * (m.gap_length - d.delta) AS pd
1587
+ (COUNT(*)::DOUBLE / NULLIF(d.gap_count, 0)) * (m.gap_length - d.delta) AS pd
1539
1588
  FROM mapped m
1540
1589
  INNER JOIN delta d ON m.genome = d.genome
1541
1590
  WHERE m.gap_length > d.delta
1542
- GROUP BY m.genome, m.gap_length, d.delta, d.rn, d.gap_mean, d.gap_std
1591
+ GROUP BY m.genome, m.gap_length, d.delta, d.gap_count, d.gap_mean, d.gap_std
1543
1592
  )
1544
1593
  SELECT
1545
- CAST(genome AS VARCHAR) AS genome,
1546
- (MIN(delta) - SUM(pd)) / MIN(delta) AS fug,
1547
- MIN(rn)::BIGINT AS rn,
1548
- MIN(gap_mean)::DOUBLE AS gap_mean,
1549
- MIN(gap_std)::DOUBLE AS gap_std
1550
- FROM gap_excess
1551
- GROUP BY genome
1594
+ CAST(ge.genome AS VARCHAR) AS genome,
1595
+ (MIN(ge.delta) - SUM(ge.pd)) / MIN(ge.delta) AS fug,
1596
+ MIN(rc.rn)::BIGINT AS rn,
1597
+ MIN(ge.gap_mean)::DOUBLE AS gap_mean,
1598
+ MIN(ge.gap_std)::DOUBLE AS gap_std
1599
+ FROM gap_excess ge
1600
+ LEFT JOIN read_count rc ON ge.genome = rc.genome
1601
+ GROUP BY ge.genome
1552
1602
  """
1553
1603
  ).fetch_arrow_table()
1554
1604
  finally:
@@ -1569,7 +1619,8 @@ def get_genome_stats(
1569
1619
  )->pl.LazyFrame:
1570
1620
  include_reference_ani = REF_BASE_BITMASK_COLUMN in set(profile.collect_schema().names())
1571
1621
  genome_lengths = extract_genome_length(stb, bed)
1572
- genome_gap_stats = get_genome_gaps(read_loc_table, stb, genome_lengths)
1622
+ scaffold_lengths = extract_scaffold_length(bed)
1623
+ genome_gap_stats = get_genome_gaps(read_loc_table, stb, genome_lengths, scaffold_lengths)
1573
1624
  profile_columns = [
1574
1625
  pl.col("genome").cast(pl.Utf8).alias("genome"),
1575
1626
  pl.col("A"),
File without changes
File without changes