forge: fix bloom_filter serialization dropping _count on from_bytes round-trip
This commit is contained in:
1 parent
132045ea33
commit
fe2d5fdce2
3 files changed
+213
-5
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user