Skip to content

feat: python-bindings - #338

Draft
eseiler wants to merge 33 commits into
seqan:mainfrom
eseiler:feat/python
Draft

eseiler wants to merge 33 commits into
seqan:mainfrom
eseiler:feat/python

Conversation

@eseiler

@eseiler eseiler commented Sep 28, 2026

Copy link
Copy Markdown
Member

generated via Claude Code Opus 5.5

@seqan-actions seqan-actions added lint and removed lint labels Sep 28, 2026
@seqan-actions

Copy link
Copy Markdown
Member

Documentation preview available at https://docs.seqan.de/preview/seqan/hibf/338

@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.79%. Comparing base (5e78633) to head (6cd83a0).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #338   +/-   ##
=======================================
  Coverage   99.79%   99.79%           
=======================================
  Files          53       53           
  Lines        1934     1939    +5     
  Branches        7        7           
=======================================
+ Hits         1930     1935    +5     
  Misses          4        4           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

generated via Claude Code Opus 5.5
@sonarqubecloud

sonarqubecloud Bot commented Oct 1, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

eseiler and others added 21 commits October 6, 2026 11:03
The input adapter only borrowed Config.input. If the input was reassigned while the library was
still requesting values, e.g. by the input itself, the adapter called a freed object and the
interpreter crashed. The adapter now owns a reference. All builders destroy it with the GIL held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IBF agents size their buffers by the number of bins. After increase_bin_number_to or
try_increase_bin_number_to, an existing agent returned wrong counts and copied the IBF's new
number of words into its old buffer, writing out of bounds.

All agents are now wrapped in py_agent, which rebuilds an IBF agent when the IBF's number of bins
differs from the one the agent was created for. HIBF agents use the same wrapper; an HIBF cannot
change, so they are never rebuilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HIBF(config, layout) passed the layout to the library unchecked. An empty layout crashed the
construction. A layout with more user bins than the config built an index that reported user bin
ids beyond number_of_user_bins, and counting agents wrote those ids out of bounds.

The user bin ids of the layout must now be 0, ..., number_of_user_bins - 1, each exactly once.
Otherwise, a ValueError is raised before the construction starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The library only asserts that merged sketches have the same number of registers. In a Release
build, merging a smaller sketch into a larger one read past the smaller buffer, and the estimate
became nan. merge and merge_and_estimate now raise a ValueError instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The library allocates and zero-fills 2^num_bits bytes before checking that num_bits is in
[5, 32]. HyperLogLog(40) therefore tried to touch 1 TiB before raising. The binding now checks
the range first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Methods that modify an IBF or a HyperLogLog sketch are not synchronised. Operations such as
batch_membership_for and save release the GIL, so a concurrent increase_bin_number_to can free the
memory they are reading. The README and the docstrings now state that such methods must not run
while another thread uses the object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Config stored the number of user bins inferred from the first sequence it was given. Assigning a
longer input afterwards kept the old count, and the index silently left out the extra user bins.

A number_of_user_bins of 0 now means "infer from the input" whenever the value is needed: by the
property, the builders, to_string, write_layout_file, __eq__ and __repr__. Pickling keeps the 0,
so a restored config keeps following its input. validate_and_set_defaults still stores the
inferred value, like the other defaults it sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Values were converted with numpy.asarray(..., dtype="uint64"), which truncates floats: 1.9, 2.7
and 3.2 were stored as 1, 2 and 3, and queries were truncated the same way.

Arrays, buffers and NumPy scalars must now have an integer dtype. Signed values are still
reinterpreted as two's complement. Python integers and iterables of them are converted value by
value via __index__: floats raise TypeError, and values outside [0, 2**64) raise OverflowError.
That no longer depends on how the installed NumPy version handles out-of-range Python integers.
For one million Python ints, this takes 51 ms; the previous numpy.asarray path took 55 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The HIBF agents summed the counts of a split bin's technical bins in uint16_t (membership) or
value_t (counting). A query value is counted in its own technical bin and may be a false positive
in the others, so the sum can exceed the number of query values. With maximum_fpr = 0.3, a query
of 60 000 values summed to 81 266, wrapped, and missed its user bin. The sums now use size_t, and
counting agents saturate at the maximum of value_t when storing a count.

The counts per technical bin still use the counter type, so they wrap for queries with more values
than it can count. The Python bindings now raise ValueError for such queries: at most 65 535
values for membership queries, and at most the maximum of the dtype for counting agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Config, Layout, InterleavedBloomFilter and HierarchicalInterleavedBloomFilter define __eq__ by
value, but kept the identity-based default hash. Two equal IBFs therefore ended up as two entries
in a set. Python requires equal objects to have equal hashes. A hash over the values would be
expensive and most of these objects are mutable, so __hash__ is now None, as for lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
config::operator== is documented to compare all options except input_fn, but it skipped
number_of_hash_functions and track_occupancy. Configs that differ only in these compared equal,
in C++ and via Config.__eq__ in Python.

Configs read from layout files of versions 1 and 2 derive track_occupancy from
empty_bin_fraction, as validate_and_set_defaults does, so they still compare equal to the
validated configs they were written from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
interleaved_bloom_filter::clear(bin_index) resets the occupancy of the bin, but the overload for a
range of bins only cleared the bits. With track_occupancy, the cleared bins kept their old
occupancy. In Python, ibf.clear(1) reset the occupancy of bin 1, while ibf.clear([2]) did not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Config owns a Python object, but its type had no tp_traverse or tp_clear. A reference cycle through
Config.input was therefore never collected. For example, a bound method of an object that stores
the config leaked the object and all its data until exit, when nanobind reported the leak.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
counting_agent() returned object, so type checkers could not see bulk_count. It now returns a
std::variant of the three counting agents, which the stub shows as a union. timings is typed
dict[str, float], and Config.input as a sequence or callable of array-likes, or None. Layout's
top_level_max_bin_id got a docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g it

Config.__repr__ built the complete repr of the input and then cut it to 60 characters. For a list
of 20 000 arrays, this took 19 s. The input is now described by its type and its name or length,
e.g. <list of length 20000>, which takes 43 us. Floats are formatted with Python's repr, so
0.1 + 0.2 shows as 0.30000000000000004 instead of 0.3, and 1.0 as 1.0 instead of 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
__getstate__ serialised into an ostringstream, which grows by doubling, and then copied the result
into a bytes object. Pickling a 256 MiB IBF raised the peak memory by 1280 MiB.

The object is now serialised twice: once into a counting stream buffer to determine the size, and
once directly into a bytes object of that size. The counting pass does not touch the data, because
cereal writes each vector with a single call. The peak memory now rises by 512 MiB: the bytes object
and pickle's own copy of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
save() never checked whether closing the file succeeded. Data that was still buffered at that
point, which is all of it for small objects, was lost without an error, e.g. on a full disk.
Larger objects failed during serialisation, but with cereal's RuntimeError instead of an OSError.

save() now writes through a file buffer that records errno of the first failed write, checks
close(), and raises an OSError with that errno in both cases. errno cannot be read after cereal's
exception, because unwinding may overwrite it; the test saw ENOENT instead of EFBIG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
membership_for and bulk_count held the GIL, so Python threads with one agent each could not query
in parallel. They now release it while the library runs. For 400 queries of 5000 values against
an HIBF with 1000 user bins, 4 threads with one agent each took 0.04 s instead of 0.18 s.

Agents keep their results in buffers, so one agent used by two threads at once would race. Each
agent is now leased for the duration of a query; a second thread using it gets a RuntimeError.
The lease flag is only accessed while holding the GIL. The filters' own membership_for uses a new
agent per call and needs no lease.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The library throws std::logic_error for a bin count, bin size or number of hash functions out of
range, which nanobind maps to RuntimeError. It does not check empty_bin_fraction at all, although
it determines the number of technical bins: 1.0 divides by zero, and a negative fraction yields
fewer technical bins than bins. The binding now checks all four and raises ValueError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README claimed that agents avoid reallocating buffers. The HIBF agent still creates a counting
agent per IBF on every query, so the README now only calls agents reusable. It also describes that
queries release the GIL, that an agent used by two threads at once raises, and that importing the
module on a free-threaded build enables the GIL again, which the checks rely on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pyproject.toml uses a PEP 639 license expression, license = "BSD-3-Clause". scikit-build-core
0.10.0 and 0.10.7 reject it ("Field project.license has an invalid type"); 0.11.0 accepts it and
builds the bindings, and all Python tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eseiler and others added 10 commits October 6, 2026 11:40
nanobind>=2.0 was never tested. With Clang 23 and libc++ on Python 3.14:

* 2.0.0 and 2.9.0 do not compile: nb_types.h and nb_call.h use std::ptrdiff_t and alloca without
  including their headers.
* 2.13.0 and 2.15.0 compile and work, but stubgen does not qualify nested classes. The stubs refer
  to CountingAgentUInt16 instead of HierarchicalInterleavedBloomFilter.CountingAgentUInt16, which
  type checkers cannot resolve.
* 3.0.1, the first 3.x release, and 3.1.0 build and pass all Python tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bindings are built with -march=native by default, which stays. pip caches wheels built from
an sdist or a git URL pinned to a commit (not from local directories). With a cache shared between
machines of different CPU generations, e.g. a cluster home directory, a cached wheel fails with an
illegal-instruction error. The README now explains this and how to avoid it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The module contains MIT-licensed code: simde, robin_hood and the HyperLogLog implementation. It
also contains BSD-3-Clause code from nanobind and cereal. The license expression is now
"BSD-3-Clause AND MIT".

The wheel now contains their licences in .dist-info/licenses/third_party. nanobind's, cereal's
and simde's licence files are copied. The notices of robin_hood and the HyperLogLog code are
generated from robin_hood.hpp and LICENSES/MIT.txt. Dependencies found on the system instead of
via CPM are not covered; CMake warns about missing licences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README now says that:

* Apple's clang cannot build the library, because it lacks OpenMP. CMake stops with "HIBF requires
  OpenMP"; Homebrew's LLVM works.
* Building, also from an sdist, needs network access for CPM unless the dependencies are cached
  or found on the system.
* A wheel links the OpenMP runtime by absolute path and is not relocatable without delocate or
  auditwheel, and two OpenMP runtimes in one process can conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_setstate_invalid_bytes truncated the pickle itself and accepted any exception. pickle raised
UnpicklingError before __setstate__ ran, so the test never checked how invalid state is handled.
It now passes truncated state to __setstate__, expects cereal's RuntimeError, and checks that the
object stays uninitialised. No test uses pytest.raises(Exception) any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Python CI only built Release, where the library's assertions are disabled. Misuse that the
library only asserts against, e.g. merging HyperLogLog sketches of different sizes, therefore
passed CI. The test job now runs for both build types with each compiler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All builds of the bindings shared build/python/{wheel_tag}, so switching between Debug and Release
reconfigured the same directory. The build type is now part of the name. CMake keeps the
compiler of an existing build directory, so the README says to delete it after changing CXX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pip install . builds the Python bindings in build/python/ (see build-dir in pyproject.toml). The
repository had no .gitignore, so these files showed up as untracked unless a global ignore
covered build/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
<tuple> and <string_view> were included but not used. The include comments of <fstream> and
<utility> now list what is used from them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Like the library's classes, the binding classes now declare all six special members, defaulted
where possible. input_adapter holds a mutex and an atomic, so it can neither be copied nor moved.
For array_like, dtype_like and input_like, NB_OBJECT_DEFAULT declares the default constructor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@seqan-actions seqan-actions removed the lint label Oct 6, 2026

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.

2 participants