polaranges 0.3.2

Rust-first genomic range operations on top of Polars DataFrames
Documentation
# RangeFrame Draft

This draft is the first Rust-only shape for a Polars-backed range library that
borrows the successful parts of `../pyrunges` while keeping the execution path
inside Rust and routing interval kernels through `../ruranges-core`.

## The core idea

`pyrunges` already shows a clean split:

- `RangeFrame` is mostly a table wrapper with validation and method dispatch.
- overlap-heavy work is delegated to `ruranges`.

The Rust version should keep exactly that split:

- `RangeFrame` owns a `polars::DataFrame`
- `RangeFrame::overlap_pairs()` prepares typed slices for `ruranges-core`
- `ruranges-core::overlaps::overlaps()` returns row-id pairs
- `RangeFrame::overlap()` gathers those row ids back into a new Polars frame

That keeps the API high-level but makes the performance-sensitive path a thin
shim over the Rust kernel.

## Why a wrapper instead of “extending” Polars?

Polars in Rust does not want a pandas-style subclass story, and that is
actually helpful here. We can make `RangeFrame` a normal Rust type:

```rust
pub struct RangeFrame {
    df: DataFrame,
    start_col: String,
    end_col: String,
}
```

This gives us:

- one place to enforce interval invariants,
- one place to normalize column naming,
- one place to attach range-specific methods,
- zero ambiguity about whether a plain `DataFrame` is valid for interval ops.

The wrapper can stay intentionally small and just expose `df()`, `into_inner()`,
and row-gather helpers.

## Validation rules

The constructor should validate the same semantic invariants as the Python
`RangeFrame`:

- start column exists
- end column exists
- start/end are integer-typed
- start/end contain no nulls
- coordinates are non-negative
- `start < end` for every row

This should happen once at construction so later methods can assume interval
correctness.

## Overlap API

The right primitive is not “return a joined table immediately”. The right
primitive is row matches:

```rust
pub struct OverlapPairs {
    pub left: Vec<u32>,
    pub right: Vec<u32>,
}
```

That matches `ruranges-core` exactly, and it plays well with Polars because
gathering rows is cheap and explicit.

So the API should be layered:

```rust
pub fn overlap_pairs(&self, other: &Self, options: OverlapOptions)
    -> Result<OverlapPairs>;

pub fn overlap(&self, other: &Self, options: OverlapOptions)
    -> Result<Self>;
```

`overlap()` is just sugar for:

1. compute `OverlapPairs`
2. take `pairs.left`
3. return those rows as a new `RangeFrame`

This is the same mental model as the current Python code, where overlap is a
thin layer over index-pair generation.

## match_by factorization

The Python code uses `factorize_binary()` so both frames share the same dense
group ids before entering the overlap kernel.

The Rust draft does the same thing in pure Rust:

1. read `match_by` columns row by row from both frames,
2. convert each row into an owned hashable key,
3. assign a dense `u32` group id,
4. pass those `u32` vectors into `ruranges-core`.

This keeps the kernel contract identical to the one already used from Python.

The important point is consistency across both frames, not whether factorization
comes from Polars internals or a Rust `HashMap`. If factorization later becomes
a bottleneck, we can swap this for a Polars-native row-encoding path without
changing the public API.

## Coordinate typing

For the first Rust draft, I would normalize coordinates to `i64` before calling
`ruranges-core`.

That gives a much simpler first implementation:

- fewer generic branches,
- easier validation,
- one kernel dispatch path,
- easy support for signed slack.

Later, if needed, we can add a zero-copy dispatch layer:

- `Int32` -> `overlaps::<u32, i32>()`
- `Int64` -> `overlaps::<u32, i64>()`

That optimization is straightforward once the API is stable.

## How overlap works end to end

The method should do this:

1. validate that both frames fit in `u32` row ids
2. extract `start` and `end` columns as integer vectors
3. factorize `match_by` into `Vec<u32>` for both frames
4. call `ruranges_core::overlaps::overlaps(...)`
5. receive `(left_idx, right_idx)`
6. either return them directly or gather rows with `DataFrame::take`

So the “real” overlap method is conceptually:

```rust
let (left_groups, right_groups) = factorize_pair(...)?;
let (left_idx, right_idx) = ruranges_core::overlaps::overlaps(
    &left_groups,
    &left_starts,
    &left_ends,
    &right_groups,
    &right_starts,
    &right_ends,
    options.slack,
    options.multiple.as_kernel_str(),
    options.preserve_input_order,
    options.contained_intervals_only,
);
```

Everything else is input prep and result gathering.

## Why row indexing is the right extraction story

You mentioned using row indexing afterward, and I think that is exactly the
right design.

Polars does not have a persistent index, so instead of pretending it does, the
library should make row ids explicit:

- overlap kernels return row ids
- join/intersect/subtract can all be built from row ids plus coordinate outputs
- callers can gather from left or right as needed

This is cleaner than hiding everything behind one big join method too early.

## Good next steps

After this draft, I would build in this order:

1. `join_overlaps()` using `OverlapPairs` plus `DataFrame::take` on both sides
2. `count_overlaps()` using `ruranges-core::overlaps::count_overlaps`
3. typed `i32`/`i64` dispatch to avoid unconditional `i64` casting
4. optional hidden row-id helper column for debugging and joins
5. `intersect()` by combining overlap pairs with new start/end coordinates

That keeps the public design stable while growing capability in small pieces.