Skip to content

feat(python): add Bitmap binding for RoaringBitmap#7837

Open
wjones127 wants to merge 4 commits into
lance-format:mainfrom
wjones127:feat/python-bitmap-7695
Open

feat(python): add Bitmap binding for RoaringBitmap#7837
wjones127 wants to merge 4 commits into
lance-format:mainfrom
wjones127:feat/python-bitmap-7695

Conversation

@wjones127

Copy link
Copy Markdown
Contributor

Two sites in python/src/transaction.rs copy a RoaringBitmap through a Python set/list on every Rust↔Python crossing:

This adds a Bitmap type (lance.bitmap.bitmap) wrapping Arc<RoaringBitmap>:

  • Constructible from a Python iterable of ints, a range, or an integer pyarrow Array/ChunkedArray (read via a zero-copy buffer, not per-value Python objects).
  • __len__/__contains__/__iter__/__eq__, add/discard/update (copy-on-write via Arc::make_mut), and pickling.

Both transaction.rs sites now emit Bitmap on output and accept it directly on input (an Arc clone instead of a Python-set round trip), while still accepting a plain set/list on input for backward compatibility. The same set-expansion pattern existed in two more places — IndexSegment.fragment_ids and IndexSegmentDescription.fragment_ids in indices.rs (used by list_indices()/describe_indices()) — so those were switched to Bitmap too.

FragmentMetadata.to_json() normalizes Bitmap/List[Bitmap] offsets back to plain (nested) lists of ints for JSON serialization.

Fixes #7695

Test plan

  • New python/tests/test_bitmap.py: construction (list/range/pyarrow array of various int widths/chunked array), len/iter/in, equality, pickle round-trip, copy-on-write mutation isolation, error cases (negative values, out-of-range values, nulls).
  • Extended test_dataset.py overlay tests to cover Bitmap-typed offsets (dense + sparse) and assert the output type; updated the invalid-offsets error-message assertion.
  • Extended test_indices.py/test_scalar_index.py to assert Index.fragment_ids/IndexInformation.fragment_ids are Bitmap.
  • cargo fmt --all, cargo clippy -p pylance --lib --tests -- -D warnings, uv run ruff format/ruff check, uv run pyright all clean.

Two sites in python/src/transaction.rs (Index.fragment_ids, and
DataOverlayFile.offsets added in lance-format#7540) copy a RoaringBitmap through a
Python set/list on every Rust<->Python crossing. This adds a Bitmap
type wrapping Arc<RoaringBitmap>, constructible from a Python iterable,
range, or pyarrow integer Array/ChunkedArray, with copy-on-write
mutation. Both sites now emit/accept Bitmap directly (falling back to
plain sets/lists on input for backward compatibility), and the same
fix is applied to the two IndexSegment(Description) getters in
indices.rs that had the identical set-expansion pattern.

Fixes lance-format#7695
@github-actions github-actions Bot added A-python Python bindings enhancement New feature or request labels Jul 18, 2026
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a Python Roaring Bitmap type with Arrow and iterable constructors, container and mutation APIs, and pickle support. Index fragment IDs and data overlay offsets now use Bitmap representations, with updated serialization, type stubs, bindings, and tests.

Changes

Python Roaring Bitmap Integration

Layer / File(s) Summary
Bitmap API and runtime implementation
python/src/bitmap.rs, python/src/lib.rs, python/python/lance/bitmap.py, python/python/lance/lance/bitmap.pyi, python/python/tests/test_bitmap.py
Adds the PyO3-backed Bitmap and lazy iterator, public exports, iterable and PyArrow construction, validation, comparisons, mutation, copy-on-write, pickle support, and tests.
Bitmap-backed index metadata
python/src/indices.rs, python/src/transaction.rs, python/python/lance/dataset.py, python/python/lance/lance/indices/__init__.pyi, python/python/tests/test_indices.py, python/python/tests/test_scalar_index.py
Changes index fragment IDs from Python sets to Bitmap values across extraction, export, representations, annotations, and tests.
Bitmap-backed overlay offsets
python/src/transaction.rs, python/python/lance/fragment.py, python/python/lance/dataset.py, python/python/tests/test_dataset.py
Accepts dense and sparse iterable or Bitmap offsets, removes input-order validation, exports Bitmap values, and normalizes offsets for JSON metadata round trips.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PythonCaller
  participant Bitmap
  participant IndexMetadata
  participant DataOverlayFile
  participant FragmentMetadata

  PythonCaller->>Bitmap: construct or mutate bitmap
  PythonCaller->>IndexMetadata: pass fragment IDs
  IndexMetadata-->>PythonCaller: return Bitmap fragment IDs
  PythonCaller->>DataOverlayFile: pass dense or sparse offsets
  DataOverlayFile->>FragmentMetadata: serialize normalized offsets
  FragmentMetadata-->>PythonCaller: return Bitmap-backed metadata
Loading

Possibly related PRs

Suggested labels: A-index

Suggested reviewers: jackye1995, xuanwo, majin1102

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a Python Bitmap binding for RoaringBitmap.
Description check ✅ Passed The description is directly related to the Bitmap binding, its behavior, and the affected Rust/Python crossings.
Linked Issues check ✅ Passed The changes satisfy #7695 by adding a RoaringBitmap-backed Python Bitmap with direct passing, copy-on-write mutation, and efficient constructors.
Out of Scope Changes check ✅ Passed The file changes all support the Bitmap binding or its consumers, with no clearly unrelated additions visible.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

Comment thread python/python/lance/dataset.py Outdated
Comment thread python/src/bitmap.rs Outdated
Comment thread python/src/bitmap.rs Outdated
Comment thread python/src/transaction.rs Outdated
- DataOverlayFile.offsets is now typed Iterable[int] | List[Iterable[int]]
  instead of spelling out Bitmap/List[Bitmap]/List[int]/List[List[int]]
  explicitly, since Bitmap already satisfies Iterable[int].
- bitmap_from_pyarrow collects straight from the arrow buffer into the
  RoaringBitmap per dtype arm, instead of through an intermediate Vec<u32>.
- Bitmap.__iter__ now returns a dedicated streaming iterator (BitmapIterator)
  instead of eagerly building a PyList of every value up front.
- DataOverlayFile's dense/sparse extraction now goes through the same
  extract_bitmap helper for both flat and per-field offsets, dropping the
  separate raw-Vec<u32> path and its ascending-order validation. Offsets are
  always resolved in ascending rank order regardless of input order, so
  there's no longer a distinct ordering contract to enforce.
- bitmap_from_pyarrow's UInt64 arm cast to i64 before range-checking, so
  a u64 value above i64::MAX wrapped to a negative number and the resulting
  ValueError reported that bogus negative instead of the real value. Added
  a dedicated u64_to_u32 that checks the range directly.
- Bitmap's __eq__/__ne__ raised TypeError when compared against a value
  that isn't a Bitmap or an iterable of ints (e.g. `bitmap([1]) == 5`),
  instead of returning False/True like normal Python equality and like
  __contains__ already does for the same case.
- Minor clarity fixes from self-review: extracted extract_sparse_bitmaps
  out of a dense combinator chain, documented that duplicate offsets
  silently collapse (a RoaringBitmap is a set), documented the
  ChunkedArray-vs-Array duck-typing check, and documented why bitmap.py
  exports both `Bitmap` and `bitmap`.
@wjones127

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
python/python/lance/fragment.py-126-131 (1)

126-131: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize every sparse offset iterable before JSON serialization.

DataOverlayFile.offsets permits List[Iterable[int]], but this only converts nested Bitmap values. A valid sparse input such as [range(1, 3)] remains a range, causing json.dumps(metadata.to_json()) to fail. Materialize every nested sparse iterable as a list while preserving dense integer iterables.

Proposed fix
 def _offsets_to_json(offsets):
-    # `offsets` is a Bitmap (dense) or a list of Bitmap/int-list (sparse,
-    # per field); normalize to plain (nested) lists of ints for JSON.
+    # Normalize dense and sparse offset iterables to JSON-compatible lists.
     if isinstance(offsets, Bitmap):
         return list(offsets)
-    return [list(o) if isinstance(o, Bitmap) else o for o in offsets]
+    offsets = list(offsets)
+    if not offsets or isinstance(offsets[0], int):
+        return offsets
+    return [list(offsets_for_field) for offsets_for_field in offsets]

Add a round-trip case using sparse range or tuple offsets. As per coding guidelines, “Every bugfix and feature must have corresponding tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/python/lance/fragment.py` around lines 126 - 131, Update
_offsets_to_json to materialize every nested sparse offset iterable as a list of
integers, not only Bitmap instances, while preserving the existing dense-offset
handling. Add a round-trip test using sparse range or tuple offsets and verify
JSON serialization/deserialization succeeds.

Source: Coding guidelines

🧹 Nitpick comments (1)
python/src/bitmap.rs (1)

197-219: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use py.NotImplemented() instead of raising NotImplementedError for unsupported ordering ops.

For Lt/Le/Gt/Ge, returning Err(PyNotImplementedError::new_err(...)) raises Python's NotImplementedError exception directly, rather than returning the NotImplemented sentinel. Per PyO3's own guide, the idiomatic way to "leave some operations unimplemented" in __richcmp__ is to return the sentinel so Python raises the conventional TypeError: '<' not supported between instances of .... Code paths that catch TypeError for unsupported comparisons (e.g. via sorted()/total_ordering) would be surprised by NotImplementedError here.

♻️ Proposed fix using the `NotImplemented` sentinel
-    fn __richcmp__(&self, other: &Bound<'_, PyAny>, op: CompareOp) -> PyResult<bool> {
+    fn __richcmp__<'py>(
+        &self,
+        other: &Bound<'py, PyAny>,
+        op: CompareOp,
+        py: Python<'py>,
+    ) -> PyResult<Bound<'py, PyAny>> {
         let other_bitmap = if let Ok(other) = other.extract::<Self>() {
             Some((*other.0).clone())
         } else {
             other.try_iter().ok().and_then(|it| {
                 it.map(|item| item?.extract::<u32>())
                     .collect::<PyResult<RoaringBitmap>>()
                     .ok()
             })
         };
         match (op, other_bitmap) {
-            (CompareOp::Eq, Some(other)) => Ok(*self.0 == other),
-            (CompareOp::Eq, None) => Ok(false),
-            (CompareOp::Ne, Some(other)) => Ok(*self.0 != other),
-            (CompareOp::Ne, None) => Ok(true),
-            _ => Err(PyNotImplementedError::new_err(
-                "Only == and != are supported",
-            )),
+            (CompareOp::Eq, Some(other)) => Ok((*self.0 == other).into_pyobject(py)?.into_any()),
+            (CompareOp::Eq, None) => Ok(false.into_pyobject(py)?.into_any()),
+            (CompareOp::Ne, Some(other)) => Ok((*self.0 != other).into_pyobject(py)?.into_any()),
+            (CompareOp::Ne, None) => Ok(true.into_pyobject(py)?.into_any()),
+            _ => Ok(py.NotImplemented().into_bound(py)),
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/src/bitmap.rs` around lines 197 - 219, Update __richcmp__ so
unsupported ordering operations (Lt, Le, Gt, and Ge) return
PyNotImplemented::new(py) as the Python NotImplemented sentinel rather than
raising PyNotImplementedError; preserve the existing Eq and Ne behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/src/transaction.rs`:
- Around line 195-219: Update extract_bitmap to detect repeated offsets before
collecting them into RoaringBitmap, and return a descriptive PyErr when a
duplicate is encountered. Ensure this validation applies to both direct bitmap
extraction and sparse coverage through extract_sparse_bitmaps, while preserving
normal deduplicated ordering for unique offsets.

---

Other comments:
In `@python/python/lance/fragment.py`:
- Around line 126-131: Update _offsets_to_json to materialize every nested
sparse offset iterable as a list of integers, not only Bitmap instances, while
preserving the existing dense-offset handling. Add a round-trip test using
sparse range or tuple offsets and verify JSON serialization/deserialization
succeeds.

---

Nitpick comments:
In `@python/src/bitmap.rs`:
- Around line 197-219: Update __richcmp__ so unsupported ordering operations
(Lt, Le, Gt, and Ge) return PyNotImplemented::new(py) as the Python
NotImplemented sentinel rather than raising PyNotImplementedError; preserve the
existing Eq and Ne behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: bd1e3e04-2ce8-42f3-b05b-f8fedf8b2b47

📥 Commits

Reviewing files that changed from the base of the PR and between da5ef90 and 17ca048.

📒 Files selected for processing (14)
  • python/python/lance/bitmap.py
  • python/python/lance/dataset.py
  • python/python/lance/fragment.py
  • python/python/lance/lance/__init__.pyi
  • python/python/lance/lance/bitmap.pyi
  • python/python/lance/lance/indices/__init__.pyi
  • python/python/tests/test_bitmap.py
  • python/python/tests/test_dataset.py
  • python/python/tests/test_indices.py
  • python/python/tests/test_scalar_index.py
  • python/src/bitmap.rs
  • python/src/indices.rs
  • python/src/lib.rs
  • python/src/transaction.rs

Comment thread python/src/transaction.rs
DataOverlayFile.offsets is a low-level API and duplicate offsets are
intentionally left unvalidated (a RoaringBitmap is a set, so a repeat
silently collapses to one entry). Make that an explicit, documented
trade-off rather than an easy-to-miss implementation detail: spell it
out in the offsets docstring and cross-reference it from the Rust-side
extract_bitmap comment so the two don't drift out of sync.
@wjones127
wjones127 marked this pull request as ready for review July 20, 2026 18:57
@wjones127
wjones127 requested a review from Xuanwo July 20, 2026 18:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
python/src/transaction.rs (2)

232-240: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Don't consume one-shot sparse iterables during shape detection.
The dense probe walks offsets first; if the first element is itself iterable, the sparse fallback reuses the same Python iterator after that item has already been consumed. A generator of per-field coverages can lose its first field and misalign the overlay. Buffer the outer iterable once before trying both interpretations, and add a generator-backed regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/src/transaction.rs` around lines 232 - 240, Update the coverage-shape
detection in the transaction overlay construction to materialize the outer
offsets iterable once before calling extract_bitmap or extract_sparse_bitmaps,
then reuse the buffered values for both interpretations. Preserve dense and
sparse behavior while ensuring one-shot generators retain their first per-field
coverage, and add a regression test using a generator-backed sparse iterable.

208-215: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve the inner conversion error for DataOverlayFile.offsets. For inputs like offsets=[-1], the dense probe has the bad value/type, but that error gets dropped and the sparse fallback ends up with the generic shape message. Wrap or propagate the original conversion error, and add cases for negative, out-of-range, and non-integer offsets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/src/transaction.rs` around lines 208 - 215, Update extract_bitmap so
the failed dense PyBitmap extraction error is preserved when the iterable
fallback also fails, including for DataOverlayFile.offsets inputs such as
negative, out-of-range, or non-integer values. Ensure the resulting error
reports the inner element conversion failure rather than the generic shape
error, and add coverage for all three invalid offset categories.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@python/src/transaction.rs`:
- Around line 232-240: Update the coverage-shape detection in the transaction
overlay construction to materialize the outer offsets iterable once before
calling extract_bitmap or extract_sparse_bitmaps, then reuse the buffered values
for both interpretations. Preserve dense and sparse behavior while ensuring
one-shot generators retain their first per-field coverage, and add a regression
test using a generator-backed sparse iterable.
- Around line 208-215: Update extract_bitmap so the failed dense PyBitmap
extraction error is preserved when the iterable fallback also fails, including
for DataOverlayFile.offsets inputs such as negative, out-of-range, or
non-integer values. Ensure the resulting error reports the inner element
conversion failure rather than the generic shape error, and add coverage for all
three invalid offset categories.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: b3bbfcc8-686b-4e0d-8b1e-73bccef75a20

📥 Commits

Reviewing files that changed from the base of the PR and between 17ca048 and 1296d99.

📒 Files selected for processing (2)
  • python/python/lance/dataset.py
  • python/src/transaction.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-python Python bindings enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(python): make Python binding for Roaring Bitmap

1 participant