seqpro 0.22.0__tar.gz → 0.23.0__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 (165) hide show
  1. {seqpro-0.22.0 → seqpro-0.23.0}/CHANGELOG.md +17 -0
  2. {seqpro-0.22.0 → seqpro-0.23.0}/PKG-INFO +1 -1
  3. {seqpro-0.22.0 → seqpro-0.23.0}/pyproject.toml +1 -1
  4. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/rag/_core.py +308 -149
  5. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/rag/_ingest.py +18 -18
  6. {seqpro-0.22.0 → seqpro-0.23.0}/skills/seqpro/SKILL.md +7 -4
  7. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_core.py +4 -4
  8. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_core_records.py +2 -1
  9. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_nested_diff.py +1 -1
  10. seqpro-0.23.0/tests/test_ragged_nested_fixed_axis.py +154 -0
  11. seqpro-0.23.0/tests/test_ragged_tuple_index.py +203 -0
  12. {seqpro-0.22.0 → seqpro-0.23.0}/.claude/skills/zensical/SKILL.md +0 -0
  13. {seqpro-0.22.0 → seqpro-0.23.0}/.gitattributes +0 -0
  14. {seqpro-0.22.0 → seqpro-0.23.0}/.github/workflows/bench.yaml +0 -0
  15. {seqpro-0.22.0 → seqpro-0.23.0}/.github/workflows/bump.yaml +0 -0
  16. {seqpro-0.22.0 → seqpro-0.23.0}/.github/workflows/docs.yml +0 -0
  17. {seqpro-0.22.0 → seqpro-0.23.0}/.github/workflows/lint.yaml +0 -0
  18. {seqpro-0.22.0 → seqpro-0.23.0}/.github/workflows/merge.yaml +0 -0
  19. {seqpro-0.22.0 → seqpro-0.23.0}/.github/workflows/publish.yaml +0 -0
  20. {seqpro-0.22.0 → seqpro-0.23.0}/.github/workflows/release-pipeline.yaml +0 -0
  21. {seqpro-0.22.0 → seqpro-0.23.0}/.github/workflows/release.yaml +0 -0
  22. {seqpro-0.22.0 → seqpro-0.23.0}/.github/workflows/test.yaml +0 -0
  23. {seqpro-0.22.0 → seqpro-0.23.0}/.gitignore +0 -0
  24. {seqpro-0.22.0 → seqpro-0.23.0}/.pre-commit-config.yaml +0 -0
  25. {seqpro-0.22.0 → seqpro-0.23.0}/CLAUDE.md +0 -0
  26. {seqpro-0.22.0 → seqpro-0.23.0}/Cargo.lock +0 -0
  27. {seqpro-0.22.0 → seqpro-0.23.0}/Cargo.toml +0 -0
  28. {seqpro-0.22.0 → seqpro-0.23.0}/LICENSE +0 -0
  29. {seqpro-0.22.0 → seqpro-0.23.0}/README.md +0 -0
  30. {seqpro-0.22.0 → seqpro-0.23.0}/benches/bench_tokenize_translate.py +0 -0
  31. {seqpro-0.22.0 → seqpro-0.23.0}/benches/kshuffle.rs +0 -0
  32. {seqpro-0.22.0 → seqpro-0.23.0}/benchmarks/bench_ragged_backends.py +0 -0
  33. {seqpro-0.22.0 → seqpro-0.23.0}/benchmarks/bench_ragged_gather.py +0 -0
  34. {seqpro-0.22.0 → seqpro-0.23.0}/benchmarks/bench_to_packed.py +0 -0
  35. {seqpro-0.22.0 → seqpro-0.23.0}/crates/seqpro-core/Cargo.toml +0 -0
  36. {seqpro-0.22.0 → seqpro-0.23.0}/crates/seqpro-core/src/lib.rs +0 -0
  37. {seqpro-0.22.0 → seqpro-0.23.0}/crates/seqpro-core/src/ragged.rs +0 -0
  38. {seqpro-0.22.0 → seqpro-0.23.0}/docs/api/alphabets.md +0 -0
  39. {seqpro-0.22.0 → seqpro-0.23.0}/docs/api/bed.md +0 -0
  40. {seqpro-0.22.0 → seqpro-0.23.0}/docs/api/gtf.md +0 -0
  41. {seqpro-0.22.0 → seqpro-0.23.0}/docs/api/index.md +0 -0
  42. {seqpro-0.22.0 → seqpro-0.23.0}/docs/api/ragged.md +0 -0
  43. {seqpro-0.22.0 → seqpro-0.23.0}/docs/api/types.md +0 -0
  44. {seqpro-0.22.0 → seqpro-0.23.0}/docs/index.md +0 -0
  45. {seqpro-0.22.0 → seqpro-0.23.0}/docs/ragged.md +0 -0
  46. {seqpro-0.22.0 → seqpro-0.23.0}/docs/roadmap/rust-ragged.md +0 -0
  47. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-04-ragged-record-array.md +0 -0
  48. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-05-documentation-site.md +0 -0
  49. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-05-narwhals-coord-schema.md +0 -0
  50. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-05-ragged-zip-and-record-introspection.md +0 -0
  51. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-20-kshuffle-optimization.md +0 -0
  52. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-20-kshuffle-pooled-buffers-and-k2-fast-path.md +0 -0
  53. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-20-kshuffle-wilson-single-pass.md +0 -0
  54. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-20-release-pipeline.md +0 -0
  55. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-28-translate-lut-validation.md +0 -0
  56. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-31-flat-buffer-to-padded.md +0 -0
  57. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-05-31-rag-to-packed.md +0 -0
  58. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-05-translate-unknown-codon-policy.md +0 -0
  59. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-12-tokenize-lut-codspeed.md +0 -0
  60. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-18-rust-tokenize-translate.md +0 -0
  61. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-19-rust-ragged-core.md +0 -0
  62. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-20-rust-ragged-records.md +0 -0
  63. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-20-rust-ragged-spec-c.md +0 -0
  64. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-21-ragged-throughput-gate.md +0 -0
  65. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-21-rust-ragged-consumer-audit.md +0 -0
  66. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-23-ragged-subclass-getitem.md +0 -0
  67. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-24-ragged-getitem-fastpaths.md +0 -0
  68. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-06-25-ragged-string-hashing.md +0 -0
  69. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-07-21-ragged-gather-ok.md +0 -0
  70. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/plans/2026-07-27-ragged-string-getitem.md +0 -0
  71. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-04-ragged-record-array-design.md +0 -0
  72. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-05-documentation-site-design.md +0 -0
  73. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-05-narwhals-coord-schema-design.md +0 -0
  74. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-05-ragged-zip-and-record-introspection-design.md +0 -0
  75. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-20-kshuffle-optimization-design.md +0 -0
  76. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-20-kshuffle-pooled-buffers-and-k2-fast-path-design.md +0 -0
  77. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-20-kshuffle-wilson-single-pass-design.md +0 -0
  78. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-20-release-pipeline-design.md +0 -0
  79. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-28-translate-lut-and-validation-design.md +0 -0
  80. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-31-flat-buffer-to-padded-design.md +0 -0
  81. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-05-31-rag-to-packed-design.md +0 -0
  82. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-05-translate-unknown-codon-policy-design.md +0 -0
  83. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-12-tokenize-lut-codspeed-design.md +0 -0
  84. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-18-rust-tokenize-translate-design.md +0 -0
  85. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-19-rust-ragged-core-design.md +0 -0
  86. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-20-rust-ragged-nested-design.md +0 -0
  87. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-20-rust-ragged-records-design.md +0 -0
  88. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-21-ragged-throughput-gate-design.md +0 -0
  89. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-21-rust-ragged-audit-ledger.md +0 -0
  90. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-21-rust-ragged-consumer-audit-design.md +0 -0
  91. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-23-ragged-subclass-getitem-design.md +0 -0
  92. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-24-ragged-getitem-fastpaths-design.md +0 -0
  93. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-06-25-ragged-string-hashing-design.md +0 -0
  94. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-07-21-ragged-gather-ok-design.md +0 -0
  95. {seqpro-0.22.0 → seqpro-0.23.0}/docs/superpowers/specs/2026-07-27-ragged-string-getitem-design.md +0 -0
  96. {seqpro-0.22.0 → seqpro-0.23.0}/meta.yaml +0 -0
  97. {seqpro-0.22.0 → seqpro-0.23.0}/pixi.lock +0 -0
  98. {seqpro-0.22.0 → seqpro-0.23.0}/pixi.toml +0 -0
  99. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/__init__.py +0 -0
  100. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/_analyzers.py +0 -0
  101. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/_cleaners.py +0 -0
  102. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/_coords.py +0 -0
  103. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/_encoders.py +0 -0
  104. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/_modifiers.py +0 -0
  105. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/_numba.py +0 -0
  106. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/_types.py +0 -0
  107. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/_utils.py +0 -0
  108. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/alphabets/__init__.py +0 -0
  109. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/alphabets/_alphabets.py +0 -0
  110. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/bed.py +0 -0
  111. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/experimental/_experimental.py +0 -0
  112. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/experimental/_visualizers.py +0 -0
  113. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/gtf.py +0 -0
  114. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/py.typed +0 -0
  115. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/rag/__init__.py +0 -0
  116. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/rag/_ak_interop.py +0 -0
  117. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/rag/_layout.py +0 -0
  118. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/rag/_ops.py +0 -0
  119. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/rag/_types.py +0 -0
  120. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/rag/_utils.py +0 -0
  121. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/transforms/__init__.py +0 -0
  122. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/transforms/augmentation.py +0 -0
  123. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/transforms/tmm.py +0 -0
  124. {seqpro-0.22.0 → seqpro-0.23.0}/python/seqpro/xr/__init__.py +0 -0
  125. {seqpro-0.22.0 → seqpro-0.23.0}/scratch_bench_rc.py +0 -0
  126. {seqpro-0.22.0 → seqpro-0.23.0}/scratch_bench_to_padded.py +0 -0
  127. {seqpro-0.22.0 → seqpro-0.23.0}/src/hashing.rs +0 -0
  128. {seqpro-0.22.0 → seqpro-0.23.0}/src/kmer_encode.rs +0 -0
  129. {seqpro-0.22.0 → seqpro-0.23.0}/src/kshuffle.rs +0 -0
  130. {seqpro-0.22.0 → seqpro-0.23.0}/src/kshuffle_ref.rs +0 -0
  131. {seqpro-0.22.0 → seqpro-0.23.0}/src/lib.rs +0 -0
  132. {seqpro-0.22.0 → seqpro-0.23.0}/src/ragged.rs +0 -0
  133. {seqpro-0.22.0 → seqpro-0.23.0}/src/translate.rs +0 -0
  134. {seqpro-0.22.0 → seqpro-0.23.0}/tests/_shape_fixtures.py +0 -0
  135. {seqpro-0.22.0 → seqpro-0.23.0}/tests/bed/test_pyranges.py +0 -0
  136. {seqpro-0.22.0 → seqpro-0.23.0}/tests/bed/test_read.py +0 -0
  137. {seqpro-0.22.0 → seqpro-0.23.0}/tests/bed/test_sort.py +0 -0
  138. {seqpro-0.22.0 → seqpro-0.23.0}/tests/bed/test_with_length.py +0 -0
  139. {seqpro-0.22.0 → seqpro-0.23.0}/tests/bench_ragged_getitem.py +0 -0
  140. {seqpro-0.22.0 → seqpro-0.23.0}/tests/bench_translate_lut.py +0 -0
  141. {seqpro-0.22.0 → seqpro-0.23.0}/tests/conftest.py +0 -0
  142. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_analyzers.py +0 -0
  143. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_bench_tokenize.py +0 -0
  144. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_concatenate.py +0 -0
  145. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_coords.py +0 -0
  146. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_core_ragged_surface.py +0 -0
  147. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_encoders.py +0 -0
  148. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ingest.py +0 -0
  149. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_modifiers.py +0 -0
  150. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ohe.py +0 -0
  151. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_rag_to_packed.py +0 -0
  152. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged.py +0 -0
  153. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_hash.py +0 -0
  154. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_nested_consumers.py +0 -0
  155. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_rc.py +0 -0
  156. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_record_indexing.py +0 -0
  157. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_slice_fastpath.py +0 -0
  158. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_subclass.py +0 -0
  159. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_ragged_to_padded.py +0 -0
  160. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_shape_matrix.py +0 -0
  161. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_tokenize.py +0 -0
  162. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_transforms.py +0 -0
  163. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_translate.py +0 -0
  164. {seqpro-0.22.0 → seqpro-0.23.0}/tests/test_translate_rust.py +0 -0
  165. {seqpro-0.22.0 → seqpro-0.23.0}/zensical.toml +0 -0
@@ -1,3 +1,20 @@
1
+ ## 0.23.0 (2026-09-26)
2
+
3
+ ### BREAKING CHANGE
4
+
5
+ - rag[:, k] and similar tuple keys now follow NumPy and
6
+ awkward semantics; code relying on the old axis-0 behavior must index
7
+ rows directly (rag[k]).
8
+ - Ragged record to_ak() for arrays with one leading axis
9
+ now returns records at the leaf (e.g. 2 * var * {a, b}) instead of one
10
+ record of lists per row.
11
+
12
+ ### Fix
13
+
14
+ - **rag**: apply each tuple key to the next output axis
15
+ - **rag**: emit leaf-level records from to_ak() for every rag_dim
16
+ - **rag**: honor fixed axes between the outer axis and nested ragged axes
17
+
1
18
  ## 0.22.0 (2026-07-27)
2
19
 
3
20
  ### BREAKING CHANGE
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seqpro
3
- Version: 0.22.0
3
+ Version: 0.23.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: Implementation :: PyPy
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "seqpro"
3
- version = "0.22.0"
3
+ version = "0.23.0"
4
4
  authors = [
5
5
  { name = "David Laub", email = "dlaub@ucsd.edu" },
6
6
  { name = "Adam Klie", email = "aklie@ucsd.edu" },
@@ -82,8 +82,71 @@ def _peel_string_row(rl: "RaggedLayout[Any]", lo: int, hi: int) -> "RaggedLayout
82
82
  )
83
83
 
84
84
 
85
+ def _is_int(k: Any) -> bool:
86
+ return isinstance(k, (int, np.integer))
87
+
88
+
89
+ def _last_ragged_axis(shape: tuple[int | None, ...]) -> int:
90
+ return len(shape) - 1 - shape[::-1].index(None)
91
+
92
+
93
+ def _elem_index(starts: NDArray[Any], stops: NDArray[Any], k: int) -> NDArray[np.int64]:
94
+ """Flat index of element ``k`` (negative counts from the end) of every row."""
95
+ lens = stops - starts
96
+ if k >= 0:
97
+ bad, idx = lens <= k, starts + k
98
+ else:
99
+ bad, idx = lens < -k, stops + k
100
+ if np.any(bad):
101
+ raise IndexError(f"index {k} is out of range for some ragged rows")
102
+ return idx.astype(np.int64)
103
+
104
+
105
+ def _slice_bounds(
106
+ starts: NDArray[Any], stops: NDArray[Any], sl: slice
107
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
108
+ """Apply a step-1 slice to every row; bounds follow Python slice semantics."""
109
+ if sl.step not in (None, 1):
110
+ raise NotImplementedError("stepped slices on a ragged axis are unsupported")
111
+ lens = stops - starts
112
+
113
+ def resolve(v: Any, default: NDArray[Any]) -> NDArray[Any]:
114
+ if v is None:
115
+ return default
116
+ v = int(v)
117
+ return np.clip(lens + v if v < 0 else np.full_like(lens, v), 0, lens)
118
+
119
+ lo = resolve(sl.start, np.zeros_like(lens))
120
+ hi = np.maximum(resolve(sl.stop, lens), lo)
121
+ return (starts + lo).astype(OFFSET_TYPE), (starts + hi).astype(OFFSET_TYPE)
122
+
123
+
124
+ def _ranges(starts: NDArray[Any], counts: NDArray[Any]) -> NDArray[np.int64]:
125
+ """Concatenate ``range(s, s + c)`` for every ``(s, c)`` pair, vectorized."""
126
+ counts = counts.astype(np.int64)
127
+ ends = np.cumsum(counts)
128
+ shift = np.repeat(starts.astype(np.int64) - (ends - counts), counts)
129
+ return np.arange(int(ends[-1]) if len(ends) else 0, dtype=np.int64) + shift
130
+
131
+
132
+ def _index_output_axis(obj: Any, p: int, k: Any) -> Any:
133
+ """Index output axis ``p`` of any ``Ragged.__getitem__`` result with ``k``."""
134
+ if isinstance(obj, Ragged):
135
+ return obj._index_axis(p, k)
136
+ if isinstance(obj, dict):
137
+ return {name: _index_output_axis(v, p, k) for name, v in obj.items()}
138
+ if isinstance(obj, bytes) and p == 0:
139
+ return obj[k]
140
+ return np.asarray(obj)[(slice(None),) * p + (k,)]
141
+
142
+
85
143
  class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
86
- """A non-branching ragged array with a single ragged axis (Spec A)."""
144
+ """A non-branching ragged array with one ragged axis, or two adjacent ones.
145
+
146
+ Shape is ``(*leading_int, None x R, *trailing_int)`` with ``R`` in ``{1, 2}``.
147
+ Fixed leading axes may sit between the outermost axis and the ragged axes,
148
+ e.g. ``(batch, ploidy, ~variants, ~window)``.
149
+ """
87
150
 
88
151
  __slots__ = ("_layout",)
89
152
 
@@ -652,73 +715,46 @@ class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
652
715
  str_offsets=self._rl.str_offsets,
653
716
  )
654
717
  return Ragged(new_layout)
655
- if (
656
- isinstance(where, tuple)
657
- and len(where) == 2
658
- and not self._is_record
659
- and self._rl.n_ragged == 2
660
- and self._is_full_slice(where[0])
661
- ):
662
- return self._getitem_inner(where[1])
663
718
  if isinstance(where, tuple):
664
- # Multi-dim tuple: each key in the tuple targets successive leading axes.
665
- # We collapse all the integer-dim keys into one combined index so that
666
- # e.g. rag[:, [0]] correctly selects sample axis 1, not range axis 0.
667
- # Only meaningful when there are *multiple* leading int axes to combine
668
- # (rag_dim > 1). When rag_dim == 1 there is a single leading axis and a
669
- # tuple just indexes it then reaches into the ragged/trailing dims; the
670
- # combining path would mis-handle the trailing fixed dim (e.g. a
671
- # (d0, None, K) array padded by to_padded), so fall through to the
672
- # sequential per-key path below.
673
- if self._layout.shape.count(None) == 1 and self.rag_dim > 1:
674
- return self._getitem_tuple_multidim(where)
675
- # Sequential per-key path. np.newaxis (None) keys are handled here:
676
- # track their positions, strip them from the tuple, apply the remaining
677
- # keys, then insert size-1 axes at the correct positions in the result.
678
- has_none = any(k is None for k in where)
679
- if has_none:
680
- # Separate None positions from real indexing keys.
681
- real_keys: list[Any] = []
682
- none_positions: list[int] = []
683
- pos = 0 # tracks the output axis number as we accumulate
684
- for k in where:
685
- if k is None:
686
- none_positions.append(pos)
687
- pos += 1 # new axis consumes one output position
688
- else:
689
- real_keys.append(k)
690
- # An integer key collapses one axis (no output position);
691
- # a slice/array key keeps one axis.
692
- if not isinstance(k, (int, np.integer)):
693
- pos += 1
694
- result: Any = self
695
- for k in real_keys:
696
- result = result[k]
697
- # Insert size-1 leading axes at recorded positions.
698
- if isinstance(result, Ragged):
699
- for p in reversed(none_positions):
700
- cur_shape = result._layout.shape
701
- new_shape = cur_shape[:p] + (1,) + cur_shape[p:]
702
- result = Ragged(
703
- RaggedLayout(
704
- data=result._rl.data,
705
- offsets=list(result._layout.offsets),
706
- shape=new_shape,
707
- str_offsets=result._rl.str_offsets,
708
- )
709
- )
710
- else:
711
- # numpy array or scalar: use np.expand_dims
712
- for p in reversed(none_positions):
713
- result = np.expand_dims(result, p)
714
- return result
715
- result = self
719
+ # np.newaxis (None) keys: track their output positions, strip them,
720
+ # apply the remaining keys, then insert size-1 axes at those positions.
721
+ real_keys: list[Any] = []
722
+ none_positions: list[int] = []
723
+ pos = 0 # tracks the output axis number as we accumulate
716
724
  for k in where:
717
- result = result[k]
725
+ if k is None:
726
+ none_positions.append(pos)
727
+ pos += 1 # new axis consumes one output position
728
+ else:
729
+ real_keys.append(k)
730
+ # An integer key collapses one axis (no output position);
731
+ # a slice/array key keeps one axis.
732
+ if not _is_int(k):
733
+ pos += 1
734
+ result: Any = self._index_keys(tuple(real_keys))
735
+ # Insert size-1 leading axes at recorded positions.
736
+ if isinstance(result, Ragged):
737
+ for p in reversed(none_positions):
738
+ cur_shape = result._layout.shape
739
+ new_shape = cur_shape[:p] + (1,) + cur_shape[p:]
740
+ result = Ragged(
741
+ RaggedLayout(
742
+ data=result._rl.data,
743
+ offsets=list(result._layout.offsets),
744
+ shape=new_shape,
745
+ str_offsets=result._rl.str_offsets,
746
+ )
747
+ )
748
+ else:
749
+ # numpy array or scalar: use np.expand_dims
750
+ for p in reversed(none_positions):
751
+ result = np.expand_dims(result, p)
718
752
  return result
719
753
  if isinstance(self._layout, RecordLayout):
720
754
  return self._getitem_record(where)
721
755
  if self._layout.n_ragged == 2:
756
+ if self.rag_dim > 1:
757
+ return self._getitem_leading((where,))[0]
722
758
  return self._getitem_r2(where)
723
759
  # Multi-dim leading shape: when rag_dim > 1 (e.g. shape (d0, d1, ..., None))
724
760
  # index the first axis treating each "row" as a block of n_inner contiguous
@@ -847,25 +883,32 @@ class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
847
883
  )
848
884
  )
849
885
 
850
- def _getitem_tuple_multidim(self, where: tuple[Any, ...]) -> Any:
851
- """Handle a tuple index on a single-ragged Ragged (record or non-record) with rag_dim > 1.
886
+ def _getitem_leading(self, where: tuple[Any, ...]) -> tuple[Any, int]:
887
+ """Index the leading fixed axes of a Ragged (record or not) with rag_dim > 1.
888
+
889
+ Returns ``(result, n_kept)``: ``n_kept`` is how many output axes the keys
890
+ left in front of the first unindexed axis, i.e. where the next key goes.
852
891
 
853
892
  Each key in ``where`` targets successive leading integer axes (like NumPy
854
893
  multi-dim indexing). All leading-dim keys are resolved simultaneously into
855
894
  a single flat segment selection so that e.g. ``rag[:, [0]]`` correctly
856
895
  selects from axis 1 (samples), not axis 0 (ranges).
857
896
 
858
- Keys that reach the ragged ``None`` axis or beyond are not handled here
859
- (fall through to normal single-key dispatch after leading dims are consumed).
897
+ For R=2 the leading keys select outer (O0) segments; O1 stays global, the
898
+ same lazy gather as ``_getitem_r2``. When every leading axis of an R=2
899
+ array gets an int key, the one selected outer segment is peeled like
900
+ ``rag[i]`` on a ``rag_dim == 1`` array, so ``rag[i, j] == rag[i][j]``.
901
+ (R=1 keeps its 1-segment ``(None, ...)`` result.)
902
+
903
+ ``where`` must not be longer than ``rag_dim``; ``_index_keys`` applies any
904
+ keys past the leading axes.
860
905
  """
861
906
  rag_dim = self.rag_dim
862
907
  leading = self._layout.shape[:rag_dim] # e.g. (d0, d1, ..., dk)
863
908
  trailing = self._layout.shape[rag_dim:] # (None, *trailing_int)
864
909
 
865
- # Split tuple: leading-dim keys vs. keys that reach or exceed the ragged axis.
866
- n_leading_keys = min(len(where), rag_dim)
867
- leading_keys = where[:n_leading_keys]
868
- remainder_keys = where[n_leading_keys:]
910
+ n_leading_keys = len(where)
911
+ leading_keys = where
869
912
 
870
913
  # For each leading dim, resolve the key to an index array (or scalar).
871
914
  # We track whether each dim produces an int (scalar), a "slice" range, or a
@@ -973,14 +1016,17 @@ class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
973
1016
  grid_combined = grid_combined + grids[j] * strides[j]
974
1017
  flat_seg_idx = np.asarray(grid_combined.reshape(-1), dtype=np.int64)
975
1018
 
976
- # Gather segment starts/stops
977
- starts, stops = self._starts_stops()
1019
+ # Gather the outer segments' starts/stops; deeper levels stay global.
1020
+ outer, *inner_offsets = self._layout.offsets
1021
+ starts, stops = _level_bounds(outer)
978
1022
  new_starts = np.ascontiguousarray(starts[flat_seg_idx], dtype=OFFSET_TYPE)
979
1023
  new_stops = np.ascontiguousarray(stops[flat_seg_idx], dtype=OFFSET_TYPE)
980
- new_offsets = np.stack([new_starts, new_stops], 0)
1024
+ new_offsets = [np.stack([new_starts, new_stops], 0), *inner_offsets]
981
1025
 
982
- # Build new shape: scalar axes squeezed, array-selected axes kept
983
- new_leading: tuple[int | None, ...] = tuple(out_leading)
1026
+ # Build new shape: scalar axes squeezed, array-selected axes kept. An R=2
1027
+ # array with every leading axis squeezed keeps a size-1 axis to peel below.
1028
+ all_int = not out_leading and len(new_offsets) == 2
1029
+ new_leading: tuple[int | None, ...] = (1,) if all_int else tuple(out_leading)
984
1030
  new_shape: tuple[int | None, ...] = (*new_leading, *trailing)
985
1031
 
986
1032
  # Build result: record Ragged or non-record Ragged depending on layout type
@@ -989,7 +1035,7 @@ class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
989
1035
  new_fields = {
990
1036
  name: RaggedLayout(
991
1037
  data=fl.data,
992
- offsets=[new_offsets],
1038
+ offsets=new_offsets,
993
1039
  shape=(
994
1040
  *new_leading,
995
1041
  *fl.shape[fl.shape.index(None) :],
@@ -1000,7 +1046,7 @@ class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
1000
1046
  }
1001
1047
  result: Any = Ragged(
1002
1048
  RecordLayout(
1003
- offsets=[new_offsets],
1049
+ offsets=new_offsets,
1004
1050
  shape=new_shape,
1005
1051
  fields=new_fields,
1006
1052
  )
@@ -1009,18 +1055,183 @@ class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
1009
1055
  result = Ragged(
1010
1056
  RaggedLayout(
1011
1057
  data=self._rl.data,
1012
- offsets=[new_offsets],
1058
+ offsets=new_offsets,
1013
1059
  shape=new_shape,
1014
1060
  str_offsets=self._rl.str_offsets,
1015
1061
  )
1016
1062
  )
1063
+ if all_int:
1064
+ return result[0], 0
1065
+ return result, len(new_leading)
1017
1066
 
1018
- # Apply any remainder keys (reaching into or past the ragged axis)
1019
- for k in remainder_keys:
1020
- result = result[k]
1067
+ def _index_keys(self, keys: tuple[Any, ...]) -> Any:
1068
+ """Apply a tuple of (non-newaxis) keys, each to the next output axis.
1021
1069
 
1070
+ A key after a non-int key must not go to axis 0 of the intermediate
1071
+ result (issue #74): track the output axis ``p`` it targets instead.
1072
+ """
1073
+ if not keys:
1074
+ return self
1075
+ if None in self._layout.shape and self.rag_dim > 1:
1076
+ n_lead = min(len(keys), self.rag_dim)
1077
+ result, p = self._getitem_leading(keys[:n_lead])
1078
+ rest = keys[n_lead:]
1079
+ else:
1080
+ result, p, rest = self, 0, keys
1081
+
1082
+ def is_fancy(k: Any) -> bool:
1083
+ return not _is_int(k) and not isinstance(k, slice)
1084
+
1085
+ if any(map(is_fancy, rest)) and sum(map(is_fancy, keys)) > 1:
1086
+ # Leading fancy keys broadcast jointly in _getitem_leading; fancy keys
1087
+ # applied one axis at a time would not follow NumPy's broadcasting.
1088
+ raise NotImplementedError(
1089
+ "multiple fancy keys are only supported on leading fixed axes"
1090
+ )
1091
+ for k in rest:
1092
+ result = _index_output_axis(result, p, k)
1093
+ if not _is_int(k):
1094
+ p += 1
1022
1095
  return result
1023
1096
 
1097
+ def _index_axis(self, p: int, k: Any) -> Any:
1098
+ """Index output axis ``p`` with one key, leaving every other axis intact."""
1099
+ shape = self._layout.shape
1100
+ if None not in shape: # flat opaque-string collection
1101
+ if p == 0:
1102
+ return self._getitem(k)
1103
+ raise NotImplementedError("indexing into opaque strings is unsupported")
1104
+ rag_dim = self.rag_dim
1105
+ if p < rag_dim:
1106
+ if p == 0:
1107
+ return self._getitem(k)
1108
+ return self._getitem_leading((*(slice(None),) * p, k))[0]
1109
+ n_rag = shape.count(None)
1110
+ if p < rag_dim + n_rag:
1111
+ return self._index_level(p - rag_dim, k)
1112
+ if p < len(shape):
1113
+ return self._index_trailing(p, k)
1114
+ if self._is_record or self._rl.is_string:
1115
+ raise NotImplementedError("indexing into opaque strings is unsupported")
1116
+ raise IndexError(f"too many indices for a Ragged of shape {shape}")
1117
+
1118
+ def _index_level(self, j: int, k: Any) -> Any:
1119
+ """Index ragged axis ``rag_dim + j``, i.e. offsets level ``j``, with ``k``."""
1120
+ layout = self._layout
1121
+ offs = list(layout.offsets)
1122
+ starts, stops = _level_bounds(offs[j])
1123
+ if isinstance(k, slice):
1124
+ offs[j] = np.stack(_slice_bounds(starts, stops, k), 0)
1125
+ return self._rebuild(offs, layout.shape)
1126
+ if not _is_int(k):
1127
+ if len(offs) == 2 and j == 0 and not self._is_record:
1128
+ return self._getitem_inner_gather(k)
1129
+ raise NotImplementedError(
1130
+ "fancy or boolean keys on a ragged axis are unsupported"
1131
+ )
1132
+ k = int(k)
1133
+ axis = self.rag_dim + j
1134
+ new_shape = layout.shape[:axis] + layout.shape[axis + 1 :]
1135
+ if j < len(offs) - 1:
1136
+ # Pick one sub-list per row: gather the next level's bounds.
1137
+ idx = _elem_index(starts, stops, k)
1138
+ nxt_starts, nxt_stops = _level_bounds(offs[j + 1])
1139
+ picked = np.stack([nxt_starts[idx], nxt_stops[idx]], 0)
1140
+ return self._rebuild([*offs[:j], picked, *offs[j + 2 :]], new_shape)
1141
+ if j == 0:
1142
+ # Innermost level is the only one: one element per leading row.
1143
+ return self._take(_elem_index(starts, stops, k), [], new_shape)
1144
+ # Innermost of two levels: one element per middle row that O0 references.
1145
+ # Gathering only those rows repacks O0 and skips unreferenced rows.
1146
+ o0_starts, o0_stops = _level_bounds(offs[0])
1147
+ counts = o0_stops - o0_starts
1148
+ rows = _ranges(o0_starts, counts)
1149
+ idx = _elem_index(starts[rows], stops[rows], k)
1150
+ return self._take(idx, [lengths_to_offsets(counts)], new_shape)
1151
+
1152
+ def _index_trailing(self, p: int, k: Any) -> "Ragged[Any]":
1153
+ """Index a trailing fixed axis: a plain index on the data buffer."""
1154
+ if self._is_record:
1155
+ raise NotImplementedError(
1156
+ "indexing a trailing axis of a record Ragged is unsupported; "
1157
+ "index a field instead"
1158
+ )
1159
+ rl = self._rl
1160
+ last = _last_ragged_axis(rl.shape)
1161
+ new_data = rl.data[(*(slice(None),) * (p - last), k)]
1162
+ return Ragged(
1163
+ RaggedLayout(
1164
+ data=new_data,
1165
+ offsets=list(rl.offsets),
1166
+ shape=(*rl.shape[: last + 1], *new_data.shape[1:]),
1167
+ str_offsets=rl.str_offsets,
1168
+ )
1169
+ )
1170
+
1171
+ def _leaves(self) -> "dict[str, RaggedLayout[Any]] | None":
1172
+ """Record fields, or None for a single leaf."""
1173
+ if isinstance(self._layout, RecordLayout):
1174
+ return self._layout.fields
1175
+ return None
1176
+
1177
+ def _rebuild(
1178
+ self, offsets: list[NDArray[Any]], shape: tuple[int | None, ...]
1179
+ ) -> "Ragged[Any]":
1180
+ """Same data, new offsets and shape (fields keep their own trailing dims)."""
1181
+ prefix = shape[: _last_ragged_axis(shape) + 1]
1182
+ fields = self._leaves()
1183
+ if fields is None:
1184
+ rl = self._rl
1185
+ return Ragged(RaggedLayout(rl.data, offsets, shape, rl.str_offsets))
1186
+ new_fields = {
1187
+ name: RaggedLayout(
1188
+ fl.data,
1189
+ offsets,
1190
+ (*prefix, *fl.shape[_last_ragged_axis(fl.shape) + 1 :]),
1191
+ fl.str_offsets,
1192
+ )
1193
+ for name, fl in fields.items()
1194
+ }
1195
+ return Ragged(RecordLayout(offsets, shape, new_fields))
1196
+
1197
+ def _take(
1198
+ self,
1199
+ idx: NDArray[np.int64],
1200
+ offsets: list[NDArray[Any]],
1201
+ shape: tuple[int | None, ...],
1202
+ ) -> Any:
1203
+ """Gather elements ``idx`` of every leaf under new ``offsets``/``shape``.
1204
+
1205
+ ``shape`` is the result shape including the record's (first field's)
1206
+ trailing dims; with no ``offsets`` left the result is dense (a dict of
1207
+ per-field results for a record), or a flat opaque-string collection.
1208
+ """
1209
+ n_trailing = len(self._layout.shape) - 1 - _last_ragged_axis(self._layout.shape)
1210
+ prefix = shape[: len(shape) - n_trailing]
1211
+
1212
+ def leaf(fl: "RaggedLayout[Any]") -> Any:
1213
+ if fl.str_offsets is not None:
1214
+ str_starts, str_stops = _level_bounds(fl.str_offsets)
1215
+ str_offsets = np.stack([str_starts[idx], str_stops[idx]], 0)
1216
+ return Ragged(RaggedLayout(fl.data, offsets, prefix, str_offsets))
1217
+ data = fl.data[idx]
1218
+ if not offsets:
1219
+ dense = data.reshape(
1220
+ (*cast("tuple[int, ...]", prefix), *data.shape[1:])
1221
+ )
1222
+ return dense[()] # a NumPy scalar when every axis was indexed
1223
+ return Ragged(RaggedLayout(data, offsets, (*prefix, *data.shape[1:])))
1224
+
1225
+ fields = self._leaves()
1226
+ if fields is None:
1227
+ return leaf(self._rl)
1228
+ out = {name: leaf(fl) for name, fl in fields.items()}
1229
+ if not offsets:
1230
+ return out
1231
+ return Ragged(
1232
+ RecordLayout(offsets, shape, {name: r._layout for name, r in out.items()})
1233
+ )
1234
+
1024
1235
  def _getitem_record(self, where: Any) -> Any:
1025
1236
  rec = self._layout
1026
1237
  assert isinstance(rec, RecordLayout)
@@ -1031,16 +1242,10 @@ class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
1031
1242
  raise KeyError(where)
1032
1243
  return Ragged(field) # field.offsets[0] is the shared object (zero-copy)
1033
1244
  # numpy contract: a non-tuple key is treated as a 1-tuple (A[x] == A[(x,)]).
1034
- # For a single-ragged-axis record with >1 leading fixed axis, route through
1035
- # the multidim peel path directly (under the SAME guard it requires, so we
1036
- # never re-dispatch through __getitem__ and risk recursion). Records that are
1037
- # not single-None fall through to the flat record-rows path unchanged.
1038
- if (
1039
- not isinstance(where, tuple)
1040
- and self.rag_dim > 1
1041
- and rec.shape.count(None) == 1
1042
- ):
1043
- return self._getitem_tuple_multidim((where,))
1245
+ # A record with >1 leading fixed axis routes through the multidim path
1246
+ # directly (never re-dispatching through __getitem__, so no recursion).
1247
+ if not isinstance(where, tuple) and self.rag_dim > 1:
1248
+ return self._getitem_leading((where,))[0]
1044
1249
  return self._getitem_record_rows(where)
1045
1250
 
1046
1251
  def _gather_indices(
@@ -1107,58 +1312,12 @@ class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
1107
1312
  as contiguous OFFSET_TYPE arrays for the shared ragged axis."""
1108
1313
  return self._gather_indices(where, *self._starts_stops())
1109
1314
 
1110
- @staticmethod
1111
- def _is_full_slice(k: Any) -> bool:
1112
- return isinstance(k, slice) and k == slice(None)
1113
-
1114
- def _getitem_inner(self, sel: Any) -> "Ragged[Any]":
1115
- o0, o1 = self._layout.offsets
1116
- o0_starts, o0_stops = _level_bounds(o0)
1117
- o1_starts, o1_stops = _level_bounds(o1)
1118
- if isinstance(sel, (int, np.integer)): # k-th middle of each group -> R=1
1119
- counts = o0_stops - o0_starts
1120
- if np.any(sel >= counts) or np.any(-sel > counts):
1121
- raise IndexError(f"inner index {sel} out of range for some group")
1122
- mid_idx = (o0_starts + (sel if sel >= 0 else counts + sel)).astype(np.int64)
1123
- ds = np.ascontiguousarray(o1_starts[mid_idx], dtype=OFFSET_TYPE)
1124
- de = np.ascontiguousarray(o1_stops[mid_idx], dtype=OFFSET_TYPE)
1125
- trailing = self._layout.shape[self.rag_dim + 2 :]
1126
- return Ragged(
1127
- RaggedLayout(
1128
- data=self._rl.data,
1129
- offsets=[np.stack([ds, de], 0)],
1130
- shape=(len(mid_idx), None, *trailing),
1131
- )
1132
- )
1133
- if isinstance(sel, slice): # local per-group slice -> R=2
1134
- start, stop, step = sel.indices(1 << 62)
1135
- if step != 1:
1136
- raise NotImplementedError("step != 1 inner slices are unsupported")
1137
- if (sel.start is not None and sel.start < 0) or (
1138
- sel.stop is not None and sel.stop < 0
1139
- ):
1140
- raise NotImplementedError(
1141
- "negative inner-slice bounds (rag[:, -k:]) are unsupported"
1142
- )
1143
- new_starts = np.minimum(o0_starts + start, o0_stops)
1144
- new_stops = np.maximum(np.minimum(o0_starts + stop, o0_stops), new_starts)
1145
- new_o0 = np.stack(
1146
- [new_starts.astype(OFFSET_TYPE), new_stops.astype(OFFSET_TYPE)], 0
1147
- )
1148
- trailing = self._layout.shape[self.rag_dim + 2 :]
1149
- return Ragged(
1150
- RaggedLayout(
1151
- data=self._rl.data,
1152
- offsets=[new_o0, o1],
1153
- shape=(len(new_starts), None, None, *trailing),
1154
- )
1155
- )
1156
- return self._getitem_inner_gather(sel) # mask / int-array -> Task 8
1157
-
1158
1315
  def _getitem_inner_gather(self, sel: Any) -> "Ragged[Any]":
1316
+ """Mask or int-array key on the first of two ragged axes."""
1159
1317
  o0, o1 = self._layout.offsets
1160
1318
  o0_starts, o0_stops = _level_bounds(o0)
1161
1319
  o1_starts, o1_stops = _level_bounds(o1)
1320
+ leading = self._layout.shape[: self.rag_dim]
1162
1321
  trailing = self._layout.shape[self.rag_dim + 2 :]
1163
1322
  if _where_is_bool(sel): # mask over the global middle axis -> R=2
1164
1323
  from seqpro.seqpro import _ragged_nested_gather # type: ignore[missing-import]
@@ -1175,23 +1334,23 @@ class Ragged(NDArrayOperatorsMixin, Generic[RDTYPE_co]):
1175
1334
  RaggedLayout(
1176
1335
  data=self._rl.data,
1177
1336
  offsets=[new_o0, np.stack([ds, de], 0)],
1178
- shape=(len(o0_starts), None, None, *trailing),
1337
+ shape=(*leading, None, None, *trailing),
1179
1338
  )
1180
1339
  )
1181
- idx = np.atleast_1d(
1182
- np.asarray(sel, dtype=np.int64)
1183
- ) # uniform per-group int array
1184
- counts = o0_stops - o0_starts
1185
- if np.any(idx.max() >= counts) or np.any(idx.min() < -counts.min()):
1340
+ # uniform per-group int array: middle (g, i) = element idx[i] of group g
1341
+ idx = np.atleast_1d(np.asarray(sel, dtype=np.int64))
1342
+ counts = (o0_stops - o0_starts)[:, None]
1343
+ if np.any(idx >= counts) or np.any(-idx > counts):
1186
1344
  raise IndexError("uniform inner index out of range for some group")
1187
- cols = [self._getitem_inner(int(k)) for k in idx] # each is (L0, ~K) R=1
1188
- ds = np.stack([c._layout.offsets[0][0] for c in cols], 1).reshape(-1)
1189
- de = np.stack([c._layout.offsets[0][1] for c in cols], 1).reshape(-1)
1345
+ mid = np.where(idx >= 0, o0_starts[:, None], o0_stops[:, None]) + idx
1346
+ mid = mid.reshape(-1)
1347
+ ds = np.ascontiguousarray(o1_starts[mid], dtype=OFFSET_TYPE)
1348
+ de = np.ascontiguousarray(o1_stops[mid], dtype=OFFSET_TYPE)
1190
1349
  return Ragged(
1191
1350
  RaggedLayout(
1192
1351
  data=self._rl.data,
1193
1352
  offsets=[np.stack([ds, de], 0)],
1194
- shape=(len(counts), len(idx), None, *trailing),
1353
+ shape=(*leading, len(idx), None, *trailing),
1195
1354
  )
1196
1355
  )
1197
1356
 
@@ -155,18 +155,10 @@ def to_ak(rag: Any) -> ak.Array:
155
155
 
156
156
  if isinstance(rag._layout, RecordLayout):
157
157
  rec = rag._layout
158
- # Fast path: shape has only one leading fixed dim (e.g. (L0, None) or
159
- # (L0, None, None)). In this case per-field to_ak() + ak.zip works because all
160
- # per-field arrays have the same outermost length and no extra RegularArray wrappers.
161
- rag_dim = rec.shape.index(None)
162
- if rag_dim <= 1:
163
- return ak.zip({f: to_ak(rag[f]) for f in rag.fields}, depth_limit=1)
164
-
165
- # Multi-leading-axis path: shape like (b, p, ~v) where rag_dim >= 2 and there is
166
- # exactly one ragged axis. Per-field to_ak() produces incompatible outer shapes:
167
- # - numeric fields get RegularArray wrappers from _parts_to_content
168
- # - string-under-axis fields do NOT (string path only wraps one level)
169
- # Fix: build leaf contents from layout buffers and re-wrap uniformly.
158
+ # Records always sit at the leaf (the ak.zip default), whatever rag_dim is
159
+ # (#75). Per-field to_ak() + ak.zip can't do this in general: numeric
160
+ # fields get RegularArray wrappers but string-under-axis fields don't.
161
+ # So build leaf contents from the layout buffers and re-wrap uniformly.
170
162
  field_leaves: list[Any] = []
171
163
  field_names: list[str] = []
172
164
  for name, fl in rec.fields.items():
@@ -198,12 +190,15 @@ def to_ak(rag: Any) -> ak.Array:
198
190
 
199
191
  content: Any = RecordArray(field_leaves, field_names)
200
192
 
201
- # Re-wrap by iterating reversed(shape[1:]), applying ListOffsetArray for None dims
202
- # (innermost ragged first) and RegularArray for fixed dims. Shared offsets are
203
- # consumed innermost-to-outermost so that the outer list wraps the inner list.
204
- # The outermost dim (shape[0]) is implicit in the final array length.
193
+ # Re-wrap by iterating reversed(shape[1 : last ragged axis + 1]), applying
194
+ # ListOffsetArray for None dims (innermost ragged first) and RegularArray for
195
+ # fixed leading dims. Shared offsets are consumed innermost-to-outermost so
196
+ # that the outer list wraps the inner list. Trailing fixed dims already live
197
+ # in each field's leaf, and the outermost dim (shape[0]) is implicit in the
198
+ # final array length.
199
+ last_rag = len(rec.shape) - 1 - rec.shape[::-1].index(None)
205
200
  off_iter = iter(reversed(rec.offsets))
206
- for size in reversed(rec.shape[1:]):
201
+ for size in reversed(rec.shape[1 : last_rag + 1]):
207
202
  if size is None:
208
203
  off = next(off_iter)
209
204
  content = _wrap_list(off, content)
@@ -223,7 +218,12 @@ def to_ak(rag: Any) -> ak.Array:
223
218
  )
224
219
  else:
225
220
  leaf = NumpyArray(np.ascontiguousarray(data)) # type: ignore[arg-type]
226
- return ak.Array(_wrap_list(o0, _wrap_list(o1, leaf)))
221
+ content = _wrap_list(o0, _wrap_list(o1, leaf))
222
+ # Fixed dims between the outer axis and the first ragged axis (e.g. ploidy
223
+ # in (b, p, ~v, ~w)) partition O0's segments into regular groups.
224
+ for size in reversed(rag._layout.shape[1 : rag.rag_dim]):
225
+ content = RegularArray(content, size)
226
+ return ak.Array(content)
227
227
 
228
228
  # --- string-under-axis path (shape (L0, None), is_string=True) ---
229
229
  if rag._rl.str_offsets is not None and rag._layout.offsets: