agglovar 0.0.1.dev17__tar.gz → 0.0.1.dev18__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 (59) hide show
  1. {agglovar-0.0.1.dev17/src/agglovar.egg-info → agglovar-0.0.1.dev18}/PKG-INFO +2 -2
  2. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/pyproject.toml +15 -6
  3. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/__init__.py +1 -1
  4. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/intersect.py +18 -1
  5. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/join.py +72 -10
  6. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/merge.py +9 -2
  7. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/expr/variant.py +38 -1
  8. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/cumulative.py +61 -9
  9. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/base.py +28 -1
  10. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/__init__.py +36 -0
  11. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/_const.py +11 -0
  12. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/_overlap.py +397 -26
  13. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_read.py +52 -21
  14. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18/src/agglovar.egg-info}/PKG-INFO +2 -2
  15. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/requires.txt +1 -1
  16. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/LICENSE +0 -0
  17. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/README.md +0 -0
  18. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/setup.cfg +0 -0
  19. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/align/__init__.py +0 -0
  20. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/align/op.py +0 -0
  21. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/align/score.py +0 -0
  22. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/__init__.py +0 -0
  23. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/bed/col.py +0 -0
  24. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/dev/__init__.py +0 -0
  25. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/dev/imports.py +0 -0
  26. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/expr/__init__.py +0 -0
  27. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/fa.py +0 -0
  28. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/io.py +0 -0
  29. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/kmer/__init__.py +0 -0
  30. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/kmer/plot.py +0 -0
  31. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/kmer/util.py +0 -0
  32. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/__init__.py +0 -0
  33. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/base.py +0 -0
  34. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/__init__.py +0 -0
  35. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/parser.py +0 -0
  36. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/stage.py +0 -0
  37. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/config/strategy.py +0 -0
  38. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/om/__init__.py +0 -0
  39. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/om/_executor_base.py +0 -0
  40. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/merge/om/_executor_nr.py +0 -0
  41. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/meta/__init__.py +0 -0
  42. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/meta/decorators.py +0 -0
  43. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/meta/descriptors.py +0 -0
  44. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/__init__.py +0 -0
  45. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/overlap/_stage.py +0 -0
  46. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/pairwise/weights.py +0 -0
  47. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/schema.py +0 -0
  48. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/seqmatch.py +0 -0
  49. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/util/__init__.py +0 -0
  50. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/util/lazy.py +0 -0
  51. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/util/str.py +0 -0
  52. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/util/var.py +0 -0
  53. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/__init__.py +0 -0
  54. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_const.py +0 -0
  55. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_header.py +0 -0
  56. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar/vcf/_vcf_write.py +0 -0
  57. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/SOURCES.txt +0 -0
  58. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/dependency_links.txt +0 -0
  59. {agglovar-0.0.1.dev17 → agglovar-0.0.1.dev18}/src/agglovar.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agglovar
3
- Version: 0.0.1.dev17
3
+ Version: 0.0.1.dev18
4
4
  Summary: Toolkit for fast genomic variant transformations and intersects
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.12
@@ -10,7 +10,7 @@ Requires-Dist: biopython>=1.85
10
10
  Requires-Dist: edlib>=1.3.9.post1
11
11
  Requires-Dist: numpy>=2.3.0
12
12
  Requires-Dist: ply>=3.11
13
- Requires-Dist: polars[numpy,pandas,pyarrow]>=1.35.2
13
+ Requires-Dist: polars[numpy,pandas,pyarrow]==1.42.1
14
14
  Requires-Dist: pysam>=0.23.3
15
15
  Provides-Extra: plot
16
16
  Requires-Dist: matplotlib>=3.10.3; extra == "plot"
@@ -15,7 +15,9 @@ dependencies = [
15
15
  "edlib>=1.3.9.post1",
16
16
  "numpy>=2.3.0",
17
17
  "ply>=3.11",
18
- "polars[numpy,pandas,pyarrow]>=1.35.2",
18
+ # 1.41.2: merge_sorted() crashes on List(Struct) columns before this (used by the cumulative
19
+ # merge to carry the complex-variant "seg" column).
20
+ "polars[numpy,pandas,pyarrow]==1.42.1",
19
21
  "pysam>=0.23.3",
20
22
  ]
21
23
 
@@ -52,11 +54,10 @@ maintainers = [
52
54
 
53
55
  [dependency-groups]
54
56
  dev = [
55
- "arrow[parquet]>=1.4.0",
56
57
  "flake8>=7.3.0",
57
58
  "flake8-bugbear>=24.12.12",
58
59
  "flake8-docstrings>=1.7.0",
59
- "flake8-dunder-all>=0.5.0",
60
+ "flake8-dunder-all>=0.6.0",
60
61
  "flake8-mutable>=1.2.0",
61
62
  "flake8-pyproject>=1.2.3",
62
63
  "flake8-rst-docstrings>=0.3.1",
@@ -107,9 +108,17 @@ max-line-length = 120
107
108
  [tool.flake8]
108
109
  max-line-length = 120
109
110
  docstring-convention = "pep257"
111
+ # DALL100/DALL101 (flake8-dunder-all >= 0.6.0) require a top-level PEP 562 __dir__ in every module.
112
+ # This package has no module-level __getattr__ to justify one, and a __dir__ returning only __all__
113
+ # would break agglovar.dev.imports.librl, which walks dir(module) to build its reload graph.
114
+ #
115
+ # These cannot go in extend-ignore: flake8 validates those codes against '^[A-Z]{1,3}[0-9]{0,3}$'
116
+ # and the 4-letter DALL prefix fails. per-file-ignores skips that check, but selects the single
117
+ # longest matching pattern rather than unioning them, so the codes must be repeated in every entry.
110
118
  per-file-ignores = [
111
- "src/agglovar/merge/config/parser.py: D200, D205, D212, D300, D400, D401, D403, D415",
112
- "src/agglovar/vcf/__init__.py: DALL002, F401, F403",
113
- "src/agglovar/pairwise/overlap/__init__.py: DALL002, F401, F403",
119
+ "*: DALL100, DALL101",
120
+ "src/agglovar/merge/config/parser.py: DALL100, DALL101, D200, D205, D212, D300, D400, D401, D403, D415",
121
+ "src/agglovar/vcf/__init__.py: DALL100, DALL101, DALL002, F401, F403",
122
+ "src/agglovar/pairwise/overlap/__init__.py: DALL100, DALL101, DALL002, F401, F403",
114
123
  ]
115
124
  rst-roles = ["class", "meth", "const", "ref", "func", "mod", "attr", "data"]
@@ -1,6 +1,6 @@
1
1
  """Agglovar: A toolkit for fast genomic variant transformations and intersects."""
2
2
 
3
- __version__ = '0.0.1.dev17'
3
+ __version__ = '0.0.1.dev18'
4
4
 
5
5
  __all__ = [
6
6
  'align',
@@ -14,6 +14,7 @@ from .col import (
14
14
  get_coord_cols,
15
15
  )
16
16
  from .join import (
17
+ CHUNK_SIZE,
17
18
  pairwise_join,
18
19
  pairwise_join_iter,
19
20
  )
@@ -36,6 +37,7 @@ def as_bool(
36
37
  col_names_a: Optional[CoordCol | Iterable[str] | str] = None,
37
38
  col_names_b: Optional[CoordCol | Iterable[str] | str] = None,
38
39
  temp_dir: bool | str | Path = False,
40
+ chunk_size: int = CHUNK_SIZE,
39
41
  ) -> pl.LazyFrame:
40
42
  """Add a boolean column to df_a indicating whether each record intersects with df_b.
41
43
 
@@ -48,6 +50,8 @@ def as_bool(
48
50
  :param col_names_b: Columns in b (chromosome or query ID, pos, end).
49
51
  :param temp_dir: How to materialise the prepared tables before iterating. See
50
52
  :func:`agglovar.bed.join.pairwise_join`.
53
+ :param chunk_size: Chunk A by this size per chromosome. See
54
+ :func:`agglovar.bed.join.pairwise_join`.
51
55
 
52
56
  :return: A LazyFrame with two columns: ``_index`` and ``name``.
53
57
  """
@@ -65,12 +69,15 @@ def as_bool(
65
69
 
66
70
  hit_val = not negate
67
71
 
72
+ logger.debug('as_bool: name=%r distance=%d negate=%s' % (name, distance, negate))
73
+
68
74
  join_list = []
69
75
 
70
76
  for df_join in pairwise_join_iter(
71
77
  df_a=df_a,
72
78
  df_b=df_b,
73
79
  distance=distance,
80
+ chunk_size=chunk_size,
74
81
  col_names_a=col_names_a,
75
82
  col_names_b=col_names_b,
76
83
  temp_dir=temp_dir,
@@ -82,6 +89,8 @@ def as_bool(
82
89
  .lazy()
83
90
  )
84
91
 
92
+ logger.debug('as_bool: collected %d non-empty join chunk(s)' % len(join_list))
93
+
85
94
  if '_index' in df_a.collect_schema().names():
86
95
  df_a_indexed = df_a
87
96
  else:
@@ -119,6 +128,7 @@ def as_proportion(
119
128
  col_names_a: Optional[CoordCol | Iterable[str] | str] = None,
120
129
  col_names_b: Optional[CoordCol | Iterable[str] | str] = None,
121
130
  temp_dir: bool | str | Path = False,
131
+ chunk_size: int = CHUNK_SIZE,
122
132
  ) -> pl.LazyFrame:
123
133
  """Compute the proportion of each interval in ``df_a`` covered by intervals in ``df_b``.
124
134
 
@@ -126,12 +136,14 @@ def as_proportion(
126
136
  proportion. Zero-length intervals (``pos == end``) produce ``NaN`` (0 / 0).
127
137
 
128
138
  :param df_a: Table a.
129
- :param df_b: Table b.
139
+ :param df_b: Table b.`
130
140
  :param name: Name of the column to add.
131
141
  :param col_names_a: Columns in a (chromosome or query ID, pos, end).
132
142
  :param col_names_b: Columns in b (chromosome or query ID, pos, end).
133
143
  :param temp_dir: How to materialise the prepared tables before iterating. See
134
144
  :func:`agglovar.bed.join.pairwise_join`.
145
+ :param chunk_size: Chunk A by this size per chromosome. See
146
+ :func:`agglovar.bed.join.pairwise_join`.
135
147
 
136
148
  :return: A LazyFrame with two columns: ``_index`` and ``name``.
137
149
  """
@@ -143,6 +155,8 @@ def as_proportion(
143
155
 
144
156
  col_expr_a = col_names_a.exprs()
145
157
 
158
+ logger.debug('as_proportion: name=%r' % name)
159
+
146
160
  if isinstance(df_a, pl.DataFrame):
147
161
  df_a = df_a.lazy()
148
162
 
@@ -165,9 +179,12 @@ def as_proportion(
165
179
  .collect()
166
180
  )
167
181
 
182
+ logger.debug('as_proportion: %d non-redundant B region(s) after merge_depth' % df_b_nr.height)
183
+
168
184
  df_join = pairwise_join(
169
185
  df_a=df_a_clean,
170
186
  df_b=df_b_nr,
187
+ chunk_size=chunk_size,
171
188
  col_names_a=col_names_a,
172
189
  col_names_b=col_names_b,
173
190
  temp_dir=temp_dir,
@@ -7,6 +7,7 @@ __all__ = [
7
7
 
8
8
  import logging
9
9
  from pathlib import Path
10
+ import time
10
11
  from typing import (
11
12
  Iterable,
12
13
  Iterator,
@@ -24,8 +25,14 @@ from .col import (
24
25
 
25
26
  logger = logging.getLogger(__name__)
26
27
 
27
- CHUNK_SIZE: int = 2_500
28
- """Default size of join chunks. Breaks up tables into batches of this size or less."""
28
+ CHUNK_SIZE: int = 25_000
29
+ """
30
+ Default number of df_a records per chunk. Table A is processed one chromosome at a time and split
31
+ into chunks of this size or less; each chunk drives one ``join_where`` (IEJoin) against the B
32
+ records overlapping the chunk's position span. Larger chunks amortize the fixed per-chunk overhead
33
+ (query setup and the per-chunk B pre-filter) over more rows, but widen each chunk's span so more B
34
+ records enter each join. Tune based on available RAM.
35
+ """
29
36
 
30
37
 
31
38
  class _JoinResources:
@@ -194,6 +201,11 @@ def pairwise_join_iter(
194
201
  col_names_b=col_names_b,
195
202
  )
196
203
 
204
+ logger.debug(
205
+ 'Starting bed join: distance=%d chunk_size=%d temp_dir=%r cols_a=%s cols_b=%s'
206
+ % (distance, chunk_size, temp_dir, tuple(resources.col_a), tuple(resources.col_b))
207
+ )
208
+
197
209
  with materialize_pair(
198
210
  resources.df_a, resources.df_b, temp_dir, prefix='bed_join_prep_',
199
211
  ) as (df_a_mat, df_b_mat):
@@ -273,6 +285,10 @@ def _join_chunks(
273
285
  col_a = join_resources.col_a
274
286
  col_b = join_resources.col_b
275
287
 
288
+ # Per-chunk timing and the df_b survivor count require extra collects, so gate them on DEBUG:
289
+ # an INFO-level run pays nothing and never materializes the diagnostics.
290
+ debug = logger.isEnabledFor(logging.DEBUG)
291
+
276
292
  # Restrict the chrom loop to chroms present in both tables. Chroms unique to A
277
293
  # would otherwise drive a wasted A chunk loop with no possible matches.
278
294
  for chrom, last_index_a in (
@@ -286,11 +302,30 @@ def _join_chunks(
286
302
  )
287
303
  .sort(col_a.chrom)
288
304
  ).collect().rows():
305
+ # Collect this chromosome's A and B once into memory. The chunk loop below re-filters
306
+ # df_a_chrom (per index range) and df_b_chrom (per prefilter bounds) many times; leaving
307
+ # them lazy re-runs the chrom filter over the full materialized df_a/df_b on every chunk,
308
+ # which dominates runtime (fixed per-chunk cost independent of the join size). Collecting
309
+ # per chrom makes the per-chunk filters operate on the small per-chrom frame instead.
310
+ # Mirrors agglovar.pairwise.overlap._overlap._join_iter_chunked.
289
311
  df_a_chrom = (
290
312
  df_a.filter(pl.col(col_a.chrom) == chrom)
291
313
  .with_row_index('_index_chrom_a')
314
+ .collect()
315
+ .lazy()
292
316
  )
293
- df_b_chrom = df_b.filter(pl.col(col_b.chrom) == chrom)
317
+ df_b_chrom = (
318
+ df_b.filter(pl.col(col_b.chrom) == chrom)
319
+ .collect()
320
+ .lazy()
321
+ )
322
+
323
+ if debug:
324
+ chrom_t0 = time.perf_counter()
325
+ chrom_chunks = 0
326
+ chrom_pairs = 0
327
+ chrom_join_s = 0.0
328
+ chrom_b_max = 0
294
329
 
295
330
  start_index_a = 0
296
331
  while start_index_a < last_index_a:
@@ -323,12 +358,39 @@ def _join_chunks(
323
358
  pl.col(col_b.end) + distance >= pos_min,
324
359
  )
325
360
 
326
- yield _build_join_pair(
327
- df_a_chunk,
328
- df_b_chunk,
329
- distance,
330
- col_a,
331
- col_b,
332
- ).collect().lazy()
361
+ # The join itself is computed identically regardless of log level; only the diagnostics
362
+ # below are gated on DEBUG, so raising the log level cannot change the result.
363
+ if debug:
364
+ t0 = time.perf_counter()
365
+
366
+ df_join = _build_join_pair(df_a_chunk, df_b_chunk, distance, col_a, col_b).collect()
333
367
 
368
+ if debug:
369
+ dt = time.perf_counter() - t0
370
+ # n_b is the count of B records surviving the range prefilter for this chunk. When
371
+ # A is position-local it stays small; when it approaches the chrom's B total the
372
+ # prefilter is not pruning. pos_min-end_max is the chunk's position span.
373
+ n_b = df_b_chunk.select(pl.len()).collect().item()
374
+
375
+ logger.debug(
376
+ 'Join chunk: chrom %s [%d:%d] pos=%s-%s n_b=%d pairs=%d in %.3fs'
377
+ % (chrom, start_index_a, end_index_a, pos_min, end_max,
378
+ n_b, df_join.height, dt)
379
+ )
380
+
381
+ chrom_chunks += 1
382
+ chrom_pairs += df_join.height
383
+ chrom_join_s += dt
384
+ if n_b > chrom_b_max:
385
+ chrom_b_max = n_b
386
+
387
+ yield df_join.lazy()
334
388
  start_index_a = end_index_a
389
+
390
+ if debug:
391
+ logger.debug(
392
+ 'Join chrom done: chrom %s n_a=%d chunks=%d n_b_max=%d pairs=%d '
393
+ 'join=%.3fs wall=%.3fs'
394
+ % (chrom, last_index_a, chrom_chunks, chrom_b_max, chrom_pairs,
395
+ chrom_join_s, time.perf_counter() - chrom_t0)
396
+ )
@@ -26,7 +26,7 @@ def merge_depth(
26
26
  distance: int = 0,
27
27
  col_names: Optional[CoordCol | Iterable[str] | str] = None,
28
28
  col_max_depth: Optional[str] = 'max_depth'
29
- ):
29
+ ) -> pl.LazyFrame:
30
30
  """Merge overlapping regions in a table.
31
31
 
32
32
  :param df: Table of coordinates.
@@ -48,6 +48,11 @@ def merge_depth(
48
48
  if isinstance(df, pl.DataFrame):
49
49
  df = df.lazy()
50
50
 
51
+ logger.debug(
52
+ 'merge_depth: distance=%d cols=%s max_depth=%r'
53
+ % (distance, tuple(col_names), col_max_depth)
54
+ )
55
+
51
56
  return (
52
57
  melt_depth(
53
58
  df=df.with_columns(pl.col(col_names.end) + distance),
@@ -75,7 +80,7 @@ def melt_depth(
75
80
  col_names: CoordCol,
76
81
  col_loc: str = '_loc',
77
82
  col_depth: str = '_depth',
78
- ):
83
+ ) -> pl.LazyFrame:
79
84
  """Get a table of depths by locations of changes.
80
85
 
81
86
  The table will have one row for each location of change with end positions having two entries (one for the
@@ -158,6 +163,8 @@ def melt_depth(
158
163
 
159
164
  assert col_loc != col_depth, 'Column names "%s" and "%s" are the same' % (col_loc, col_depth)
160
165
 
166
+ logger.debug('melt_depth: cols=%s loc=%s depth=%s' % (tuple(col_names), col_loc, col_depth))
167
+
161
168
  return (
162
169
  pl.concat(
163
170
  [
@@ -6,6 +6,7 @@ __all__ = [
6
6
  'sort_cols',
7
7
  'id_expr_noalias',
8
8
  'id_expr',
9
+ 'id_expr_for_table',
9
10
  'lead_src_expr',
10
11
  ]
11
12
 
@@ -14,6 +15,8 @@ from typing import Optional
14
15
 
15
16
  import polars as pl
16
17
 
18
+ import agglovar.schema as _agg_schema
19
+
17
20
  logger = logging.getLogger(__name__)
18
21
 
19
22
  id_snv_expr = (
@@ -64,7 +67,41 @@ id_expr_noalias = (
64
67
  """Expression for generating variant IDs."""
65
68
 
66
69
  id_expr = id_expr_noalias.alias('id')
67
- """Expression for generating variant IDs including alias "id" for the result (ideal for `with_column()`)."""
70
+ """Expression for generating variant IDs including alias "id" for the result (ideal for `with_column()`).
71
+
72
+ Requires both the SNV columns (``ref``, ``alt``) and the non-SNV column (``varlen``) to be present:
73
+ Polars resolves both branches of the expression regardless of which one a row selects. Use
74
+ :func:`id_expr_for_table` for single-type tables, which carry only the columns listed in
75
+ :data:`agglovar.schema.STANDARD_FIELDS` for their type.
76
+ """
77
+
78
+
79
+ def id_expr_for_table(table: str, alias: Optional[str] = 'id') -> pl.Expr:
80
+ """Get a variant ID expression for a single variant-type table.
81
+
82
+ Single-type tables carry only the columns listed for their type in
83
+ :data:`agglovar.schema.STANDARD_FIELDS`, so :data:`id_expr` cannot be applied to them (see its
84
+ documentation). This routine returns the one branch that matches the table's columns.
85
+
86
+ :param table: Table name from :data:`agglovar.schema.STANDARD_FIELDS` (e.g. "snv", "insdel").
87
+ :param alias: Output column name for the resulting IDs, or `None` to leave it unaliased.
88
+
89
+ :returns: An expression yielding variant IDs for *table*.
90
+
91
+ :raises ValueError: If *table* is not a known variant-type table.
92
+ """
93
+ fields = _agg_schema.STANDARD_FIELDS.get(table)
94
+
95
+ if fields is None:
96
+ raise ValueError(
97
+ f'Unknown variant-type table: {table!r}: '
98
+ f'expected one of {", ".join(sorted(_agg_schema.STANDARD_FIELDS))}'
99
+ )
100
+
101
+ # Tables without "varlen" (SNV) are identified by their changed bases, all others by length
102
+ expr = id_nonsnv_expr if 'varlen' in fields else id_snv_expr
103
+
104
+ return expr.alias(alias) if alias is not None else expr
68
105
 
69
106
 
70
107
  def lead_src_expr(
@@ -30,6 +30,7 @@ __all__ = [
30
30
  ]
31
31
 
32
32
  from collections.abc import Iterable
33
+ from concurrent.futures import Executor
33
34
  from enum import Enum
34
35
  import logging
35
36
  from pathlib import Path
@@ -176,6 +177,7 @@ class MergeCumulative(MergeBase):
176
177
  sort: bool = True,
177
178
  add_id: bool = True,
178
179
  temp_dir: bool | str | Path = False,
180
+ match_threads: Optional[int] = 4,
179
181
  ) -> pl.LazyFrame:
180
182
  """
181
183
  Intersect callsets.
@@ -187,6 +189,13 @@ class MergeCumulative(MergeBase):
187
189
  last (after "_index" is set).
188
190
  :param temp_dir: Forwarded to the pairwise intersect for each cumulative step. See
189
191
  :meth:`agglovar.pairwise.base.PairwiseJoin.join_iter`.
192
+ :param match_threads: Number of worker processes used to score sequence matches
193
+ (``match_prop``) during the merge. A single ``spawn``-context process pool is created
194
+ once and reused across all sources. Defaults to 4. If ``None``, no pool is created and
195
+ match scoring runs serially (turns multiprocessing off entirely). Ignored when the
196
+ pairwise join does no sequence matching. In a spawn-restricted or resource-limited
197
+ environment the pool degrades to serial scoring with a logged warning (see
198
+ :meth:`agglovar.pairwise.overlap.PairwiseOverlap.make_match_pool`).
190
199
 
191
200
  :return: A merged callset table.
192
201
  """
@@ -336,13 +345,62 @@ class MergeCumulative(MergeBase):
336
345
 
337
346
  logger.debug('Merge source %d: Cumulative size %s' % (iter_idx, f'{df_cumulative.height:,}'))
338
347
 
348
+ # Create one match-scoring pool for the whole merge (spawn context, sized by match_threads;
349
+ # None or a restricted environment keeps scoring serial). The pool is owned here and passed
350
+ # into each per-source join; it is never retained on the pairwise object.
351
+ with self.pairwise_join.make_match_pool(match_threads) as match_pool:
352
+ df_cumulative, df_sources = self._merge_sources(
353
+ callsets_mat=callsets_mat,
354
+ df_cumulative=df_cumulative,
355
+ df_sources=df_sources,
356
+ next_mg_index=next_mg_index,
357
+ merge_stat_cols=merge_stat_cols,
358
+ stat_struct_dtype=stat_struct_dtype,
359
+ sources_schema=sources_schema,
360
+ pairwise_cols=pairwise_cols,
361
+ mg_sort_expr=mg_sort_expr,
362
+ temp_dir=temp_dir,
363
+ match_pool=match_pool,
364
+ )
365
+
366
+ return self._finalize(
367
+ df_sources=df_sources,
368
+ callsets_mat=callsets_mat,
369
+ all_col_dict=all_col_dict,
370
+ sort=sort,
371
+ add_id=add_id,
372
+ )
373
+
374
+ def _merge_sources(
375
+ self,
376
+ callsets_mat: list[dict[str, Any]],
377
+ df_cumulative: pl.DataFrame,
378
+ df_sources: pl.DataFrame,
379
+ next_mg_index: int,
380
+ merge_stat_cols: dict[str, pl.DataType],
381
+ stat_struct_dtype: pl.DataType,
382
+ sources_schema: pl.Schema,
383
+ pairwise_cols: list[str],
384
+ mg_sort_expr: pl.Expr,
385
+ temp_dir: bool | str | Path,
386
+ match_pool: Optional[Executor] = None,
387
+ ) -> tuple[pl.DataFrame, pl.DataFrame]:
388
+ """Merge each remaining callset into the cumulative table, one source at a time.
389
+
390
+ The ``match_pool`` created by :meth:`__call__` is passed to each per-source pairwise join so
391
+ sequence matches score on the shared process pool (or serially when it is ``None``). Returns
392
+ the updated cumulative variant table and the long-form sources sidecar.
393
+ """
339
394
  # Iterate remaining callsets.
340
395
  for iter_idx, callset_mat in enumerate(callsets_mat[1:], start=1):
341
396
  logger.debug('Merge source %d: Merging "%s"' % (iter_idx, callset_mat['name']))
342
397
 
343
398
  df_join = (
344
- self.pairwise_join.join(df_cumulative.lazy(), callset_mat['table'].lazy(), temp_dir=temp_dir)
345
- .sort('weight', descending=True)
399
+ self.pairwise_join.join(
400
+ df_cumulative.lazy(), callset_mat['table'].lazy(),
401
+ temp_dir=temp_dir, match_pool=match_pool,
402
+ )
403
+ .sort('weight', descending=True, maintain_order=True)
346
404
  .unique('index_a', keep='first')
347
405
  .unique('index_b', keep='first')
348
406
  .collect()
@@ -460,13 +518,7 @@ class MergeCumulative(MergeBase):
460
518
 
461
519
  logger.debug('Merge source %d: Cumulative size %s' % (iter_idx, f'{df_cumulative.height:,}'))
462
520
 
463
- return self._finalize(
464
- df_sources=df_sources,
465
- callsets_mat=callsets_mat,
466
- all_col_dict=all_col_dict,
467
- sort=sort,
468
- add_id=add_id,
469
- )
521
+ return df_cumulative, df_sources
470
522
 
471
523
  def _finalize(
472
524
  self,
@@ -15,8 +15,11 @@ from collections.abc import (
15
15
  Iterator,
16
16
  Iterable,
17
17
  )
18
+ from concurrent.futures import Executor
19
+ from contextlib import contextmanager
18
20
  import logging
19
21
  from pathlib import Path
22
+ from typing import Optional
20
23
 
21
24
  import polars as pl
22
25
 
@@ -54,6 +57,7 @@ class PairwiseJoin(ABC):
54
57
  df_b: pl.DataFrame | pl.LazyFrame,
55
58
  retain_index: bool = False,
56
59
  temp_dir: bool | str | Path = False,
60
+ match_pool: Optional[Executor] = None,
57
61
  ) -> Iterator[pl.LazyFrame]:
58
62
  """Find all pairs of variants in two sources that meet a set of criteria.
59
63
 
@@ -65,6 +69,9 @@ class PairwiseJoin(ABC):
65
69
  ``False`` (default) collects both into memory; ``True`` writes them to the
66
70
  system temp directory as parquet files; a ``str``/``Path`` writes them to
67
71
  that directory. Temp files are always removed on exit.
72
+ :param match_pool: Optional worker pool for parallel sequence matching (from
73
+ :meth:`make_match_pool`), or ``None`` to score serially. Ignored by strategies that do
74
+ no sequence matching.
68
75
 
69
76
  :yields: A LazyFrame for each chunk.
70
77
  """
@@ -76,6 +83,7 @@ class PairwiseJoin(ABC):
76
83
  df_b: pl.DataFrame | pl.LazyFrame,
77
84
  retain_index: bool = False,
78
85
  temp_dir: bool | str | Path = False,
86
+ match_pool: Optional[Executor] = None,
79
87
  ) -> pl.LazyFrame:
80
88
  """Find all pairs of variants in two sources that meet a set of criteria.
81
89
 
@@ -86,13 +94,32 @@ class PairwiseJoin(ABC):
86
94
  :param retain_index: If True, do not drop an existing "_index" column in callset tables
87
95
  if they exist.
88
96
  :param temp_dir: See :meth:`join_iter`.
97
+ :param match_pool: See :meth:`join_iter`.
89
98
 
90
99
  :returns: A join table.
91
100
  """
92
101
  return pl.concat(
93
- self.join_iter(df_a, df_b, retain_index=retain_index, temp_dir=temp_dir)
102
+ self.join_iter(
103
+ df_a, df_b, retain_index=retain_index, temp_dir=temp_dir, match_pool=match_pool,
104
+ )
94
105
  )
95
106
 
107
+ @contextmanager
108
+ def make_match_pool(self, match_threads: Optional[int] = None) -> Iterator[Optional[Executor]]:
109
+ """Context manager yielding a worker pool for parallel sequence matching, or ``None``.
110
+
111
+ The default is a no-op that always yields ``None`` (serial scoring): strategies that do no
112
+ sequence matching (or do not parallelize it) ignore ``match_threads``. Subclasses that
113
+ parallelize sequence matching override this to create and tear down a worker pool the caller
114
+ passes to :meth:`join`/:meth:`join_iter` via ``match_pool``. See
115
+ :meth:`agglovar.pairwise.overlap.PairwiseOverlap.make_match_pool`.
116
+
117
+ :param match_threads: Requested worker count, or ``None`` to keep scoring serial.
118
+
119
+ :yields: A worker pool, or ``None`` when scoring serially.
120
+ """
121
+ yield None
122
+
96
123
  @property
97
124
  @abstractmethod
98
125
  def required_cols(self) -> set[str]:
@@ -219,6 +219,42 @@ but they are different and both have merits. While Agglovar compares *only* vari
219
219
  if they are shifted, Truvari includes the shift in the sequence match (i.e. the compared sequences
220
220
  include the variant and flanking reference bases).
221
221
 
222
+ Minimum segment reciprocal overlap (seg_ro_min)
223
+ -----------------------------------------------
224
+
225
+ **seg_ro_min**: Minimum segment reciprocal overlap (segment RO) for complex variants.
226
+
227
+ A complex variant is not one contiguous change but a set of *segments*, some aligned to a reference
228
+ locus and some not aligned at all. Comparing two complex variants by their reference span alone is
229
+ crude: two variants may cover the same span while being built from entirely different pieces.
230
+ Segment RO instead compares the pieces.
231
+
232
+ Segment RO is the size of the overlap between the two variants' aligned segments, treating segments
233
+ as a multiset. Both variants' segments are broken into ranges at every segment boundary, and each
234
+ range contributes its length times the *smaller* of the two variants' segment depths over it, where
235
+ depth is the number of segments covering the range. Taking the smaller depth is what keeps a segment
236
+ from being counted more than once when it overlaps several segments on the other side, and it makes
237
+ a duplicated segment count only as many times as both variants duplicate it. Segments overlap only
238
+ within the same chromosome and orientation.
239
+
240
+ Unaligned bases have no reference locus, so there is nothing to intersect them against. The smaller
241
+ of the two variants' unaligned lengths is credited as overlapping, which treats unaligned bases as
242
+ maximally agreeing:
243
+
244
+ .. code-block:: python
245
+
246
+ seg_ro = (overlap + min(unaligned_a, unaligned_b)) / max(total_len_a, total_len_b)
247
+
248
+ Where `total_len` is the length of all of a variant's segments, aligned and unaligned. Segment RO is
249
+ symmetric and is exactly 1.0 for a variant compared against itself.
250
+
251
+ Segment RO requires "seg", "qry_pos", and "qry_end" columns on both input tables. The "seg" column
252
+ is a list of segment structs with "chrom", "pos", "end", "qry_pos", "qry_end", and "is_rev" fields.
253
+
254
+ .. note::
255
+ `seg_ro_min` is applied after the join, so it does not narrow the candidate pairs on its own.
256
+ Pair it with a criterion that does, such as `ro_min`.
257
+
222
258
  Reasonable join parameters
223
259
  --------------------------
224
260
 
@@ -4,6 +4,7 @@ __all__ = [
4
4
  'RESERVED_COLS',
5
5
  'AUTOGEN_COLS',
6
6
  'DEFAULT_CHUNK_SIZE',
7
+ 'MATCH_CHUNK_SIZE',
7
8
  'EXPR_OVERLAP_RO',
8
9
  'EXPR_SZRO',
9
10
  'EXPR_OFFSET_DIST',
@@ -22,6 +23,8 @@ import polars as pl
22
23
  RESERVED_COLS: frozenset[str] = frozenset({
23
24
  '_index',
24
25
  '_end_ro',
26
+ '_seg_unaligned',
27
+ '_seg_total_len',
25
28
  })
26
29
  """Reserved columns are added automatically to input tables."""
27
30
 
@@ -38,6 +41,14 @@ size presented to the sequence-alignment thread pool — larger values amortize
38
41
  better but use more memory. Tune alongside ``n_threads`` based on available RAM and CPU count.
39
42
  """
40
43
 
44
+ MATCH_CHUNK_SIZE: int = 128
45
+ """
46
+ Number of ``(seq_a, seq_b)`` pairs scored per worker task when ``match_prop`` is computed on a
47
+ process pool. A small fixed chunk balances the heavy-tailed sequence-length distribution across
48
+ workers; larger chunks starve workers on the long tail. Batches smaller than ``2 * MATCH_CHUNK_SIZE``
49
+ are scored serially because the pool's per-batch overhead outweighs the parallel gain (benchmarked).
50
+ """
51
+
41
52
  EXPR_OVERLAP_RO = (
42
53
  (
43
54
  (