Deduplicating Barcode Scans with Bloom Filters
A pure-Python Bloom filter for high-throughput barcode-scan deduplication: false-positive math, bounded memory, and a mandatory exact fall-through
At a busy dispensing counter the scan stream can burst to thousands of events a second, and the deduplication layer must answer “have I seen this key before?” for every one of them without letting memory grow without bound. Holding every seen key in a set is correct but linear in memory: a week of high-volume dispensing is millions of keys, and the working set outgrows a single worker. A Bloom filter answers the membership question in constant memory regardless of how many keys have passed through — which makes it the ideal pre-filter in front of an authoritative exact store. The trade is that it can occasionally say “possibly seen” for a key it has never observed. In a controlled-substance system that asymmetry is not a footnote; it is the whole safety argument, because a filter that could be trusted to drop a scan would occasionally drop a real dispense. This recipe builds the filter and, just as importantly, wires it so a “possibly seen” answer can never by itself suppress a controlled event. It implements the probabilistic pre-filter described in the parent reference, Real-Time Barcode-Scan Deduplication.
The design goal is precise: use the filter to make the common case — a brand-new key — cheap, by skipping the network round-trip to the exact store whenever the filter says “definitely never seen.” Only the rarer “possibly seen” answer pays for an exact lookup, and only the exact store may ever declare a duplicate.
Problem framing
The deduplication gate needs to know, for each incoming scan key, whether it is worth paying for an authoritative exact lookup. The overwhelming majority of keys in a healthy stream are genuinely new, and for those a network round-trip to the shared exact store is wasted work. A Bloom filter turns that majority into a local, constant-time, constant-memory “definitely never seen” answer. The one thing it must never do is the inverse: it must never let its “possibly seen” answer be the reason a Schedule II dispense is dropped, because that answer is sometimes wrong in the direction that loses a real event.
Two properties of the filter make this safe when respected:
- No false negatives. If a key was ever added, the filter always reports “possibly seen.” A genuine duplicate can never slip past the pre-filter and skip the exact check.
- Bounded false positives. A key that was never added can be reported “possibly seen” with a probability set by the filter’s sizing. This costs an unnecessary exact lookup — never a dropped scan — provided the fall-through rule is honored.
Prerequisites & environment
- Python 3.11+ — the implementation uses
dataclass(frozen=True)and modern typing. - Standard library only. Bits are stored in a
bytearrayand manipulated with pure-Python bit operations; there is nobitarrayor third-party dependency. Hashing useshashlib; addpytestonly for the test block. - The false-positive-rate identities. For a bit array of
mbits,khash functions, andninserted keys, the false-positive probability isp ≈ (1 - e^(-kn/m))^k. The optimal hash count isk = (m/n) · ln 2, and to hit a targetpfor an expectednyou sizem = -n · ln p / (ln 2)^2. These are the only formulas you need to pick parameters deliberately rather than by feel. - Regulatory context. The exactly-once accounting standard of
21 CFR § 1304.21and the perpetual-inventory standard of21 CFR § 1304.11are why a bloom-only “seen” must never drop a controlled-substance scan. The filter is an optimization around the exact guarantee, never a replacement for it.
Implementation: a rotating pure-Python Bloom filter
The filter derives k indices from a single SHA-256 digest using double hashing (the Kirsch–Mitzenmacher technique): two independent 64-bit values pulled from the digest generate all k indices as (h1 + i·h2) mod m, which is statistically indistinguishable from k independent hashes without paying for k separate digests. A rotating wrapper holds two generations so the structure can shed old keys and never saturate — when the active generation reaches its design capacity it is retired behind a fresh one, and queries consult both.
from __future__ import annotations
import hashlib
import json
import logging
import math
from dataclasses import dataclass
logger = logging.getLogger("pharmacy.scan.bloom")
def optimal_params(expected_n: int, target_fp: float) -> tuple[int, int]:
"""Return (m_bits, k_hashes) that meet target_fp for expected_n keys.
m = -n ln p / (ln 2)^2 ; k = (m/n) ln 2."""
if expected_n <= 0 or not (0.0 < target_fp < 1.0):
raise ValueError("expected_n must be > 0 and 0 < target_fp < 1")
m = math.ceil(-expected_n * math.log(target_fp) / (math.log(2) ** 2))
k = max(1, round((m / expected_n) * math.log(2)))
return m, k
class BloomFilter:
"""Fixed-size Bloom filter over a bytearray. No false negatives; the
false-positive rate is bounded by (m, k) and the number of inserts."""
def __init__(self, m_bits: int, k_hashes: int) -> None:
self.m = m_bits
self.k = k_hashes
self._bits = bytearray((m_bits + 7) // 8)
self._count = 0
def _indices(self, key: str) -> list[int]:
digest = hashlib.sha256(key.encode("utf-8")).digest()
h1 = int.from_bytes(digest[:8], "big")
h2 = int.from_bytes(digest[8:16], "big") | 1 # odd => good stride
return [(h1 + i * h2) % self.m for i in range(self.k)]
def add(self, key: str) -> None:
for idx in self._indices(key):
self._bits[idx >> 3] |= (1 << (idx & 7))
self._count += 1
def __contains__(self, key: str) -> bool:
return all(
self._bits[idx >> 3] & (1 << (idx & 7))
for idx in self._indices(key)
)
@property
def fill_ratio(self) -> float:
set_bits = sum(bin(b).count("1") for b in self._bits)
return set_bits / self.m
@property
def count(self) -> int:
return self._count
@dataclass
class RotatingBloom:
"""Two-generation Bloom filter. When the active generation reaches its
design capacity it is retired behind a fresh one, bounding saturation and
letting keys age out with the deduplication window."""
expected_n: int
target_fp: float
def __post_init__(self) -> None:
self._m, self._k = optimal_params(self.expected_n, self.target_fp)
self._active = BloomFilter(self._m, self._k)
self._retiring: BloomFilter | None = None
def probably_seen(self, key: str) -> bool:
if key in self._active:
return True
return self._retiring is not None and key in self._retiring
def add(self, key: str) -> None:
if self._active.count >= self.expected_n:
self._retiring = self._active # keep one generation of history
self._active = BloomFilter(self._m, self._k)
self._active.add(key)
def observe(self, key: str) -> bool:
"""Pre-filter verdict for the dedup gate. Returns True for
'possibly seen' (caller MUST fall through to the exact store) and
False for 'definitely new' (caller may fast-path)."""
verdict = self.probably_seen(key)
logger.info(json.dumps({
"event": "bloom_prefilter",
"verdict": "probably_seen" if verdict else "definitely_new",
"fp_budget": self.target_fp,
"fill_ratio": round(self._active.fill_ratio, 4),
}, sort_keys=True))
if not verdict:
self.add(key) # record the new key for next time
return verdict
The observe method is the single call the deduplication gate makes. Its contract is deliberately narrow: True means “possibly seen — you must consult the exact store,” and False means “definitely new — you may skip the exact store and commit.” It never returns a verdict that suppresses on its own authority. Adding the key on a definitely_new verdict is what makes the next retransmission of the same key report probably_seen and route to the exact check where it will be caught.
Verification & testing
Two properties must hold: the filter never produces a false negative (a real duplicate can never escape the pre-filter), and its false-positive rate stays within the configured budget across a realistic load. The block below proves both empirically.
import pytest
def test_no_false_negatives():
# Every key that was added MUST report probably_seen. This is the safety
# property that guarantees no genuine duplicate skips the exact check.
bf = RotatingBloom(expected_n=10_000, target_fp=0.01)
keys = [f"scan-key-{i}" for i in range(10_000)]
for key in keys:
bf.add(key)
assert all(bf.probably_seen(k) for k in keys)
def test_false_positive_rate_within_budget():
# Query keys that were never inserted; the observed FP rate must stay
# near the configured target (with generous slack for sampling noise).
n, target = 20_000, 0.01
bf = RotatingBloom(expected_n=n, target_fp=target)
for i in range(n):
bf.add(f"present-{i}")
trials = 50_000
fp = sum(bf.probably_seen(f"absent-{j}") for j in range(trials))
observed = fp / trials
assert observed <= target * 2.0, f"FP rate {observed:.4f} exceeds budget"
def test_optimal_params_hit_target():
m, k = optimal_params(expected_n=10_000, target_fp=0.001)
assert k >= 1 and m > 0
# Analytic FP at capacity must not exceed the target we asked for.
analytic = (1 - math.exp(-k * 10_000 / m)) ** k
assert analytic <= 0.001 * 1.2
Capturing the emitted pre-filter line confirms the audit trail carries only the verdict and sizing telemetry — never the raw scan payload or any operator identity:
INFO pharmacy.scan.bloom {"event": "bloom_prefilter", "fill_ratio": 0.4213, \
"fp_budget": 0.01, "verdict": "definitely_new"}
The key never appears in clear text in the filter’s logs: the gate hashes it to a SHA-256 digest before the filter ever sees it, so the pre-filter telemetry stays inside the HIPAA audit-control boundary of 45 CFR § 164.312(b).
Gotchas & compliance pitfalls
- Never drop a controlled-substance scan on a bloom-only hit. This is the cardinal rule. A “possibly seen” verdict is sometimes wrong, so treating it as a duplicate would occasionally suppress a real dispense — an under-count that surfaces as a reportable Schedule II shortage. Always fall through to the exact store, which is the only authority allowed to suppress. The atomic exact claim that backs the fall-through is built in Idempotent Scan Ingestion with Redis SETNX.
- Filter saturation degrades the rate, silently. Insert far more than
expected_nkeys and the fill ratio climbs, driving the false-positive rate well above the budget until nearly everything reads “possibly seen.” That is safe — it only costs extra exact lookups — but it erodes the fast path. The rotating wrapper bounds it by retiring a generation at capacity; monitorfill_ratioand alarm before it approaches the design point. - A standard filter cannot delete a key. Clearing one key’s bits would clear bits shared with other keys and create false negatives. Never
unsetbits in place. When keys must age out with the deduplication window, rotate a whole generation (as here) or use a counting Bloom filter that tracks per-bit counters — but never mutate a shared bit to remove a single member. - Hash quality matters. Deriving all
kindices from a single well-distributed digest via double hashing is sound; deriving them from a weak or truncated hash clusters the set bits and inflates the real false-positive rate above the analytic one. Keep the full SHA-256 digest as the source of bothh1andh2. - Sizing to the wrong
n. The false-positive math holds only near the design capacity. Sizemandkfrom the peak number of distinct keys you expect within one filter generation, not the average, or the live rate will exceed the budget under load.
Frequently Asked Questions
If the filter can be wrong, why use it at all?
Because it is only ever wrong in the safe direction when wired correctly. A false “possibly seen” costs one extra exact lookup; it never drops a scan. In exchange, every genuinely new key — the common case — is resolved locally in constant time and memory, skipping the network round-trip to the exact store. The filter buys throughput and bounded memory without touching the correctness guarantee, which stays entirely with the exact store.
Counting Bloom filter or rotating generations for aging keys out?
Both work; they trade memory for granularity. A counting Bloom filter stores a small counter per bucket instead of a single bit, which lets you delete individual keys at several times the memory cost. Rotating two standard generations, as shown here, ages keys out in coarse blocks at minimal memory. For scan deduplication, where keys naturally expire with the time window, rotation is simpler and cheaper — you never need to delete one specific key, only to forget everything older than the window.
How do I choose the target false-positive rate?
Trade throughput against exact-store load. A smaller target p means a larger bit array and more hashes, so more memory and slightly more CPU per query, but fewer unnecessary exact lookups. A rate around one percent is a common starting point: it keeps the filter compact while sending only one in a hundred genuinely-new keys on an unnecessary round-trip. Because a false positive never costs correctness, you can tune p purely on the throughput-versus-memory curve.
Does the Bloom filter ever touch protected health information?
No. The deduplication gate hashes the composite scan key to a SHA-256 digest before the filter sees it, and the filter stores only bits and logs only its verdict plus sizing telemetry. No NDC-plus-operator correlation, no patient identifier, and no raw payload ever enters the filter or its audit line, keeping it inside the 45 CFR § 164.312(b) audit-control boundary.
Related
- Up: Real-Time Barcode-Scan Deduplication — the parent reference where this pre-filter sits in the dedup gate.
- Idempotent Scan Ingestion with Redis SETNX — the exact, authoritative store the “possibly seen” verdict falls through to.
- Barcode Scan Log Routing Logic — the routing subsystem that consumes de-duplicated scans.
- Data Ingestion & Inventory Sync Workflows — the ingestion architecture this deduplication layer serves.