Skip to content

feat(dataclasses)!: immutable Interval and one strand rule for resizing - #13

Open
Hoeze wants to merge 13 commits into
feat/one-hot-case-insensitivefrom
feat/interval-resize-rule
Open

Hoeze wants to merge 13 commits into
feat/one-hot-case-insensitivefrom
feat/interval-resize-rule

Conversation

@Hoeze

@Hoeze Hoeze commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

tl;dr: Interval is an immutable, validated value object with fewer members, and all interval resizing follows one strand rule. Interval.resize(width, *, anchor, use_strand) replaces resize_interval, and pad, trim and fixed_len round like it.

Interval is a frozen dataclass, and its constructor checks the fields once.

  • A start or end that is not an int raises a TypeError. start > end and a strand other than "+", "-" and "." raise a ValueError.
  • strand is keyword-only. Before, Interval("chr1", 0, 10, "-") set the name to "-" and kept the strand ".".
  • name and score are gone. Keep them in the columns of a polars frame instead.
  • The methods return new intervals, so the constructor checks their results too.
  • Two intervals are equal if all their fields are equal.
  • width is a property. The aliases chr and stop, __len__ and copy are gone.
  • from_str parses negative coordinates and chromosome names with colons.
  • unstrand, swap_strand, neg_strand and is_valid are gone. Each is one expression on the fields, listed in MIGRATING.md.
  • truncate(chrom_len) requires chrom_len, and raises a TypeError if it is not an int. The default math.inf forced an int cast on the end.

A center in the middle of a base is rounded towards the 3' end: up on "+" and "." intervals, down on "-" intervals. This is what Interval.resize already did.

  • Interval.resize(width, *, anchor="center", use_strand=True) keeps the start, center or end. "start" and "end" follow the strand: on a "-" interval, "start" keeps the genomic end. A negative width raises a ValueError.
  • resize_interval is gone.
  • Interval.center(use_strand=False) returns the center of the unstranded interval.
  • Interval.slop(use_strand=True) swaps upstream and downstream on "-" intervals.
  • Interval.trim(i, j) becomes Interval.slice(i, j), because trim of kipoiseq2.transforms trims a sequence to a length. It raises a ValueError unless 0 <= i < j <= width, instead of an AssertionError or a longer interval.
  • The options of the methods are keyword-only, and shift(x) becomes shift(offset).
  • pad, trim and fixed_len keep the letters that Interval.resize keeps of an interval from 0 to len(seq). They take only a str, anchor and value only as keywords, and raise a TypeError or ValueError instead of an AssertionError. trim(seq, 0, anchor="end") returns "" instead of seq.

Verified against the previous implementation:

  • Interval.resize, shift, slop and slice (trim before) give the same coordinates on 396 intervals of all strands, with many widths and offsets.
  • center(use_strand=False) moves up by 1 exactly when start + end is odd.
  • pad, trim and fixed_len with anchor="center" change in 56 of 225 combinations of lengths 1 to 15. With "start" and "end", only trim(seq, 0, anchor="end") changes.

BREAKING CHANGE: Interval is immutable and takes strand only as a keyword. It raises a TypeError or ValueError for invalid fields, and equals only intervals with equal fields. Interval.name and Interval.score are removed: keep them in the columns of a polars frame. Interval.unstrand, swap_strand, neg_strand and is_valid are removed. Interval.truncate requires an int chrom_len. Use interval.width instead of width() and len(), chrom instead of chr, end instead of stop, and dataclasses.replace instead of copy. resize_interval is removed: use interval.resize(width, anchor=anchor, use_strand=False) for "start" and "end", and interval.resize(width) for "center", whose result moves by one base on "+" and "." intervals if the widths differ in parity. Interval.trim is renamed to Interval.slice. The options of the Interval methods are keyword-only. Interval.center(use_strand=False) rounds up instead of down. Interval.slop moves end by upstream on "-" intervals. pad, trim and fixed_len take only strings and take anchor and value only as keywords, and with anchor="center" their result can move by one letter.

feat(dataclasses)!: validate the fields of Interval
feat(dataclasses)!: resize at an anchor and drop resize_interval
feat(dataclasses)!: rename Interval.trim to slice and check its bounds
feat(dataclasses)!: take the options of Interval methods as keywords
feat(transforms)!: pad and trim with the center rule of Interval.resize
feat(dataclasses)!: drop Interval.name and score
feat(dataclasses)!: drop unstrand, swap_strand, neg_strand and is_valid
feat(dataclasses)!: require an int chrom_len in Interval.truncate

@Hoeze
Hoeze added this pull request to stack #16 October 15, 2026 22:32
@Hoeze
Hoeze force-pushed the feat/interval-resize-rule branch 2 times, most recently from 57cedc0 to a418804 Compare October 16, 2026 19:49
@Hoeze
Hoeze force-pushed the feat/interval-resize-rule branch from a418804 to 746d11e Compare October 16, 2026 23:51
@Hoeze Hoeze changed the title feat(dataclasses)!: resize, center and slop with one strand rule feat(dataclasses)!: immutable Interval and one strand rule for resizing Oct 16, 2026
@Hoeze
Hoeze force-pushed the feat/interval-resize-rule branch 2 times, most recently from 05a3a49 to ce18d4f Compare October 17, 2026 16:22
Hoeze added 13 commits October 17, 2026 18:36
… rule

All interval resizing now follows Interval.resize: a center in the middle
of a base is rounded towards the 3' end, up on "+" and "." intervals and
down on "-" intervals.

- resize_interval with anchor "center", and so ResizeInterval, returns
  Interval.resize. Anchors "start" and "end" keep the genomic start and end.
- Interval.center(use_strand=False) returns the center of the unstranded
  interval.
- Interval.slop(use_strand=True) swaps upstream and downstream on "-"
  intervals. resize_interval passes use_strand=False.
- Interval.trim and Interval.truncate always return a copy.

BREAKING CHANGE: Interval.center(use_strand=False) rounds up instead of down, so it grows by 1 for intervals with an odd start + end. resize_interval with anchor "center" and ResizeInterval give the result of Interval.resize: on "+" and "." intervals, the result moves by one base if the interval width and the new width differ in parity. Interval.slop moves end by upstream and start by downstream on "-" intervals; pass use_strand=False for the old behavior. Interval.trim and Interval.truncate return a new interval also if nothing changes.
ValueError is a subclass of Exception, so code that catches Exception still works.
Interval validated nothing and took name and score positionally, so
Interval("chr1", 0, 10, "-") set name="-" and kept the strand ".".
strand="x" and start > end passed, and len() raised later. Its fields
and attrs could change after it served as a dict key.

Interval is now a frozen dataclass:
- strand, name, score and attrs are keyword-only.
- The constructor raises TypeError if chrom, strand or name is not a
  str, if start or end is not an int, if score is not a number, or if
  attrs is not a mapping. It raises ValueError if start > end, if
  strand is not "+", "-" or ".", or if score is NaN. The methods build
  their results with the constructor, so they raise the same errors.
- attrs is a read-only copy of the given mapping.
- Two intervals are equal if all their fields are equal. The hash
  leaves out attrs, whose values may be unhashable.
- width is a property. The aliases chr and stop, __len__ and copy are
  gone.
- from_str parses negative coordinates and chromosome names with
  colons, and raises ValueError for another format.

IntervalSeqBuilder of VariantSeqExtractor marks the reference ranges
with (start, end) tuples instead of Intervals, because overlapping
variants give ranges whose start is greater than their end.
VariantSeqExtractor also turns a float chrom_len into an int end,
because Interval rejects a float end.

BREAKING CHANGE: Interval is immutable, takes strand, name, score and
attrs only as keywords, and raises TypeError or ValueError for invalid
fields. Use interval.width instead of interval.width() and
len(interval), chrom instead of chr, and end instead of stop.
Interval.copy is gone. An Interval equals only an Interval with equal
fields, including name, score and attrs.
resize_interval duplicated Interval.resize for "center", and its anchors
followed two strand rules: on a "-" interval 10-20 resized to width 4,
"start" gave 10-14 (genomic) and "center" gave 13-17 (stranded).
Interval("chr1", 10, 20).resize(-2) returned 16-14, and ResizeInterval
checked its anchor only when called.

- Interval.resize(width, *, anchor="center", use_strand=True) keeps the
  start, center or end of the interval. "start" and "end" follow the
  strand like "center": on a "-" interval, "start" keeps the genomic
  end. use_strand=False resizes as an unstranded interval.
  resize raises TypeError if width is not an int, and ValueError if
  width is negative or anchor is not "start", "center" or "end".
- resize_interval is gone.
- ResizeInterval(width, *, anchor="center", use_strand=True) checks its
  arguments in __init__ and calls Interval.resize.

BREAKING CHANGE: resize_interval is removed. Use
interval.resize(width, anchor=anchor, use_strand=False) for the anchors
"start" and "end", and interval.resize(width) for "center".
ResizeInterval takes anchor only as a keyword, and its anchors "start"
and "end" follow the strand unless use_strand=False. Interval.resize
takes use_strand only as a keyword. It raises ValueError for a negative
width and TypeError for width=None, instead of returning an invalid
interval or a copy.
Interval.trim(i, j) had the name of functional.trim(seq, length,
anchor), but sliced the interval by relative positions. It checked
j > i with assert, and extended the interval past its end: trim(0, 20)
on an interval of width 10 gave width 20.

Interval.slice(i, j, *, use_strand=True) raises TypeError if i or j is
not an int, and ValueError unless 0 <= i < j <= width.

BREAKING CHANGE: Interval.trim(i, j) is renamed to Interval.slice(i, j),
and use_strand is keyword-only. slice raises ValueError instead of
AssertionError if j <= i, and also if i < 0 or j > width.
Interval.center, shift and slop took use_strand positionally, unlike
resize and slice. The name x of the shift amount hid that it is an
offset, and slop(2, 5) did not say which side moves.

- center(*, use_strand=True)
- shift(offset, *, use_strand=True)
- slop(*, upstream=0, downstream=0, use_strand=True)

shift and slop raise TypeError if an amount is not an int.

BREAKING CHANGE: use_strand of Interval.center and Interval.shift is
keyword-only, and the parameter x of shift is renamed to offset.
Interval.slop takes upstream, downstream and use_strand only as
keywords.
pad and trim with anchor="center" rounded the other way than
Interval.resize: trimming 10 letters to 7 kept 2-9, but
Interval("chr1", 0, 10).resize(7) gives 1-8. trim(seq, 0, anchor="end")
returned seq, because seq[-0:] is the whole sequence. The functions
checked their arguments with assert, and pad(..., value="") raised
ZeroDivisionError. pad took (value, anchor) and fixed_len
(anchor, value) positionally, and the support for lists was a kipoi
leftover.

- pad(seq, length, *, anchor="center", value="N"),
  trim(seq, length, *, anchor="center") and
  fixed_len(seq, length, *, anchor="center", value="N") take a str.
  They keep the letters that Interval.resize keeps of an interval from
  0 to len(seq).
- They raise TypeError if seq or value is not a str or if length is
  not an int. They raise ValueError for a length out of range, an
  unknown anchor or an empty value.

BREAKING CHANGE: pad, trim and fixed_len take only strings, and anchor
and value only as keywords. With anchor="center", the result can move
by one letter. They raise TypeError or ValueError instead of
AssertionError, and trim(seq, 0, anchor="end") returns "".
name and score are the BED6 fields of pybedtools. With attrs, an
Interval had three places for metadata, and only the matchers filled
name and score. A NaN score also needed its own check, because it made
the interval unequal to itself.

Interval keeps chrom, start, end, strand and attrs. The repr leaves out
name and score.

BREAKING CHANGE: Interval.name and Interval.score are removed, and
Interval no longer takes name or score. Keep them in attrs, e.g.
Interval("chr1", 0, 10, attrs={"name": "a", "score": 1.5}).
Interval is a frozen dataclass now, so each of these is one expression
on its fields.

BREAKING CHANGE: Interval.unstrand, swap_strand, neg_strand and
is_valid are removed:
- interval.unstrand() becomes dataclasses.replace(interval, strand=".").
- interval.neg_strand becomes interval.strand == "-".
- interval.swap_strand() becomes replace(interval, strand=s), with s
  from {"+": "-", "-": "+", ".": "."}[interval.strand].
- interval.is_valid(chrom_len) becomes
  0 <= interval.start and interval.end <= chrom_len.
The default chrom_len=math.inf and float lengths forced an int() cast
on the end. A chromosome length is an int, so truncate now takes one.

BREAKING CHANGE: Interval.truncate requires chrom_len, and raises a
TypeError if it is not an int. interval.truncate() without chrom_len
becomes replace(interval, start=max(interval.start, 0)).
Five Interval methods repeated `use_strand and self.strand == "-"`.
The helper _is_reverse now holds this rule, so "." counts as "+" in one
place.
Interval wrote its own __repr__, which 0.1 needed for name, score and
attrs. The repr that dataclass generates names each field, and eval of
it gives an equal Interval too. A test checks the repr.
Tokenizer copied the int rule of _check_int: an int or a numpy integer,
but not a bool. It now calls _check_int, so its TypeError reads like the
other int checks, e.g. "the token of 'N' in token_map must be an int,
not float".
@Hoeze
Hoeze force-pushed the feat/interval-resize-rule branch from ce18d4f to d766003 Compare October 17, 2026 17:19

This branch has not been deployed

No deployments
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.

1 participant