# 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.