Skip to content

Validate get_subset_from_range against coordinates, not variant count - #178

Merged
andrewkern merged 1 commit into
mainfrom
fix/172-subset-from-range-coordinates
Aug 12, 2026
Merged

Validate get_subset_from_range against coordinates, not variant count#178
andrewkern merged 1 commit into
mainfrom
fix/172-subset-from-range-coordinates

Conversation

@andrewkern

Copy link
Copy Markdown
Member

Fixes #172.

low and high are genomic coordinates. The guard compared high to positions.size,
which is the variant count:

if low < 0 or high > self.positions.size or low >= high:

Everything else in the method treats them as coordinates: it filters self.positions,
calls accessible_mask.slice(low, high), and stores them as chrom_start/chrom_end.
The sibling get_subset validates against haplotypes.shape[1] correctly, because its
argument really is indices. This check looks inherited from there. It has been in the
file since the first commit.

What it broke

Almost every real range was refused. Only ranges inside [0, n_variants) passed. A
matrix of 100 variants over 1 Mb rejected the window [200000, 300000) with
ValueError: Invalid range specified. WindowedAnalyzer.compute_region
(windowed_analysis.py:631) is the only caller and failed the same way.

A range with no variants crashed. A range that passed the guard but held no variant
reached the constructor, which rejects zero-size arrays, and raised
ValueError: genotypes cannot be empty. get_subset has an empty-result path; this
method had none, so the guard was hiding the gap.

The fix

Keep low < 0 and low >= high, and drop the third check. A range that reaches past
the last variant is a valid query -- it returns the variants that do fall inside it.
Validating against chrom_start/chrom_end would recreate the same problem for windows
at the edge of a chromosome.

Two further faults in the same method:

  • chrom_end was set to high, which is exclusive. chrom_end is inclusive: the
    constructor counts accessible bases up to chrom_end + 1, from_ts stores
    sequence_length - 1 with a comment saying so, and _iter_bp_windows writes
    end - 1. The subset claimed one base more than it covered.
  • The samples list was dropped, so load_pop_file did not work on the result.

Two cleanups that the fix needs:

  • _empty_subset now holds the empty-result builder that only get_subset had. It also
    sets fields, which the old code left unset -- reading .fields on an empty subset
    raised AttributeError. That object is reachable today: test_windowed_analysis.py
    builds one, and every variant-free window produces one.
  • _accessible_bases_in_range replaces three copies of the same accessible-base count.

Outside the method

An empty region matrix now reaches WindowIterator, which read positions_np[0] with no
guard. _iter_bp_windows and count_windows return nothing for a matrix with no
variants. Without this, compute_region would trade one error for another. Drop this
part if you would rather keep the PR inside haplotype_matrix.py.

Tests

get_subset_from_range had two tests and both were written around the bug.
test_accessible_mask.py:370 says so outright: "get_subset_from_range checks
high <= positions.size, so use a range within the number of positions"
.
test_haplotype_matrix.py:221 passes only because that fixture's 10th position is below
its variant count. Both still pass unchanged.

New tests cover a coordinate range far past the variant count, the inclusive chrom_end,
the kept sample names, an empty window, a range past the last variant, malformed ranges,
and fields on an empty subset. compute_region gets its first two tests.

pixi run pytest tests/ -q -n 10          # 886 passed, 64 skipped
pixi run ruff check pg_gpu/ tests/ examples/   # clean

Not fixed here

_iter_bp_windows anchors windows at positions[0] and ignores chrom_start. After
this fix, compute_region(hm, "1", 200000, 300000) returns windows that start at the
first variant in the region and can end past 300000, so two neighbouring region calls
give windows that do not line up. That is a decision about what compute_region
promises, so it belongs in its own issue.

low and high are genomic coordinates. The guard compared high to
positions.size, which is the number of variants, so it rejected any range
whose end coordinate was larger than the variant count. That is nearly every
real range: a matrix of 100 variants over 1 Mb refused the window
[200000, 300000). WindowedAnalyzer.compute_region is the only caller and
failed the same way.

The guard also hid a second fault in the other direction. A range that passed
it but held no variant reached the constructor, which rejects zero-size
arrays, and raised "genotypes cannot be empty". get_subset already had an
empty-result path; this method had none.

Keep the low < 0 and low >= high checks and drop the third. A range that
reaches past the last variant is a valid query and returns the variants that
do fall inside it.

Two more faults in the same method:

* chrom_end was set to high, which is exclusive. chrom_end is inclusive: the
  constructor counts accessible bases up to chrom_end + 1, from_ts stores
  sequence_length - 1, and the bp window iterator writes end - 1. The subset
  claimed one base more than it covered.

* The sample names were dropped, so load_pop_file did not work on the result.

Move the empty-result builder out of get_subset into _empty_subset, which
both methods now call. It also sets fields, which the old code left unset --
reading .fields on an empty subset raised AttributeError.

Fold the three copies of the accessible-base count into
_accessible_bases_in_range.

An empty region matrix now reaches the window iterator, which read
positions[0] without a guard. Return no windows instead.
@mufernando

Copy link
Copy Markdown
Member

I was about to add that the subset was dropping the acc mask, but it seems this PR is fixing that as well.

@andrewkern
andrewkern requested a review from mufernando August 10, 2026 21:27
@andrewkern

Copy link
Copy Markdown
Member Author

@mufernando -- would you review this?

@mufernando mufernando left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks good to me. I tested on my use case and it works now.

I just left a question about whether we need to warn the user that the high is greater than the accessible mask they already have. the current code does the right thing and clips the high anyway.

# only bounds that mean anything are that the range starts at or after
# zero and is not empty. A range that reaches past the last variant is
# a valid query: it returns the variants that do fall inside it.
if low < 0 or low >= high:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we check high is not greater than the size of the accessible mask array?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the current code does the right thing if that's the case, because accessiblemask.slice get's the min out of the high or len of the array.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good question -- i don't think we need the extra check. slice and count_accessible both clip, and bases past the mask end are just inaccessible, which is the answer we want

@andrewkern

Copy link
Copy Markdown
Member Author

i'm gonna merge this

@andrewkern
andrewkern merged commit d092574 into main Aug 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug in hm.get_subset_from_range

2 participants