pyribbon 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. pyribbon-0.1.0/CHANGELOG.md +34 -0
  2. pyribbon-0.1.0/CONTRIBUTING.md +28 -0
  3. pyribbon-0.1.0/DESIGN.md +446 -0
  4. pyribbon-0.1.0/LICENSE +21 -0
  5. pyribbon-0.1.0/LICENSES/xxhash-BSD-2-Clause.txt +26 -0
  6. pyribbon-0.1.0/MANIFEST.in +13 -0
  7. pyribbon-0.1.0/PKG-INFO +281 -0
  8. pyribbon-0.1.0/README.md +236 -0
  9. pyribbon-0.1.0/RESEARCH.md +340 -0
  10. pyribbon-0.1.0/core/Makefile +64 -0
  11. pyribbon-0.1.0/core/fuzz/corpus/empty.bin +0 -0
  12. pyribbon-0.1.0/core/fuzz/corpus/n1000_r16.bin +0 -0
  13. pyribbon-0.1.0/core/fuzz/corpus/n400_r8.bin +0 -0
  14. pyribbon-0.1.0/core/fuzz/corpus/n50_r4.bin +0 -0
  15. pyribbon-0.1.0/core/fuzz/fuzz_load.c +116 -0
  16. pyribbon-0.1.0/core/include/ribbon.h +106 -0
  17. pyribbon-0.1.0/core/src/rb_hash.c +47 -0
  18. pyribbon-0.1.0/core/src/rb_internal.h +144 -0
  19. pyribbon-0.1.0/core/src/ribbon.c +377 -0
  20. pyribbon-0.1.0/core/tests/test_core.c +523 -0
  21. pyribbon-0.1.0/core/tests/vectors.h +128 -0
  22. pyribbon-0.1.0/core/tools/calibrate.c +48 -0
  23. pyribbon-0.1.0/core/tools/fpr.c +44 -0
  24. pyribbon-0.1.0/core/tools/overhead_table.py +65 -0
  25. pyribbon-0.1.0/core/tools/sweep.c +52 -0
  26. pyribbon-0.1.0/core/vendor/README.md +6 -0
  27. pyribbon-0.1.0/core/vendor/xxhash.h +7528 -0
  28. pyribbon-0.1.0/examples/01_quickstart.py +16 -0
  29. pyribbon-0.1.0/examples/02_key_types.py +31 -0
  30. pyribbon-0.1.0/examples/03_choosing_fp_bits.py +25 -0
  31. pyribbon-0.1.0/examples/04_save_and_load.py +38 -0
  32. pyribbon-0.1.0/examples/05_batches_and_arrays.py +25 -0
  33. pyribbon-0.1.0/examples/06_errors.py +34 -0
  34. pyribbon-0.1.0/examples/07_threads.py +25 -0
  35. pyribbon-0.1.0/pyproject.toml +88 -0
  36. pyribbon-0.1.0/setup.cfg +4 -0
  37. pyribbon-0.1.0/setup.py +29 -0
  38. pyribbon-0.1.0/src/pyribbon/__init__.py +22 -0
  39. pyribbon-0.1.0/src/pyribbon/_ribbon.c +656 -0
  40. pyribbon-0.1.0/src/pyribbon/_ribbon.pyi +35 -0
  41. pyribbon-0.1.0/src/pyribbon/py.typed +0 -0
  42. pyribbon-0.1.0/src/pyribbon.egg-info/PKG-INFO +281 -0
  43. pyribbon-0.1.0/src/pyribbon.egg-info/SOURCES.txt +56 -0
  44. pyribbon-0.1.0/src/pyribbon.egg-info/dependency_links.txt +1 -0
  45. pyribbon-0.1.0/src/pyribbon.egg-info/requires.txt +18 -0
  46. pyribbon-0.1.0/src/pyribbon.egg-info/top_level.txt +1 -0
  47. pyribbon-0.1.0/stubtest-allowlist.txt +1 -0
  48. pyribbon-0.1.0/tests/conftest.py +28 -0
  49. pyribbon-0.1.0/tests/spec_reference.py +358 -0
  50. pyribbon-0.1.0/tests/test_concurrency.py +238 -0
  51. pyribbon-0.1.0/tests/test_examples.py +74 -0
  52. pyribbon-0.1.0/tests/test_filter.py +486 -0
  53. pyribbon-0.1.0/tests/test_leaks.py +363 -0
  54. pyribbon-0.1.0/tests/test_loader.py +586 -0
  55. pyribbon-0.1.0/tests/test_property.py +571 -0
  56. pyribbon-0.1.0/tests/test_readme.py +70 -0
  57. pyribbon-0.1.0/tests/test_release_workflow.py +73 -0
  58. pyribbon-0.1.0/tests/test_statistics.py +233 -0
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/).
4
+
5
+ ## [Unreleased]
6
+
7
+ First development version (0.1.0, not published).
8
+
9
+ ### Added
10
+ - `RibbonFilter`: an immutable standard ribbon filter (width 64, `fp_bits` 1 to 32), built from an iterable of
11
+ `bytes`, `bytearray`, `memoryview`, `str` or `int` keys, or from a 1-D integer buffer (`array.array`, numpy).
12
+ - On `RibbonFilter`: `in`, `contains_many`, `len`, `to_bytes`, `size_in_bytes`, `bits_per_key` and `fp_bits`, plus
13
+ zero-copy `from_buffer` (with optional payload verification) and `pickle` and `copy` support. The exceptions are
14
+ `RibbonError`, `ConstructionError` and `FormatError`.
15
+ - A versioned, little-endian serialization format with a magic number, a header checksum and a payload checksum. The
16
+ loader treats its input as untrusted.
17
+ - A C11 core (`core/`) that builds without Python. It has its own unit tests, portable fallbacks for the bit operations,
18
+ ASan/UBSan runs and a fuzz harness for the loader.
19
+ - Seven numbered example scripts in `examples/` (start with `01_quickstart.py`); `make example` runs them and a test checks
20
+ their output.
21
+ - A pure-Python reference implementation of the spec (`tests/spec_reference.py`), used as the oracle in tests.
22
+ - Property, differential-fuzz, statistical, leak and concurrency tests, plus the `make stats`, `make bench` and
23
+ `make calibrate` targets.
24
+ - Benchmarks against `pyfusefilter` (Xor8, Xor16, Fuse8, Fuse16) and `rbloom` (`docs/benchmark.md`, `docs/benchmark.png`),
25
+ plus small README figures drawn from the saved results (`docs/img/`, `make figures`).
26
+ - A release workflow (`release.yml`): a pushed `v*` tag runs the tests, builds the sdist and the wheels, publishes to PyPI and creates
27
+ a GitHub release. Manual runs are dry runs.
28
+ - CI workflows for lint, docstyle, mypy strict and stubtest, tests on Python 3.10 to 3.14, Windows and macOS builds,
29
+ sanitizers, fuzzing, the sdist build and cibuildwheel. None of them publishes anything.
30
+
31
+ ### Notes
32
+ - Two measurements changed the design before this version. The seed is now pre-mixed and added to the key hash, because
33
+ an XOR-mixed seed made nearby seeds produce nearly identical filters for dense integer keys. The default overhead
34
+ table was also recalibrated against the real build, which fails at most 3.3% of attempts at every size.
@@ -0,0 +1,28 @@
1
+ # Contributing
2
+
3
+ ```bash
4
+ make venv # create .venv
5
+ make install-dev # editable install with dev tools (builds the C extension)
6
+ make check # lint, docstyle, mypy + stubtest, C tests, Python tests
7
+ make example # run every script in examples/ (a test also runs them)
8
+ ```
9
+
10
+ New examples go in `examples/` with the next number (`08_...py`) and a docstring that says how to run it. `tests/test_examples.py`
11
+ runs each script, and `tests/test_readme.py` checks that the README lists it.
12
+
13
+ The C core (`core/`) builds and tests without Python, using `make c-test`, `make c-asan` and `make c-portable`.
14
+ C code must compile with `-std=c11 -Wall -Wextra -Wconversion -Werror`. A change to the algorithm needs a citation to
15
+ the Ribbon papers (see `RESEARCH.md`). Do not copy code from RocksDB, fastfilter_cpp, BuRR or other implementations.
16
+
17
+ ## Releasing
18
+
19
+ 1. Raise `version` in `pyproject.toml` and `__version__` in `src/pyribbon/__init__.py`, move the CHANGELOG entry from
20
+ "Unreleased" to the new version, and commit to `main`.
21
+ 2. Wait for CI to pass on `main`. To rehearse, run the Release workflow by hand (Actions, Release, Run workflow). It builds and
22
+ checks everything and publishes nothing, even when started from a tag.
23
+ 3. Tag that commit and push the tag: `git tag v0.1.0 && git push origin v0.1.0`.
24
+
25
+ The Release workflow then runs the full tests, builds the sdist and the wheels, checks that the tag equals the version in
26
+ `pyproject.toml` and points at a commit on `main`, publishes to PyPI with the repository secret `PYPI_API_TOKEN`, and creates a
27
+ GitHub release. A tag with a suffix such as `v0.2.0rc1` becomes a GitHub pre-release. A failed run can be repeated, because
28
+ files already on PyPI are skipped. PyPI never accepts the same version twice, so a bad release needs a new version number.
@@ -0,0 +1,446 @@
1
+ # DESIGN (v0.1, approved 2026-11-12)
2
+
3
+ Status: approved on 2026-11-12; implementation in progress. Evidence and citations are in RESEARCH.md.
4
+ `R§n` points to a section of RESEARCH.md; a plain `§n` points inside this file. Resolved decisions are listed in §15.
5
+
6
+ ## 0. Phase 0 record
7
+
8
+ * Name. `https://pypi.org/pypi/pyribbon/json` returned HTTP 404 on 2026-11-12, and a grep of the full PyPI simple
9
+ index (938,319 projects) for "ribbon" found 21 unrelated names, none `pyribbon`. The name is free today;
10
+ re-check at upload time (PyPI names can be taken at any moment). The unrelated GitHub project called pyribbon
11
+ (a Sonus/Ribbon SBC REST client) is not on PyPI under that name. Decided 2026-11-12: keep `pyribbon`.
12
+ * Gap. No Python ribbon-filter package or BuRR wrapper found on PyPI or GitHub. Ribbon implementations exist in
13
+ Rust (`pleat`, `ribbon-filter`), Go (`ribbonGo`), C++ (RocksDB, `fastfilter_cpp`, `lorenzhs/BuRR`) (R§10).
14
+ * Corrections to the brief. (1) `pyxorfilter` exists but is unusable on Python 3.13/3.14 (no wheel, broken
15
+ sdist, breaks with xxhash 4.x, `in` loops forever); its maintained successor is `pyfusefilter`, which the benchmark
16
+ uses instead (R§12). (2) "A fastfilter_cpp fork with BuRR" is `lorenzhs/fastfilter_cpp`, which pulls the BuRR
17
+ code from the separate `lorenzhs/BuRR` repo. (3) "Balanced" ribbon is a real variant but exists only in the Ribbon
18
+ paper, as experimental, with details left to code (R§2).
19
+
20
+ ## 1. v0.1 scope
21
+
22
+ Variant: standard ribbon, width w = 64, integer fingerprint bits r = 1..32, interleaved column layout.
23
+
24
+ Why standard: FP = 2^-r *exactly* (the fingerprint is independent of the equations), which is what the requirement
25
+ "measured FPR matches 2^-r" and "retry with a new seed on failure" both assume (R§2, R§5). Homogeneous ribbon never
26
+ fails but gives FP = p + (1-p)·2^-r, can exceed 2^-r, and has undetectable FP variance on small sets (R§2). Standard
27
+ ribbon is simpler to prove correct with tests.
28
+
29
+ Why w = 64: one machine word, shifts and parity are single instructions, and the authors advise 32 or 64 on 64-bit
30
+ machines (R§3). Decided 2026-11-12 after a measurement (`benchmarks/research/`, a simulation of the paper's
31
+ elimination, i7-7700): overhead needed for 95% per-attempt success, w = 64 vs w = 128:
32
+
33
+ | slots `m` | 2^10 | 2^14 | 2^17 | 2^20 | 2^22 |
34
+ |---|---:|---:|---:|---:|---:|
35
+ | w = 64 | 4.7% | 6.9% | 9.6% | 11.5% | 12.7% |
36
+ | w = 128 | 2.2% | 2.7% | 3.7% | 4.6% | 5.5% |
37
+
38
+ (These agree with [R Table 1] and RocksDB's tables where they overlap: 4.7% vs 4.9% at 2^10, 3.8% vs 3.8% at 50%
39
+ success and 2^14. RocksDB's extrapolation past 2^17 overstates the w = 64 overhead: ~14% predicted vs 12.7% measured
40
+ at 2^22.) At r = 8 and n = 1e6, w = 64 costs about 9.0 bits/key against Fuse8's 9.04 (pyfusefilter, measured by the
41
+ benchmark harness on a loaded machine), and 8.8 against 9.51 at n = 1e5. Ribbon with w = 64 therefore wins on space at
42
+ small and medium n and is level with Fuse at n = 1e6 and beyond. w = 128 would save a further ~6% of bits per key, but
43
+ its insertion was 2-3x slower in my naive generic code, and it needs a two-word core with 128-bit shifts in the query.
44
+ That is not worth it for v0.1. w = 128 and hard sharding lead the roadmap, and the format already carries the width, so
45
+ adding them is not a format break. Caveat for the README: the space advantage over Fuse is single-digit percent at large n.
46
+
47
+ Deferred to the roadmap (§13): BuRR, Balanced, homogeneous, w = 128, sharding, SIMD, parallel build, fractional r,
48
+ smash, free-threaded wheels.
49
+
50
+ ## 2. Parameters
51
+
52
+ | Parameter | Default | Range | Justification |
53
+ |---|---|---|---|
54
+ | width `w` | 64 | fixed (stored in header as `width_log2`) | §1 |
55
+ | `fp_bits` r | 8 | 1..32 | FP ≈ 2^-r. 8 matches Xor8/Fuse8 (0.39%) for like-for-like benchmarks; ≤ 32 keeps the per-slot result in a `uint32_t` |
56
+ | rows `m` | `roundup64(ceil(n·(1+ε(n))))`, min 128 | multiple of 64 | the interleaved layout needs `m % 64 == 0`; `m ≥ 2w` so that the number of start positions `m-w+1 > w` and every window has a following block (R§9) |
57
+ | overhead ε(n) | table at n = 2^7..2^24 interpolated linearly in n (6.6% at 2^7, 5.3% at 2^10, 9.5% at 2^16, 12.5% at 2^20, 15.5% at 2^24); above 2^24 +0.7 pp per doubling | `overhead=` override | measured with the real derivation and insertion code (`make calibrate`, `core/tools/overhead_table.py`): 3rd percentile of the fill at first failure, i.e. 97% per-attempt success, expected attempts ≈ 1.03. Above 2^24 slots it is extrapolated from the measured slope and unmeasured. |
58
+ | `max_attempts` | 32 | 1..255 | with 5% per-attempt failure, 32 attempts is far below any realistic failure rate; RocksDB uses 256 with a Bloom fallback (R§9); we raise instead |
59
+ | retry policy | new seed every attempt; from the 3rd attempt also grow `m` by `(w+1)/w` | | reseeding is the primary mechanism; growth is the paper's alternative [R p9] and guarantees termination even if the fit is wrong for some `n` (R§5) |
60
+ | `seed` | 0 | 0..2^64-1 | user domain separation; stored in the header |
61
+
62
+ Validation (`make ovh-sweep`, real library, an independent key range per trial; 4000 trials for n <= 1000, 1500 for
63
+ 3e3-1e4, 300 for 1e5, 60 for 1e6): per-attempt failure at the default overhead is 0.0% (n=100), 0.85% (300), 0.10% (1e3),
64
+ 1.8% (3e3), 2.1% (1e4), 3.3% (1e5), 3.3% (1e6); mean attempts <= 1.033; so the acceptance bound (failure <= 7%, mean attempts
65
+ <= 1.1) holds at every size. Resulting size at r = 8 is 8.70 / 8.60 / 8.79 / 8.99 bits/key, payload only. The 64-byte header adds
66
+ 512/n bits per key, so `bits_per_key` reads 9.22 at n = 1e3, 8.65 at 1e4, 8.79 at 1e5, 8.99 at 1e6. Tiny sets are padded to
67
+ the 128-row minimum: 10.24 bits/key payload, 15.4 with header at n = 100. Two measurement pitfalls were found along the way,
68
+ and both are fixed:
69
+ (1) the first version mixed the seed into the key hash by XOR, so dense integer keys gave overlapping hash sets for nearby
70
+ seeds (`seed=1` rebuilt almost the filter of `seed=0`); (2) trials that re-use the same keys share hash values and are
71
+ correlated, which had inflated one failure-rate estimate to 12% against 4% for independent trials.
72
+
73
+ On failure the build raises `ConstructionError` after `max_attempts`; a filter that did not pass banding is
74
+ never returned. `n = 0` produces a valid empty filter (header only, `num_rows = 0`, every query false).
75
+
76
+ ## 3. Hashing (key → bytes → 64 bits → ribbon coordinates)
77
+
78
+ Python's built-in `hash()` is never used (randomized per process; breaks saved filters).
79
+
80
+ ### 3.1 Key to master hash `H` (hash id 1, frozen by spec; any change bumps `hash_id`)
81
+
82
+ | Key type | Rule |
83
+ |---|---|
84
+ | `bytes`, `bytearray`, `memoryview` (C-contiguous) | payload bytes `P`; `h0 = XXH3_64bits(P)` (seed 0, default secret). A `memoryview` is treated as raw bytes regardless of its format. |
85
+ | `str` | UTF-8 bytes (strict; a lone surrogate raises `UnicodeEncodeError`); `h0 = XXH3_64bits(P) ^ TAG_STR` |
86
+ | `int` (or anything with `__index__`) in `[-2^63, 2^63)` | `h0 = (uint64_t)value ^ TAG_INT` (two's complement of the numeric value; no byte order involved) |
87
+ | any other `int` | `P` = little-endian two's complement of exactly `value.bit_length() // 8 + 1` bytes (one canonical encoding per value); `h0 = XXH3_64bits(P) ^ TAG_BIGINT` |
88
+ | anything else (`float`, `None`, tuples, ...) | `TypeError`. No implicit `str()`: `51` and `"51"` are different keys and floats are not keys |
89
+
90
+ `TAG_STR = 0x6a09e667f3bcc908`, `TAG_INT = 0xbb67ae8584caa73b`, `TAG_BIGINT = 0x3c6ef372fe94f82b` (SHA-512 initial
91
+ values, chosen as nothing-up-my-sleeve), and the bytes tag is 0. So `b"a"`, `"a"` and `97` are distinct keys. Then
92
+ `H = mix64(h0 + mix64(seed + GAMMA))`. The seed is pre-mixed and added. An earlier draft XORed it into `h0`; a
93
+ measurement showed that for dense integer keys the hash values then overlap across nearby seeds (`{i ^ seed}` is nearly the same set), so
94
+ `seed=1` rebuilt almost the same filter as `seed=0` and trial outcomes were correlated. A random 64-bit offset makes every seed an
95
+ independent hash universe.
96
+
97
+ `mix64` is the SplitMix64 finalizer (Vigna; public domain) and `GAMMA = 0x9e3779b97f4a7c15`:
98
+ `z = (z ^ z>>30)·0xbf58476d1ce4e5b9; z = (z ^ z>>27)·0x94d049bb133111eb; return z ^ z>>31`.
99
+
100
+ * XXH3-64 is vendored (`xxhash.h`, BSD-2-Clause, notice shipped in sdist and wheels) with
101
+ `XXH_FORCE_MEMORY_ACCESS=0` (the default mode let GCC miscompile in BuRR's experience, R§10), scalar mode, fixed seed.
102
+ Why XXH3: the only candidate with a written output-stability promise, a spec, in-repo test vectors, and
103
+ byte-order-independent output (R§11). Fallback if you object to a 7.5k-line vendored header: SipHash-2-4 (CC0, 185
104
+ lines, ~5x slower on short keys per third-party numbers).
105
+ * The int path is defined on the numeric value and `mix64` is a bijection, so distinct int64 keys never collide in `H`.
106
+ * Distinct bytes/str keys collide in `H` with probability ≈ n²/2^65 (0.027 expected colliding pairs at n = 1e9). A
107
+ collision, like a duplicate key, yields identical equations; standard ribbon accepts these as redundant (R§4), so it
108
+ costs nothing but one possible false positive.
109
+
110
+ ### 3.2 `H` to ribbon coordinates (independent 64-bit words, own derivation)
111
+
112
+ RocksDB-style designs stretch one 64-bit hash into the start, the coefficient row and the fingerprint with a
113
+ multiplication, and for w = 128 only 64 bits of entropy back a 128-bit row (R§9). A bare multiply has no avalanche
114
+ (measured: max deviation 0.5, R§11). We use independent words instead:
115
+
116
+ ```
117
+ salt_a = mix64((a + 1) * GAMMA) # a = attempt index, stored in the header
118
+ S = H ^ salt_a
119
+ z0 = mix64(S + 1*GAMMA); z1 = mix64(S + 2*GAMMA); z2 = mix64(S + 3*GAMMA)
120
+ start = mulhi64(z0, m - 63) # floor(z0 * (m-w+1) / 2^64), in [0, m-64]
121
+ coeff = z1 | 1 # bit 0 forced to 1 (the row at `start`), [R p3]
122
+ fp = z2 >> (64 - r) # top r bits
123
+ ```
124
+
125
+ The seed is mixed *after* `H` is already a full-avalanche value, so retries are not correlated the way "add the seed
126
+ to the key before hashing" is (R§11). XXH3's own seed argument is not used (weak in third-party SMHasher3 runs, R§11).
127
+ The builder hashes each key once into a `uint64_t H[n]` array (8 bytes/key of temporary memory) and every retry
128
+ recomputes only the cheap derivation. Clean-room note: this derivation is my construction from the papers' requirements
129
+ (R§6: fastrange on high bits, forced low coefficient bit, independent fingerprint); it uses none of RocksDB's constants.
130
+
131
+ ## 4. C core
132
+
133
+ C11, no dependencies besides the vendored `xxhash.h`, no global state, no atomics, no `malloc` inside queries,
134
+ no VLAs. Layout: `core/include/ribbon.h`, `core/src/*.c`, `core/tests/*.c`, `core/fuzz/`, `core/vendor/`. Builds and
135
+ tests with `make -C core test` and no Python.
136
+
137
+ ```c
138
+ typedef struct rb_filter rb_filter; /* opaque */
139
+ typedef enum {
140
+ RB_OK = 0, RB_E_INVALID_ARG, RB_E_NOMEM, RB_E_CONSTRUCT /* all attempts failed */,
141
+ RB_E_TOO_LARGE, RB_E_TRUNCATED, RB_E_MAGIC, RB_E_VERSION, RB_E_FORMAT, RB_E_CHECKSUM
142
+ } rb_status;
143
+ typedef enum { RB_KEY_BYTES = 0, RB_KEY_STR = 1, RB_KEY_BIGINT = 2 } rb_key_kind;
144
+ typedef struct { uint32_t fp_bits; uint32_t max_attempts; double overhead; /* <0: auto */ uint64_t seed; } rb_params;
145
+ typedef struct { uint32_t attempts; uint64_t num_rows; } rb_build_info;
146
+
147
+ rb_params rb_params_default(void);
148
+ const char *rb_strerror(rb_status status);
149
+ uint64_t rb_hash_bytes(const void *data, size_t len, uint64_t seed, rb_key_kind kind); /* section 3.1 */
150
+ uint64_t rb_hash_i64(int64_t value, uint64_t seed);
151
+ double rb_default_overhead(uint64_t n); /* the sizing table, section 2 */
152
+
153
+ rb_status rb_build(const uint64_t *key_hashes, size_t n, const rb_params *p, rb_filter **out, rb_build_info *info);
154
+ rb_status rb_from_buffer(const void *buf, size_t len, int verify_payload, rb_filter **out); /* zero copy: caller keeps buf alive */
155
+ void rb_free(rb_filter *f);
156
+ int rb_contains(const rb_filter *f, uint64_t key_hash);
157
+ void rb_contains_many(const rb_filter *f, const uint64_t *hashes, size_t n, uint8_t *out);
158
+ size_t rb_serialized_size(const rb_filter *f);
159
+ rb_status rb_serialize(const rb_filter *f, void *dst, size_t cap);
160
+ /* accessors: rb_num_keys, rb_num_rows, rb_fp_bits, rb_seed */
161
+ ```
162
+
163
+ * The core takes key hashes as input. Hashing is a separate step, so the Python layer can hash Python objects and
164
+ call the core on a `uint64_t` array.
165
+ * `rb_filter` copies every header field into the struct at load time and never re-reads the header from the
166
+ buffer. Only payload words are read, at offsets computed from the validated struct; so a buffer mutated after
167
+ loading can give wrong answers but cannot cause an out-of-bounds read (no time-of-check/time-of-use hole).
168
+ * Build. One array of 16-byte slots `{uint64_t coeff; uint32_t res}` (one cache line per insertion), empty slot = `coeff == 0`.
169
+ Keys are processed in chunks of 16: the equations of a chunk are derived and their slots prefetched first, then inserted in
170
+ arrival order, so the result is identical to one-at-a-time insertion (the byte-for-byte vectors prove it).
171
+ Per key: `i = start; c = coeff; b = fp`; loop { if slot `i` empty, store, done; `c ^= coeff[i]; b ^= res[i]`; if
172
+ `c == 0`: redundant if `b == 0`, else failure; `j = ctz(c); i += j; c >>= j` } (R§4). `c` always has bit 0 set before
173
+ the XOR and `c != 0` before `ctz`, so `1 ≤ j ≤ 63` and no shift reaches the type width. No pre-sort (R§4).
174
+ Back-substitution runs `i = m-1 .. 0`, keeps one 64-bit running word per column, and writes the interleaved layout
175
+ directly, one flushed block of `r` words per 64 rows (R§4).
176
+ * Query. `s = start`, `blk = s >> 6`, `off = s & 63`; for each column `j < r`: the result bit is
177
+ `parity(W[blk][j] & (c << off)) ^ parity(W[blk+1][j] & (c >> (64 - off)))` (coefficient bit `k` selects row `s+k`; the
178
+ second term covers the bits that spill into the next block), with the second term skipped when `off == 0` (a shift by 64 is undefined behaviour). Early exit at the first mismatching
179
+ column. The last start is `m-64`, i.e. `off == 0` on the last block, so `blk+1` always exists when `off > 0`: no
180
+ read beyond the buffer (R§9).
181
+ * Portable bit ops live in one internal header: `ctz64`, `parity64`, `mulhi64`, `load_le64`/`store_le64`. Selection by
182
+ `#if`: GCC/Clang builtins (`__builtin_ctzll`, `__builtin_parityll`, `unsigned __int128`); MSVC (`_BitScanForward64`
183
+ on x64/ARM64, `_umul128` on x64, `__umulh` on ARM64; no `__popcnt64`, since parity folds with XORs and needs no
184
+ POPCNT); and pure-C fallbacks (de Bruijn `ctz`, XOR-fold parity, 32x32 split multiply). The fallbacks are compiled
185
+ and tested on Linux by forcing `-DRB_PORTABLE=1`. All integers are `uint64_t`/`uint32_t`, never `long` (32-bit on
186
+ Win64). `load_le64` is `memcpy` plus a byte swap on big-endian hosts, so no unaligned or endianness assumption.
187
+ * Warnings: `-std=c11 -Wall -Wextra -Wconversion -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Werror`.
188
+ * Sizing is integer arithmetic. The overhead table is stored in parts per million and interpolated with integer division, a
189
+ user `overhead=` is rounded once to whole ppm, and the row count is `ceil(n·(1e6 + ppm) / 1e6)`: no floating-point
190
+ contraction on any platform can change a row count, so the same keys, parameters and seed give the same bytes everywhere
191
+ (the Python reference mirrors the integer code and the byte-for-byte vectors cover it). `n ≤ RB_MAX_KEYS = 2^38` and
192
+ `ppm ≤ 4e6` keep `n·(1e6 + ppm)` below 2^61; rows above 2^39 are `RB_E_TOO_LARGE`. `rb_build` leaves `*info` untouched on failure.
193
+
194
+ ## 5. Serialization (format v1, little-endian, all fields explicit)
195
+
196
+ | Offset | Size | Field | Notes |
197
+ |---:|---:|---|---|
198
+ | 0 | 8 | magic | `"PYRIBBON"` |
199
+ | 8 | 2 | `version` | 1; a reader rejects other versions (`RB_E_VERSION`) |
200
+ | 10 | 2 | `header_size` | 64 |
201
+ | 12 | 1 | `width_log2` | 6 |
202
+ | 13 | 1 | `fp_bits` | 1..32 |
203
+ | 14 | 1 | `hash_id` | 1 (§3) |
204
+ | 15 | 1 | `flags` | 0; reserved bits must be zero |
205
+ | 16 | 8 | `num_keys` | inputs counted, duplicates included; never used for addressing, but validated: 0 iff `num_rows == 0`, and ≤ `num_rows` (every filter `rb_build` makes has `num_rows ≥ num_keys`) |
206
+ | 24 | 8 | `num_rows` | 0, or a multiple of 64 in [128, 2^40] |
207
+ | 32 | 8 | `seed` | user seed |
208
+ | 40 | 4 | `attempt` | seed index that succeeded |
209
+ | 44 | 4 | reserved | 0 |
210
+ | 48 | 8 | `payload_xxh3` | XXH3-64 of the payload |
211
+ | 56 | 8 | `header_xxh3` | XXH3-64 of bytes 0..55 |
212
+ | 64 | `num_rows/64·r·8` | payload | for block `k`, `r` words; word `j` holds column `j` for rows `64k..64k+63`, row `64k+i` at bit `i` |
213
+
214
+ Loader rules (the input is untrusted), checked in this order (the first failure decides the status, and
215
+ `tests/spec_reference.py` mirrors the order): (1) `len ≥ 64`, else TRUNCATED; (2) magic (MAGIC), version (VERSION),
216
+ `header_size == 64` (FORMAT); (3) `header_xxh3` matches (CHECKSUM); (4) `width_log2 == 6`, `hash_id == 1`, `flags == 0`,
217
+ reserved `== 0`, `fp_bits` in 1..32 (FORMAT); (5) `num_rows` is 0 or a multiple of 64 in [128, 2^40] (FORMAT); (6) `num_keys`
218
+ is 0 iff `num_rows == 0`, and `num_keys ≤ num_rows` (FORMAT; this keeps `len()` of a loaded filter meaningful);
219
+ (7) `payload_len = num_rows/64·r·8` (at most 2^42, so it cannot overflow 64 bits given rule 5), the buffer holds fewer
220
+ bytes (TRUNCATED) or more (FORMAT: slice a memoryview to embed a filter in a larger buffer) than `64 + payload_len`;
221
+ (8) if `verify_payload`, `payload_xxh3` matches (CHECKSUM; reads every byte once). `from_buffer(verify=False)` skips only
222
+ rule 8, which keeps an mmapped filter lazy; rules 1-7 are O(1) and always run, so even the unverified path cannot read out of
223
+ bounds. The loader copies the 64 header bytes into a local buffer first and parses only that copy, so a concurrent writer to
224
+ the buffer cannot make validation and use see different header values. Payload corruption under `verify=False` can only change
225
+ query answers (`attempt` is also taken as-is). The checksum detects accidents, not tampering (it is not a MAC), and a file
226
+ truncated by another process while memory-mapped faults on access (SIGBUS), which no library can prevent.
227
+
228
+ Zero copy: a loaded filter keeps a pointer into the caller's buffer; the payload is read with `load_le64`, so the same
229
+ file works on little- and big-endian hosts without a conversion pass. Nothing is allocated per query.
230
+
231
+ ## 6. Python binding
232
+
233
+ Recommendation: a thin hand-written CPython C extension, built with setuptools, limited API (abi3, floor 3.11) for
234
+ 3.11+ and the same source built non-limited for 3.10 (R§12). Why: the same per-call cost as the full API and
235
+ the best of the three options measured (a trivial call costs 32-34 ns vs 41 Cython `def` vs 175-267 cffi, R§12); zero runtime
236
+ dependencies; build dependencies are setuptools and a C compiler; an sdist builds with only `cc`. Cython and cffi are
237
+ rejected partly on speed; they also add a build or runtime dependency without a measured gain, and cffi
238
+ cannot define `__contains__` as a type slot. meson-python is rejected (needs a VCS checkout for sdist, breaks on 3.10
239
+ and 3.14t); scikit-build-core is the runner-up if CMake is ever wanted.
240
+
241
+ * Python 3.10. `Py_buffer` entered the stable ABI in 3.11, so 3.10 gets its own non-limited wheel from the same
242
+ source (about 10 lines of conditional logic in `setup.py`). 3.10 reached end of life on 2026-10-01, but it is kept (decided
243
+ 2026-11-12) after checking pypistats (past month): 3.10 is still 12.8% of numpy's downloads and 16.7% of cryptography's.
244
+ The cost is one extra wheel per platform; dropping it later removes that wheel and the conditional.
245
+ * Free-threaded (3.14t) has no abi3, and `Py_MOD_GIL_NOT_USED` is not declared before testing: v0.1 skips
246
+ `*t-*` wheels, so a free-threaded interpreter builds from the sdist and re-enables the GIL on import. Roadmap item.
247
+ * GIL. Single-key `__contains__` never releases the GIL (the save/restore costs more than the ~30 ns of work, R§12).
248
+ `build` hashes keys with the GIL held (it touches Python objects; the integer-buffer path below hashes without it), then releases the GIL for banding and back
249
+ substitution (pure C, can take seconds). `contains_many` hashes into a temporary array with the GIL held and
250
+ releases it for the probe loop when the batch is ≥ 1024 keys (an untuned threshold; a 4-thread test shows about 1.7x
251
+ the single-thread time, against about 4x with the GIL held). Between the
252
+ macros the code touches no Python object, refcount, `PyErr_*` or `PyMem_*` allocator (R§12).
253
+ * Thread safety. An immutable filter is safe to query from many threads at once: no field is written after
254
+ construction, there is no lazy state, and the core has no globals. Concurrent builds are independent. `free` of the last
255
+ reference cannot race a running query, because a query holds a reference. The docs state this, and
256
+ `tests/test_concurrency.py` exercises it (8 threads on one shared filter; concurrent builds; GIL-release checks).
257
+ * Buffers. `from_buffer` calls `PyObject_GetBuffer(PyBUF_SIMPLE)` once and holds the `Py_buffer` until
258
+ deallocation. Accepted: any C-contiguous buffer (`bytes`, `mmap`, `memoryview`, `bytearray`). While a filter exists,
259
+ Python blocks resizing a `bytearray` and closing an `mmap` (`BufferError`, R§12), but in-place writes to a
260
+ `bytearray` or a writable `mmap` are not blocked and would silently change query answers (memory-safe, but
261
+ false negatives are possible). The docs say "do not mutate the buffer"; a read-only source (`bytes`, `mmap.ACCESS_READ`) is the
262
+ recommended path. Writable buffers are not rejected, because the default `mmap` mode is writable.
263
+ * Length hints. `__len__` of `keys` is only a hint: it may lie or be astronomically large (`range(2**61)`), so the
264
+ preallocation is capped at 2^20 keys and growth is geometric; a `__len__` that raises `TypeError` means "unsized" and is
265
+ ignored, any other exception (including `KeyboardInterrupt`) propagates. An uncapped `hint * 8` once wrapped to 0 bytes and
266
+ corrupted the heap (found by the independent review; regression-tested with hints up to 2^63-1).
267
+ * `int` subclasses are hashed by value: the bigint encoding runs on the exact `int` (`PyNumber_Index`), so a subclass that
268
+ overrides `bit_length` or `to_bytes` cannot change a key's hash.
269
+ * Subinterpreters. The module keeps all state in per-module state and has no mutable globals, but the abi3 wheel targets the
270
+ 3.11 limited API, where the `Py_mod_multiple_interpreters` slot does not exist: isolated (own-GIL) subinterpreters on 3.12+
271
+ refuse the import; shared-GIL subinterpreters work (sanitizer-clean). Exception types fall back to `RuntimeError` if a
272
+ filter outlives module teardown.
273
+ * GC. A filter made by `from_buffer` holds a reference to the buffer's owner, which could refer back to it, so the type is
274
+ GC-tracked (traverse + clear, like `memoryview`). `tp_clear` frees the core filter *before* releasing the buffer and leaves
275
+ the object in a released state in which every method raises `ValueError`. Found by the leak tests.
276
+ * Key arrays. A 1-D C-contiguous buffer of integers (`array.array`, numpy) passed as `keys` is treated as an array of int
277
+ keys and hashed in C without the GIL, with exactly the hashes the same ints would get one by one (uint64 values >= 2^63 take
278
+ the bigint encoding). Non-contiguous, multi-dimensional, big-endian-format or float buffers fall back to plain iteration (and so to
279
+ its errors). `str`, `bytes`, `bytearray` and `memoryview` passed as `keys` raise `TypeError`: iterating one key's characters
280
+ or bytes is almost always a bug. The benchmark reports both batch variants (`docs/benchmark.md`).
281
+ * Types. Ship `py.typed` and `_ribbon.pyi` (stubs for the C module), checked with `mypy.stubtest` in CI so the stubs
282
+ cannot drift from the compiled module.
283
+
284
+ ## 7. Python API (immutable filter; names decided by me as best practice, approved 2026-11-12)
285
+
286
+ ```python
287
+ class RibbonFilter:
288
+ def __init__(
289
+ self,
290
+ keys: Iterable[Key],
291
+ *,
292
+ fp_bits: int = 8,
293
+ seed: int = 0,
294
+ overhead: float | None = None,
295
+ max_attempts: int = 32,
296
+ ) -> None: ...
297
+ def __contains__(self, key: Key) -> bool: ...
298
+ def contains_many(self, keys: Iterable[Key]) -> list[bool]: ...
299
+ def __len__(self) -> int: ... # number of keys supplied; duplicates are counted
300
+ def to_bytes(self) -> bytes: ...
301
+ @classmethod
302
+ def from_buffer(cls, buffer: Buffer, *, verify: bool = True) -> RibbonFilter: ... # zero copy
303
+ @property
304
+ def size_in_bytes(self) -> int: ... # len(to_bytes()), header included
305
+ @property
306
+ def bits_per_key(self) -> float: ... # 8 * size_in_bytes / len(self); 0.0 for an empty filter
307
+ @property
308
+ def fp_bits(self) -> int: ...
309
+ def __repr__(self) -> str: ... # RibbonFilter(keys=1000, fp_bits=8, size_in_bytes=1146)
310
+ def __reduce__(self) -> ...: ... # pickle and copy work: rebuilt from to_bytes()
311
+
312
+
313
+ Key = bytes | bytearray | memoryview | str | int # §3.1
314
+
315
+
316
+ class RibbonError(Exception): ...
317
+
318
+
319
+ class ConstructionError(RibbonError): ... # all attempts failed
320
+
321
+
322
+ class FormatError(RibbonError, ValueError): ... # bad/truncated/corrupt serialized data
323
+ ```
324
+
325
+ Notes: there is no `add` or `remove` (the structure is static); `__contains__` on `bool` is an `int`. `len()` counts
326
+ supplied keys because deduplicating would need a sort, so `bits_per_key` includes any duplicates in its denominator.
327
+ Builds are deterministic and independent of the order of the keys (the set of pivots and the zero-free-variables
328
+ solution are order-invariant; property-tested, including builds that needed retries). Single-pass iterables are
329
+ supported (the keys are hashed as they stream into the temporary array; a `__len__` lets the array be preallocated).
330
+ `from_buffer` copies nothing and raises `FormatError` (never crashes) on bad input. Errors for wrong key types are
331
+ `TypeError`; for out-of-range arguments `ValueError`.
332
+
333
+ ## 8. Packaging
334
+
335
+ * Python ≥ 3.10; MIT licence, confirmed (`license-files = ["LICENSE", "LICENSES/*"]`); `src/pyribbon/`
336
+ layout and `[tool.ruff]`/`[tool.mypy]` blocks matching your other repos; `dependencies = []`.
337
+ * Build backend setuptools ≥ 77; `MANIFEST.in` puts `core/` (including the Makefile and vendored header) in the sdist,
338
+ so `pip install` from the sdist needs only a C compiler.
339
+ * Wheels via cibuildwheel 4.x: `skip = "*-win32 *-manylinux_i686 *-musllinux_i686 *t-*"`; macOS `x86_64` (on the Intel runner)
340
+ and `arm64` (on the Apple Silicon runner, cibuildwheel's default `auto` per runner);
341
+ Windows `AMD64` only (ARM64 is untested); manylinux_2_28 and musllinux_1_2. Result per platform:
342
+ a `cp310` wheel and a `cp311-abi3` wheel (R§12). Wheel tests run `pytest` against the installed wheel. Sanitizer
343
+ builds run as a separate CI job on a plain runner, outside cibuildwheel. Verified in CI on 2026-11-12: the wheels build and pass the
344
+ test suite on Linux x86-64 and ARM (manylinux_2_28 and musllinux_1_2), macOS Intel and Apple silicon, and Windows x64.
345
+ * Releases: `release.yml` runs the full tests, builds the sdist and the wheels, and publishes to PyPI (secret
346
+ `PYPI_API_TOKEN`) and to GitHub releases when a `v*` tag is pushed. Only the `publish` job can publish, and it runs only for a
347
+ push event on a `v*` tag; a manual run is a dry run, even from a tag. `tests/test_release_workflow.py` enforces that and
348
+ checks that no other workflow can publish. No tag has been pushed and nothing is on PyPI.
349
+
350
+ ## 9. Repository layout and `make` targets
351
+
352
+ ```
353
+ core/{include,src,tests,tools,fuzz,vendor} C library, C unit tests, calibration/report tools, loader fuzz harness; own Makefile
354
+ src/pyribbon/{__init__.py,_ribbon.c,_ribbon.pyi,py.typed}
355
+ tests/ (pytest + hypothesis + spec_reference.py) examples/ (numbered scripts, run by a test) benchmarks/ (bench.py, research/) docs/ (generated results)
356
+ Makefile pyproject.toml setup.py MANIFEST.in LICENSE README.md CHANGELOG.md RESEARCH.md DESIGN.md
357
+ ```
358
+
359
+ `make`: `venv` · `install-dev` · `test` (C unit + pytest) · `lint` (ruff check + format check) · `docstyle` · `typecheck`
360
+ (mypy strict + stubtest) · `check` (lint, docstyle, typecheck, test) · `c-test` · `c-asan` · `c-portable` · `c-fuzz` ·
361
+ `ovh-sweep` / `calibrate` (the sizing table) · `fpr-report` / `stats` (measured false-positive rates and failure rates,
362
+ `docs/measurements.md`) · `bench` (writes `docs/benchmark.png`, `.md` and `.json`) · `build` (sdist, wheel, `twine check`) · `clean`. Docstring checks use ruff's `D` rules
363
+ with `convention = "google"` (the same rules as your `pydocstyle --convention=google`, one tool fewer).
364
+
365
+ ## 10. Testing and evidence plan (maps to your requirements)
366
+
367
+ All rows are implemented (`tests/`, `core/tests/`, `core/tools/`); results are in `docs/measurements.md` and `docs/benchmark.md`.
368
+
369
+ | Requirement | How |
370
+ |---|---|
371
+ | No false negatives | Hypothesis for n from 0 to a few thousand (boundaries 0, 1, 63, 64, 65, 127, 128, 129 forced), all key types, `fp_bits` 1..32, seeds, duplicates; fixed parametrized tests at n = 1e5 and 1e6; C tests with fixed seeds; same keys through `contains` and `contains_many` |
372
+ | Measured FPR ≈ 2^-r | for r ∈ {1,4,8,12,16}: ≥ 1e8 disjoint negative probes for r ≥ 8 (≥ 1e7 for r < 8), assert the count lies within 5σ of the binomial mean; test structured keys (sequential ints, short strings, bit patterns) as well as random; report every number |
373
+ | Construction behavior | measure failure rate vs `overhead` for n ∈ {1e2..1e7} (thousands of trials at small n), fit and *replace* ε(n) if §2's formula misses its acceptance bound; failure raises `ConstructionError` (forced by `overhead=0.0, max_attempts=1`); duplicates and the empty set |
374
+ | Hash and derivation | XXH3 outputs checked against the `xxhash` PyPI package as a test oracle (dev dependency only); known-answer vectors for `H` and `(start, coeff, fp)` committed as the format spec; avalanche test on the derivation words |
375
+ | Serialization | round-trip; every truncation length; single-bit flips over the header (must be rejected, by a field rule or the checksum); a differential fuzz of the loader against the independent Python reference (accept/reject decision and error class agree on every mutant); payload flips with `verify=True` (rejected) and `verify=False` (still memory-safe); hand-crafted hostile headers (huge `num_rows`, overflowing sizes); libFuzzer on `rb_from_buffer` + `rb_contains` under ASan/UBSan; the same corpus run under pytest |
376
+ | Sanitizers, warnings | ASan/UBSan for the C tests and the extension in a separate CI job; `-Werror` set above; forced `RB_PORTABLE=1` build |
377
+ | Memory | build/free and load/free in a loop, assert RSS growth under a threshold; ASan leak check on the C tests |
378
+ | Concurrency | threads querying one filter (`contains` and `contains_many`), results compared to a single-thread run; threads building different filters; GIL-release timing check |
379
+
380
+ ## 11. Benchmarks (`make bench` reproduces everything)
381
+
382
+ * Compare against `pyfusefilter` Xor8, Xor16, Fuse8, Fuse16 (in place of the unusable `pyxorfilter`, R§12) and
383
+ `rbloom` as the Bloom baseline: maintained, native Rust, MIT, abi3 wheels on 3.10-3.14, a settable FPR and a readable
384
+ size. A pure-Python Bloom would be a straw man (1.5-13 µs/probe measured). `fastbloom-rs` is an optional second Bloom
385
+ because it alone has a batch query.
386
+ * Same keys, same measured FPR: each filter is sized for the same target, then its FPR is *measured* with the same
387
+ negative probes and bits/key is taken at the measured FPR (and reported with it, since fingerprint widths are
388
+ discrete: 8 vs 16 bits, and Bloom is continuous).
389
+ * Metrics: bits/key at matched measured FPR; build time; single-query latency; batch throughput; n ∈ {1e3, 1e4, 1e5,
390
+ 1e6, 1e7}.
391
+ * Method: print CPU, OS, Python, compiler and flags; pin the process to a core; report median and spread over
392
+ ≥ 7 runs; keys pre-generated, so key creation is outside the timed region.
393
+ * Fairness caveats (written into the output): `pyfusefilter` hashes keys in Python (`str()` + xxhash) per call and
394
+ has no batch query, so its latency includes that cost (reported both as shipped and, where possible, with the
395
+ per-key Python hashing separated); `rbloom` needs an explicit `hash_func` (ints hash to themselves, `bytes` are
396
+ salted per process); the box runs a `powersave` governor, so absolute numbers are comparable between libraries within
397
+ one run but not across machines.
398
+ * Where I expect to lose (to be confirmed or refuted by the data): build time against fuse and Bloom, and
399
+ space against Fuse at n ≥ 1e6 (§1). The README's comparison table will be generated from the benchmark output.
400
+
401
+ ## 12. CI quality gates
402
+
403
+ Lint (ruff), strict mypy plus stubtest on the typed surface, Google-style docstring checks, pytest on 3.10 to 3.14,
404
+ Windows and macOS test jobs, a sanitizer job (ASan + UBSan, C and extension), a C-warnings-as-errors job (`-Werror`, gcc and
405
+ clang, forced portable fallback), a loader fuzz job (libFuzzer where available), an sdist-builds-with-plain-cc job, a
406
+ cibuildwheel job (build and test, no upload), a benchmark smoke run (`--quick`), and a weekly job for the slow statistical tests. Existing conventions kept: actions/checkout and setup-python, `permissions:
407
+ contents: read`.
408
+
409
+ ## 13. Roadmap (not v0.1)
410
+
411
+ 1. w = 128 (two-word core) and/or hard sharding, to flatten the overhead growth at large n (§1), with per-shard
412
+ retry and bounded temporary memory. First roadmap item because it is the main reason to prefer ribbon over fuse at scale.
413
+ 2. Homogeneous variant (no failure, FP caveats, R§2); fractional `fp_bits` (floor/ceil columns); smash for small `m`.
414
+ 3. BuRR (0.06-1.4% overhead, but bucket sort, rollback, per-bucket thresholds and 4-layer recursion) and Balanced.
415
+ 4. SIMD queries (AVX512 POPCNT / 8-bit interleaving), parallel build (bump `w` positions per
416
+ block to cut the system into independent blocks, R§7).
417
+ 5. Free-threaded wheels and testing (3.14t, 3.15t); abi3t once 3.15 is the floor (also enables isolated subinterpreters); 32-bit and big-endian CI; `bytearray`-safe loading; `memoryview(filter)` zero-copy export.
418
+ 6. Cache effect of sorted vs random insertion for standard ribbon (not measured in the papers, R§4).
419
+
420
+ ## 14. Risks and open questions
421
+
422
+ * The sizing table is measured on the real core up to 2^24 slots (n ≈ 1.4e7); above that it is extrapolated at the measured
423
+ slope and unmeasured. The retry-with-growth rule limits the damage, though it does not bound the speed.
424
+ * XXH3 is vendored from a 7.5k-line header. Its stability promise and test vectors are taken on trust, and the header has not been audited.
425
+ * Build memory: the banding array takes 16 bytes/slot (about 18 bytes/key at the w=64 overhead) plus 8 bytes/key for `H`,
426
+ roughly 26 bytes/key, about 2.6 GB at `n = 1e8`, until sharding exists.
427
+ * Never built or run: 32-bit and big-endian machines, Windows ARM, and free-threaded CPython. The local machine has no clang, so
428
+ the libFuzzer build is exercised only in CI; the built-in mutator runs the same harness locally. Windows, macOS, the
429
+ manylinux/musllinux wheels and cibuildwheel itself were first exercised by the CI runs on 2026-11-12, which found and
430
+ fixed four problems (see the commit history).
431
+ * An independent review of the C core and the extension found one high-severity extension bug (an overflowing length-hint
432
+ preallocation) and several lower-severity items; all actionable ones are fixed and regression-tested (§6).
433
+ * I read RocksDB-derived code through research notes; the implementation derives every step from [R]/[B] (R§9, R§10),
434
+ uses no RocksDB constants and no pleat/ribbonGo test vectors, and cites its sources in code comments.
435
+ * Patents on ribbon filters were not searched (R§9).
436
+
437
+ ## 15. Decisions (resolved 2026-11-12)
438
+
439
+ 1. Name `pyribbon`: kept (free on PyPI on 2026-11-12; re-check before any upload).
440
+ 2. Licence: MIT, plus the BSD-2 xxHash notice for the vendored hash.
441
+ 3. Public API and names (§7): left to me, "use best practice". Choices: `RibbonFilter`, keyword-only options, `list[bool]`
442
+ from `contains_many`, `TypeError` for unsupported key types (never a silent `False`), `FormatError` that is also a
443
+ `ValueError`, `__repr__`, `__reduce__` so pickle, `copy` and multiprocessing work.
444
+ 4. Width: w = 64, decided by the width study (§1). w = 128 and sharding lead the roadmap.
445
+ 5. Python 3.10: kept, decided by the download shares (§6).
446
+ 6. Benchmark `pyfusefilter` instead of `pyxorfilter`: yes (§0, §11).
pyribbon-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yoav Haimov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,26 @@
1
+ xxHash Library
2
+ Copyright (c) 2012-2021 Yann Collet
3
+ All rights reserved.
4
+
5
+ BSD 2-Clause License (https://www.opensource.org/licenses/bsd-license.php)
6
+
7
+ Redistribution and use in source and binary forms, with or without modification,
8
+ are permitted provided that the following conditions are met:
9
+
10
+ * Redistributions of source code must retain the above copyright notice, this
11
+ list of conditions and the following disclaimer.
12
+
13
+ * Redistributions in binary form must reproduce the above copyright notice, this
14
+ list of conditions and the following disclaimer in the documentation and/or
15
+ other materials provided with the distribution.
16
+
17
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
18
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
21
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
22
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
23
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
24
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
26
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,13 @@
1
+ include LICENSE CHANGELOG.md CONTRIBUTING.md DESIGN.md RESEARCH.md stubtest-allowlist.txt
2
+ graft LICENSES
3
+ graft core/include
4
+ graft core/src
5
+ graft core/vendor
6
+ graft core/tests
7
+ graft core/tools
8
+ graft core/fuzz
9
+ include core/Makefile
10
+ graft tests
11
+ graft examples
12
+ prune core/build
13
+ global-exclude *.py[cod] __pycache__ *.so