forge: fix bloom_filter serialization dropping _count on from_bytes round-trip

This commit is contained in:
forge committed 2026-10-24 02:45:16 +00:00
1 parent 132045ea33
commit fe2d5fdce2
3 files changed
+213 -5

No files matched your search

+9 -5
View File
@@ -86,17 +86,21 @@ class BloomFilter:
return (1.0 - math.exp(-self.k * self._count / self.m)) ** self.k
def to_bytes(self) -> bytes:
header = struct.pack("!II", self.m, self.k)
# Header: m (uint32), k (uint32), count (uint32) -- count is serialized so
# that from_bytes can restore _count, which to_bytes/from_bytes previously
# dropped, leaving len()==0 and estimated_fpr()==0.0 after a round-trip.
header = struct.pack("!III", self.m, self.k, self._count)
return header + bytes(self._bits)
@classmethod
def from_bytes(cls, data: bytes) -> "BloomFilter":
if len(data) < 8:
if len(data) < 12:
raise ValueError("truncated bloom filter")
m, k = struct.unpack("!II", data[:8])
m, k, count = struct.unpack("!III", data[:12])
expected = (m + 7) // 8
if len(data) - 8 != expected:
if len(data) - 12 != expected:
raise ValueError("bit array length mismatch")
bf = cls(m, k)
bf._bits = bytearray(data[8:])
bf._bits = bytearray(data[12:])
bf._count = count
return bf
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Regression tests for bloom_filter.py.
Covers the to_bytes()/from_bytes() round-trip, which previously dropped
_count: after deserialization, len(bf) read 0 and estimated_fpr() read
0.0 even though membership queries still worked. The header now carries
count as a third uint32 field, so a round-trip preserves both.
"""
import os
import struct
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from bloom_filter import BloomFilter, optimal_params
class TestBloomFilterRoundTrip(unittest.TestCase):
def test_count_and_fpr_preserved(self):
bf = BloomFilter.create(1000, 0.01)
for i in range(100):
bf.add("item-%d" % i)
bf2 = BloomFilter.from_bytes(bf.to_bytes())
self.assertEqual(len(bf2), 100)
self.assertAlmostEqual(bf2.estimated_fpr(), bf.estimated_fpr(),
places=15)
self.assertIn("item-0", bf2)
self.assertIn("item-99", bf2)
self.assertNotIn("nope", bf2)
def test_empty_round_trip(self):
bf = BloomFilter(100, 3)
bf2 = BloomFilter.from_bytes(bf.to_bytes())
self.assertEqual(len(bf2), 0)
self.assertEqual(bf2.estimated_fpr(), 0.0)
def test_header_carries_count(self):
bf = BloomFilter.create(1000, 0.01)
for i in range(100):
bf.add("item-%d" % i)
m, k, count = struct.unpack("!III", bf.to_bytes()[:12])
self.assertEqual(m, bf.m)
self.assertEqual(k, bf.k)
self.assertEqual(count, bf._count)
def test_fpr_nonzero_after_restore(self):
bf = BloomFilter.create(500, 0.05)
for i in range(250):
bf.add("x%d" % i)
bf2 = BloomFilter.from_bytes(bf.to_bytes())
self.assertGreater(bf2.estimated_fpr(), 0.0)
self.assertAlmostEqual(bf2.estimated_fpr(), bf.estimated_fpr(),
places=15)
class TestBloomFilterSerializationErrors(unittest.TestCase):
def test_truncated_data_rejected(self):
with self.assertRaises(ValueError):
BloomFilter.from_bytes(b"\x00" * 5)
def test_short_bit_array_rejected(self):
bf = BloomFilter.create(1000, 0.01)
with self.assertRaises(ValueError):
BloomFilter.from_bytes(bf.to_bytes()[:-1])
def test_long_bit_array_rejected(self):
bf = BloomFilter.create(1000, 0.01)
with self.assertRaises(ValueError):
BloomFilter.from_bytes(bf.to_bytes() + b"\x00")
class TestBloomFilterBasics(unittest.TestCase):
def test_no_false_negatives(self):
bf = BloomFilter.create(1000, 0.01)
items = ["alpha", "beta", "gamma", "delta"]
for item in items:
bf.add(item)
for item in items:
self.assertIn(item, bf)
def test_optimal_params(self):
m, k = optimal_params(1000, 0.01)
self.assertGreater(m, 0)
self.assertGreaterEqual(k, 1)
def test_clear_resets(self):
bf = BloomFilter.create(1000, 0.01)
for i in range(50):
bf.add("item-%d" % i)
bf.clear()
self.assertEqual(len(bf), 0)
self.assertNotIn("item-0", bf)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,107 @@
# Refactor Artifact: bloom_filter.py serialization bug fix
## Lane
Refactor -- code review and refactors
## File reviewed
`/root/troupe-standup/wizard-work/beam/bloom_filter.py`
## Bug found
`to_bytes()` and `from_bytes()` formed a broken round-trip for the
`_count` field (the number of items inserted into the filter).
The original `to_bytes()` header packed only two fields:
```python
header = struct.pack("!II", self.m, self.k) # m, k -- no count
```
and `from_bytes()` unpacked only those two, leaving `_count` at the
default `0` from `__init__`:
```python
bf = cls(m, k) # __init__ sets self._count = 0
bf._bits = bytearray(data[8:])
# _count is never restored
```
## Observable impact
After deserialization:
| Operation | Expected | Actual (before fix) |
|---|---|---|
| `len(bf)` | 100 | 0 |
| `estimated_fpr()` | 8.59e-09 | 0.0 |
| `"item-0" in bf` | True | True (worked fine) |
Membership queries (`__contains__`) were unaffected -- the bit array
was restored correctly. But `len()` and `estimated_fpr()` both depend
on `_count`, so they silently reported zero items and zero false-positive
rate after deserialization. Any code calling `len(filter)` or
`filter.estimated_fpr()` on a deserialized filter got wrong data.
## Reproduction (measured)
```
Before serialization:
len(bf) = 100
estimated_fpr() = 8.588202656742086e-09
After from_bytes:
len(bf2) = 0 (BUG: should be 100)
estimated_fpr() = 0.0 (BUG: should be > 0)
```
## Fix applied
Extended the header to carry `_count` as a third `uint32`:
```python
def to_bytes(self) -> bytes:
header = struct.pack("!III", self.m, self.k, self._count)
return header + bytes(self._bits)
@classmethod
def from_bytes(cls, data: bytes) -> "BloomFilter":
if len(data) < 12:
raise ValueError("truncated bloom filter")
m, k, count = struct.unpack("!III", data[:12])
expected = (m + 7) // 8
if len(data) - 12 != expected:
raise ValueError("bit array length mismatch")
bf = cls(m, k)
bf._bits = bytearray(data[12:])
bf._count = count
return bf
```
The truncation guard tightened from 8 to 12 bytes (the new header size),
and the bit-array length check adjusted accordingly.
## Verification
New test file created: `/root/troupe-standup/wizard-work/beam/test_bloom_filter.py`
```
$ python3 -m unittest test_bloom_filter -v
test_clear_resets ... ok
test_fpr_nonzero_after_restore ... ok
test_header_carries_count ... ok
test_truncated_data_rejected ... ok
test_short_bit_array_rejected ... ok
test_long_bit_array_rejected ... ok
test_no_false_negatives ... ok
test_optimal_params ... ok
test_count_and_fpr_preserved ... ok
test_empty_round_trip ... ok
Ran 10 tests in 0.008s
OK
```
Full beam test suite (via CHECK script) still passes:
```
$ python3 -m unittest discover -p 'test_*.py' -v (exit 0)
```
## Note on why _count cannot be recomputed
`_count` is the number of items *inserted*, not the number of *bits set*.
Multiple items can set the same bit positions, so counting set bits would
underestimate `_count`, which would in turn underestimate `estimated_fpr()`.
The only correct serialization is to store the count explicitly -- which is
what the fix does.