فهرست مقاله
در مسیرِ ورودِ سند به یک مجموعهٔ جستوجو، گاهی ابتدا میخواهیم بدانیم آیا اصلاً لازم است سراغ بررسیِ دقیقِ سابقه برویم. فیلترِ بلوم برای همین پرسشِ محدود مفید است: بعضی ورودیها را قطعاً خارج از مجموعه میداند و بعضی را فقط نامزدِ بررسی معرفی میکند. این درس یک پیشفیلترِ کوچک و مجموعهٔ دقیقِ همراهش میسازد. هیچ سندی حذف نمیشود و هیچ مخزن، شبکه یا نمایهٔ واقعی تغییر نمیکند.
۱. پاسخِ احتمالی را از سندِ تکراری جدا کنیم

تعریفِ تکراری در اینجا برابریِ تمامِ بایتهای ورودی است. عنوان، زبان، منشأ و استخراجِ متن داخلِ این مثال نیستند. اگر لازم است چنین فیلدهایی در تعریفِ محتوا شرکت کنند، باید پیشتر نمایشِ بدونِ ابهامی بسازیم؛ درسِ قراردادِ تشخیصِ سندِ تکراری با هش همان مرز را بررسی میکند. اینجا فرض میکنیم نمایشِ نهایی آماده است و فقط دربارهٔ عضویتِ آن سؤال داریم.
بلوم یک آرایهٔ بیت دارد. برای هر عضو، چند جایگاه از روی هش محاسبه و یک میشوند. هنگامِ پرسوجو، وجودِ حتی یک صفر در همان جایگاهها نشانهٔ نبودن است؛ یک بودنِ همهٔ آنها ممکن است حاصلِ اعضای دیگر باشد. بخشِ ۲٫۱ مرورِ برودر و میتسنماخر این سازوکار و مثبتِ کاذب را توضیح میدهد. مثبتِ کاذب یعنی غیرعضوی که فیلتر آن را نامزد اعلام میکند.
نامزد بودن، مجوزِ کنار گذاشتنِ سند نیست. برنامه بعد از پاسخِ احتمالی، بایتهای کامل را در مجموعهٔ دقیق بررسی میکند. این مسئله نیز با شباهتِ سند با MinHash فرق دارد: دو متنِ نزدیک ممکن است بایتهای متفاوت داشته باشند و در این قرارداد تکراری نباشند. نه امتیازِ شباهت تولید میکنیم و نه شباهتِ معنایی را از اشتراکِ چند بیت نتیجه میگیریم.
۲. قراردادِ بایت، دامنهٔ هش و بیتها را ثابت کنیم

ورودیِ سازنده دقیقاً یک تاپلِ صفر تا ۶۴ رکورد است؛ سقف پیش از حذفِ تکرارها اعمال میشود. هر رکورد و هر پرسوجو دقیقاً از نوعِ bytes و حداکثر ۶۴ بایت است. بایتِ خالی معتبر است. رشته، آرایهٔ بایتِ تغییرپذیر و تبدیلِ ضمنی پذیرفته نیستند. کد نرمالسازی، تغییرِ حروف یا انتخابِ رمزگذاری انجام نمیدهد؛ بنابراین دو نمایشِ یونیکدیِ ظاهراً مشابه میتوانند اعضای متفاوت باشند.
پارامترِ m تعدادِ بیتها و بینِ ۸ تا ۵۱۲ است؛ k تعدادِ محاسبههای هش و بینِ ۱ تا ۸ است. هر دو باید عددِ صحیحِ واقعیِ پایتون باشند؛ مقدارِ بولی پذیرفته نیست. seed نیز بایتِ ثابتِ صفر تا ۱۶ بایتی است. این بذر فقط برچسبِ بازتولیدپذیریِ پروتکل است و کلیدِ احرازِ هویت نیست. این سقفها انتخابِ آموزشیِ ما هستند و از تعریفِ عمومیِ بلوم نمیآیند.
پیامِ هش با دامنهٔ ثابتِ doc-membership-v1\x00 شروع میشود؛ انتهای دامنه یک بایتِ صفر است. سپس m در دو بایت، k و طولِ بذر هرکدام در یک بایت، خودِ بذر، شمارهٔ هش در یک بایت، طولِ سند در دو بایت و خودِ سند میآیند. اعدادِ چندبایتی در این پیام بزرگانتها هستند. طولها مرزِ فیلدها را مشخص میکنند تا چسباندنِ دو بخشِ متفاوت، پیامِ یکسان نسازد.
خروجیِ کاملِ SHA-256 را عددِ بزرگانتها میخوانیم و باقیماندهٔ تقسیمش بر m را جایگاه میگیریم. بیتِ منطقیِ صفر کمارزشترین بیت است؛ خروجیِ بستهبندیشده کوچکانتهاست و بیتهای بالاییِ بلااستفاده صفرند. این تفاوتِ دو ترتیب عمدی است. مستنداتِ hashlib ورودی و خروجیِ بایتیِ هش را شرح میدهد؛ قاببندی و ترتیبِ بیتها قراردادِ اختصاصیِ همین کد هستند.
۳. کدِ کاملِ پیشفیلتر و تأییدِ دقیق

سازنده، مجموعهٔ تغییرناپذیرِ بایتها و بیتهای متناظر را همزمان میسازد. درجِ مکررِ عضو یا جابهجاییِ ترتیبِ ورودی، نتیجه را تغییر نمیدهد. هر جایگاهِ عضو هنگامِ ساخت یک شده و بعد صفر نمیشود؛ محاسبهٔ دوبارهٔ همان جایگاهها پس از ساخت نیز همان بیتها را میبیند. این استدلالِ نبودِ منفیِ کاذب به سازگاریِ پروتکل و سالم ماندنِ تصویرِ ثابت وابسته است.
خروجیِ lookup سه مقدارِ بولی دارد: پاسخِ احتمالی، انجام شدنِ بررسیِ دقیق و تصمیمِ نهاییِ تکراری بودن. برای پاسخِ منفی، بررسیِ دقیق اجرا نمیشود؛ برای پاسخِ مثبت، برابریِ بایتها در frozenset تصمیم میگیرد. فیلتر امکانِ وارد کردنِ بیتهای بیرونی، حذف یا تغییرِ اعضا ندارد. ثابت بودنِ فیلدها در استفادهٔ عادی، تضمینِ مقابله با دستکاریِ عمدیِ شیء، تغییرِ کد یا خرابیِ حافظه نیست.
کدِ زیر کامل است و با کتابخانهٔ استاندارد اجرا میشود. علاوه بر مسیرِ اصلی، مرجعِ مستقلی جایگاهها را با قاببندیِ هگزادسیمال و کاهشِ پیمانهایِ رقمبهرقم میسازد؛ آرایهٔ بیتِ تولیدی را فراخوانی نمیکند. هر دو از همان SHA-256 استفاده میکنند، پس مرجع جایگزینِ آزمودنِ خودِ پیادهسازیِ رمزنگاری نیست. بردارِ شناختهشدهٔ abc و یک پیامِ بایتیِ صریح نیز قراردادِ هش و قاببندی را کنترل میکنند.
"""Offline immutable byte-membership prefilter; no storage/network actions."""
from dataclasses import dataclass, FrozenInstanceError
from hashlib import sha256
from itertools import combinations
import json
import platform
import struct
DOMAIN = b'doc-membership-v1\x00'
def _item(value):
if type(value) is not bytes:
raise TypeError('items must be exact bytes, without implicit encoding')
if len(value) > 64:
raise ValueError('item exceeds 64 bytes')
return value
def _message(m, k, seed, index, item):
return (DOMAIN + struct.pack('>HBB', m, k, len(seed)) + seed
+ bytes([index]) + len(item).to_bytes(2, 'big') + item)
def _positions(m, k, seed, item):
return tuple(int.from_bytes(sha256(_message(m, k, seed, i, item)).digest(),
'big') % m for i in range(k))
@dataclass(frozen=True, slots=True, init=False)
class DocumentPrefilter:
m: int
k: int
seed: bytes
bits: int
documents: frozenset
def __init__(self, records, m=64, k=3, seed=b''):
if type(m) is not int or type(k) is not int:
raise TypeError('m and k must be exact Python ints')
if not 8 <= m <= 512 or not 1 <= k <= 8:
raise ValueError('own bounds: m8..512 bits and k1..8')
if type(seed) is not bytes:
raise TypeError('seed must be exact bytes')
if len(seed) > 16:
raise ValueError('seed exceeds 16 bytes')
if type(records) is not tuple:
raise TypeError('records must be an exact tuple')
if len(records) > 64:
raise ValueError('at most64 records before deduplication')
documents = frozenset(_item(record) for record in records)
bits = 0
for document in documents:
for position in _positions(m, k, seed, document):
bits |= 1 << position
for name, value in [('m', m), ('k', k), ('seed', seed),
('bits', bits), ('documents', documents)]:
object.__setattr__(self, name, value)
def maybe_present(self, item):
item = _item(item)
return all(self.bits & (1 << i) for i in
_positions(self.m, self.k, self.seed, item))
def lookup(self, item):
maybe = self.maybe_present(item)
return {'maybe_present': maybe, 'exact_checked': maybe,
'duplicate': item in self.documents if maybe else False}
def packed_bits(self):
"""Logical bit0 is LSB; little-endian bytes, unused high bits zero."""
return self.bits.to_bytes((self.m + 7) // 8, 'little')
def reference_positions(m, k, seed, item):
"""Independent hex framing and nibble modular reduction, no production call."""
positions = set()
for index in range(k):
wire_hex = ('646f632d6d656d626572736869702d763100'
+ format(m, '04x') + format(k, '02x')
+ format(len(seed), '02x') + seed.hex()
+ format(index, '02x') + format(len(item), '04x') + item.hex())
digest_hex = sha256(bytes.fromhex(wire_hex)).hexdigest()
remainder = 0
for digit in digest_hex:
remainder = (16 * remainder + int(digit, 16)) % m
positions.add(remainder)
return positions
def reference_occupied(records, m, k, seed):
answer = set()
for item in records:
answer.update(reference_positions(m, k, seed, item))
return answer
def rejects(kind, operation):
try:
operation()
except kind:
return True
return False
def self_check():
checks = {}
empty = DocumentPrefilter((), 16, 3, b'lab')
checks['empty_snapshot_definitely_absent'] = empty.bits == 0 and empty.lookup(b'') == {
'maybe_present': False, 'exact_checked': False, 'duplicate': False}
universe = tuple(('doc-%02d' % i).encode('ascii') for i in range(64))
records = universe[:4]
snapshot = DocumentPrefilter(records, 16, 3, b'lab')
checks['inserted_items_have_no_false_negatives'] = all(snapshot.maybe_present(x) for x in records)
checks['positive_members_exactly_confirmed'] = all(snapshot.lookup(x) == {
'maybe_present': True, 'exact_checked': True, 'duplicate': True} for x in records)
false_positives = [x for x in universe[4:] if snapshot.maybe_present(x)]
checks['finite_universe_false_positives_never_become_duplicates'] = (
len(false_positives) > 0 and all(snapshot.lookup(x) == {
'maybe_present': True, 'exact_checked': True, 'duplicate': False}
for x in false_positives)
)
repeated = DocumentPrefilter(tuple(reversed(records)) + records, 16, 3, b'lab')
checks['order_and_duplicate_insertion_idempotent'] = (
repeated.bits == snapshot.bits and repeated.documents == snapshot.documents)
extended = DocumentPrefilter(universe[:8], 16, 3, b'lab')
checks['compatible_extension_only_sets_bits'] = (
snapshot.bits & extended.bits == snapshot.bits and
all(extended.maybe_present(x) for x in records)
)
raw = DocumentPrefilter((b'', b'\x00', '\u00e9'.encode('utf-8')), 32, 3, b'raw')
checks['raw_bytes_empty_nul_and_unicode_forms_distinct'] = (
all(raw.lookup(x)['duplicate'] for x in raw.documents) and
not raw.lookup('e\u0301'.encode('utf-8'))['duplicate'] and
not raw.lookup(b'\x00\x00')['duplicate']
)
wire = DOMAIN + b'\x00\x09\x02\x01S\x01\x00\x02\x00A'
small = DocumentPrefilter((b'\x00A',), 9, 2, b'S')
occupied = reference_occupied((b'\x00A',), 9, 2, b'S')
expected_bytes = bytes(sum(1 << (j % 8) for j in occupied if j // 8 == byte)
for byte in range(2))
checks['hash_domain_framing_and_little_endian_bit_protocol'] = (
sha256(b'abc').hexdigest() == 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
and _message(9, 2, b'S', 1, b'\x00A') == wire
and small.packed_bits() == expected_bytes
and small.bits >> 9 == 0
and _message(16, 3, b'a', 0, b'bc') != _message(16, 3, b'ab', 0, b'c')
)
checks['snapshot_fields_are_immutable'] = (
rejects(FrozenInstanceError, lambda: setattr(snapshot, 'bits', 0)) and
type(snapshot.documents) is frozenset
)
# Fault injection outside public API: clear a shared bit and lose a member.
shared = next((a, b, min(reference_positions(16, 3, b'lab', a) &
reference_positions(16, 3, b'lab', b)))
for a, b in combinations(records, 2)
if reference_positions(16, 3, b'lab', a) & reference_positions(16, 3, b'lab', b))
corrupted_bits = snapshot.bits & ~(1 << shared[2])
checks['clearing_shared_bit_breaks_no_false_negative_contract'] = not all(
corrupted_bits & (1 << i) for i in _positions(16, 3, b'lab', shared[1]))
checks['parameter_types_and_limits_rejected'] = (
all(rejects(TypeError, lambda m=m, k=k, s=s: DocumentPrefilter((), m, k, s))
for m, k, s in [(True, 3, b''), (16, False, b''), (16.0, 3, b''),
(16, 3, 'lab'), (16, 3, bytearray(b'lab'))]) and
all(rejects(ValueError, lambda m=m, k=k, s=s: DocumentPrefilter((), m, k, s))
for m, k, s in [(7, 3, b''), (513, 3, b''), (16, 0, b''),
(16, 9, b''), (16, 3, b'x'*17)])
)
checks['record_and_query_contract_rejected'] = (
rejects(TypeError, lambda: DocumentPrefilter([b'a'])) and
all(rejects(TypeError, lambda x=x: DocumentPrefilter((x,)))
for x in ['a', bytearray(b'a'), memoryview(b'a'), 1, True]) and
rejects(ValueError, lambda: DocumentPrefilter((b'a',)*65)) and
rejects(ValueError, lambda: DocumentPrefilter((b'a'*65,))) and
rejects(TypeError, lambda: snapshot.lookup('a')) and
rejects(ValueError, lambda: snapshot.lookup(b'a'*65))
)
base = (b'', b'\x00', b'a', b'a\x00', b'\xff', b'A', b'00', b'\xc3\xa9')
queries = base + (b'b', b'c', b'\x00a', b'aa')
profiles = [(8, 1, b''), (9, 2, b'S'), (16, 3, b'lab'), (31, 4, b'\x00')]
oracle_ok, oracle_queries = True, 0
for m, k, seed in profiles:
for mask in range(256):
subset = tuple(x for i, x in enumerate(base) if mask & (1 << i))
current = DocumentPrefilter(subset, m, k, seed)
positions = reference_occupied(subset, m, k, seed)
expected_bits = sum(1 << i for i in positions)
oracle_ok = oracle_ok and current.bits == expected_bits
for query in queries:
maybe = reference_positions(m, k, seed, query) <= positions
oracle_ok = oracle_ok and current.lookup(query) == {
'maybe_present': maybe, 'exact_checked': maybe,
'duplicate': any(query == stored for stored in subset)}
oracle_queries += 1
checks['all_small_subsets_independent_position_set_oracle'] = oracle_ok and oracle_queries == 12288
cap_records = tuple(bytes([i])*64 for i in range(64))
cap = DocumentPrefilter(cap_records, 512, 8, b's'*16)
checks['maximum_declared_snapshot_bounds'] = (
len(cap.documents) == 64 and len(cap.packed_bits()) == 64 and
all(cap.lookup(x)['duplicate'] for x in cap_records) and cap.bits.bit_length() <= 512)
assert len(checks) == 14 and all(type(v) is bool for v in checks.values())
assert all(checks.values()), checks
return {
'python': platform.python_version(), 'dependencies': 'standard library only',
'fixture': {'m_bits':16, 'k':3, 'seed_hex':b'lab'.hex(), 'inserted':4,
'occupied_bits':snapshot.bits.bit_count(), 'packed_little_endian_hex':snapshot.packed_bits().hex(),
'universe_size':64, 'tested_nonmembers':60,
'realized_false_positives':len(false_positives),
'false_positive_items':[x.decode('ascii') for x in false_positives],
'false_duplicate_decisions':sum(snapshot.lookup(x)['duplicate'] for x in universe[4:])},
'shared_bit_fault': {'removed_item':shared[0].decode('ascii'),
'damaged_member':shared[1].decode('ascii'), 'cleared_bit':shared[2]},
'independent_oracle_snapshot_count':1024, 'independent_oracle_queries':oracle_queries,
'unique_executed_checks':len(checks), 'checks':checks, 'all_passed':all(checks.values()),
'scope':'Realized finite-universe counts only; no global error rate, benchmark, security or Redis compatibility claim.'}
if __name__ == '__main__':
print(json.dumps(self_check(), ensure_ascii=True, indent=2))
۴. مثبتِ کاذب را در یک جهانِ محدود بشماریم

جهانِ آزمایش دقیقاً ۶۴ برچسبِ ASCII از doc-00 تا doc-63 است. چهار برچسبِ اول عضو هستند؛ m برابرِ ۱۶، k برابرِ ۳ و بذر بایتهای lab است. در اجرای واقعی، هفت بیت یک شد و دو بایتِ بستهبندیشده نمایشِ هگزادسیمالِ d291 داشتند. این دو بایت فقط بخشِ فیلتر هستند؛ حافظهٔ شیء، مجموعهٔ دقیق و خودِ سندها را اندازه نمیگیرند.
از ۶۰ غیرعضو، پنج مورد مثبتِ کاذب بودند: doc-04، doc-41، doc-43، doc-44، doc-51. هر پنج به مرحلهٔ دقیق رفتند و هیچکدام تکراری اعلام نشدند. چهار عضو نیز همگی تأیید شدند. مقدارِ پنج از شصت شمارشِ همین جهان و همین پارامترهاست؛ نرخِ خطای کلِ اسنادِ آینده، احتمالِ شرطیِ تکراری بودنِ پاسخِ مثبت یا تضمینِ کیفیتِ جستوجو نیست.
اجرای ثبتشده با پایتونِ ۳٫۱۴٫۵، بدونِ وابستگیِ بیرونی و با خطای استانداردِ خالی پایان یافت. چهارده گروهِ نامدار گذشتند. یک گروه، تمامِ زیرمجموعههای هشت ورودی را در چهار پروفایل بررسی کرد: ۱۰۲۴ تصویر و ۱۲۲۸۸ پرسوجو، همگی یک خانوادهٔ آزمون هستند. گروههای دیگر ورودیِ خالی، تکرار، توسعهٔ سازگار، بایتهای متفاوت، ترتیبِ بستهبندی، ثابت بودن، خرابیِ عمدی، ورودیِ نامعتبر و مرزِ سقفها را میسنجند.
{
"python": "3.14.5",
"dependencies": "standard library only",
"fixture": {
"m_bits": 16,
"k": 3,
"seed_hex": "6c6162",
"inserted": 4,
"occupied_bits": 7,
"packed_little_endian_hex": "d291",
"universe_size": 64,
"tested_nonmembers": 60,
"realized_false_positives": 5,
"false_positive_items": [
"doc-04",
"doc-41",
"doc-43",
"doc-44",
"doc-51"
],
"false_duplicate_decisions": 0
},
"shared_bit_fault": {
"removed_item": "doc-00",
"damaged_member": "doc-01",
"cleared_bit": 4
},
"independent_oracle_snapshot_count": 1024,
"independent_oracle_queries": 12288,
"unique_executed_checks": 14,
"checks": {
"empty_snapshot_definitely_absent": true,
"inserted_items_have_no_false_negatives": true,
"positive_members_exactly_confirmed": true,
"finite_universe_false_positives_never_become_duplicates": true,
"order_and_duplicate_insertion_idempotent": true,
"compatible_extension_only_sets_bits": true,
"raw_bytes_empty_nul_and_unicode_forms_distinct": true,
"hash_domain_framing_and_little_endian_bit_protocol": true,
"snapshot_fields_are_immutable": true,
"clearing_shared_bit_breaks_no_false_negative_contract": true,
"parameter_types_and_limits_rejected": true,
"record_and_query_contract_rejected": true,
"all_small_subsets_independent_position_set_oracle": true,
"maximum_declared_snapshot_bounds": true
},
"all_passed": true,
"scope": "Realized finite-universe counts only; no global error rate, benchmark, security or Redis compatibility claim."
}
۵. چهار پرسش دربارهٔ حذف، سازگاری و نرخِ خطا

پرسشِ اول: اگر بلوم مثبت بود، میتوان سند را حذف کرد؟ پاسخ خیر است. در همین اجرای کوچک، پنج غیرعضو مثبت شدند. تصمیم باید از مقایسهٔ دقیق بیاید؛ برنامه فقط تصمیم را گزارش میکند و عملِ حذف ندارد. حتی تأییدِ برابری نیز بهتنهایی سیاستِ نگهداریِ منشأ، مجوزِ حذف یا انتخابِ نسخهٔ مرجع را تعیین نمیکند. این مسئولیتها خارج از مثالِ عضویت باقی میمانند.
پرسشِ دوم: آیا برای حذفِ یک عضو، بیتهایش را صفر کنیم؟ پاسخ خیر است. آزمونِ خرابی، بیرون از رابطِ عمومی، بیتِ مشترکِ شمارهٔ ۴ مربوط به doc-00 و doc-01 را در یک عددِ محلی پاک میکند؛ عضوِ دوم دیگر از فیلتر عبور نمیکند. بخشِ ۲٫۵ مقاله همین خطرِ اشتراک را توضیح میدهد. در این مثال راهِ تغییرِ مجموعه، ساختنِ تصویرِ تازه و هماهنگ از اعضای باقیمانده است.
پرسشِ سوم: با تغییرِ بذر یا رمزگذاری، فیلترِ قبلی معتبر میماند؟ پاسخ خیر است. دامنه، قاببندی، m، k، بذر و بایتهای سند باید با زمانِ ساخت یکسان بمانند. عوض کردنِ هرکدام میتواند جایگاههای دیگری بدهد و استدلالِ پاسخِ منفی را از بین ببرد. خروجیِ packed_bits بهتنهایی قالبِ ذخیرهسازیِ کامل نیست؛ این برنامه عمداً بارگذاریِ دوبارهٔ فیلتر یا اتصال به مخزنِ تغییرپذیر را پیاده نمیکند.
پرسشِ چهارم: آیا پنج مثبتِ کاذب، نرخِ عمومی یا بهینه بودنِ k را ثابت میکند؟ پاسخ خیر است. تحلیلِ احتمالاتیِ منبع فرضهایی دربارهٔ هشهای تصادفی دارد؛ مشتق کردنِ چند جایگاه از SHA-256 در این کد اثباتِ استقلالِ آنها نیست. انتخابِ پارامترها، سنجشِ کارایی و هزینهٔ مراجعه به مخزن به داده و معماری نیاز دارد. این اجرای محدود هیچ ادعایی دربارهٔ سرعت، صرفهجوییِ حافظهٔ کل، امنیت یا سازگاری با محصولِ دیگری ندارد.
منابع و مطالعهٔ بیشتر
بررسی منابع: ۲۷ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

