Digest collisions and Bloom-filter data loss
Simulate a modelNeeds Python
Problem
Two error directions. A digest used as an identity collides with probability about 1 − exp(−n²/2^(b+1)) for n items and b bits: rare for 256 bits, likely for 32 bits at 100,000 items. A Bloom filter errs only one way, "maybe present" for something absent; used as a dedup ("drop if the filter says seen") it silently drops distinct rows. Used as a negative oracle ("definitely new" if the filter says absent, check an exact set otherwise), it never drops anything and only saves lookups.
Part A: verify the birthday formula by shrinking SHA-256 to 32 bits, with n = 100,000 over 200 runs. Part B: build a Bloom filter (8 million bits, 4 hashes) and use it for dedup of 1,000,000 distinct keys with no real duplicates; count how many rows are dropped. Then use it as a negative oracle with an exact set as fallback, and count again.
The question. Does the measured collision rate match the formula, and how many distinct rows does each use of the Bloom filter drop?
Predict
Both about 0.69. The naive version loses several thousand rows, about 0.5% at these sizes (the filter is 39% full at the end, so its final false-positive rate is about 2.4%, and the average over the run is lower). The inverted version keeps all 1,000,000.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
digest-collisions-and-bloom-filter-data.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Digest collisions, and a Bloom filter used for data. Part A: the birthday formula, by shrinking SHA-256 to 32 bits: the share of 200 runs of 100,000 random digests that contain a collision, against 1 − exp(−n²/2^33). Part B: a Bloom filter (8 million bits, 4 hashes) used to drop "duplicates" from 1,000,000 distinct keys, and the same filter used as a negative oracle in front of an exact set. """ import argparse, hashlib, json, math, random p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--n", type=int, default=100_000, help="Part A: digests per run.") p.add_argument("--bits", type=int, default=32, help="Part A: digest width.") p.add_argument("--runs", type=int, default=200, help="Part A: runs.") p.add_argument("--keys", type=int, default=1_000_000, help="Part B: distinct keys.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) def collides(n, bits): seen = set() for _ in range(n): d = int.from_bytes(hashlib.sha256(random.randbytes(16)).digest(), "big") >> (256 - bits) if d in seen: return True seen.add(d) return False measured = sum(collides(args.n, args.bits) for _ in range(args.runs)) / args.runs formula = 1 - math.exp(-args.n * args.n / 2 ** (args.bits + 1)) class Bloom: def __init__(self, m_bits, k): self.m, self.k, self.bits = m_bits, k, bytearray(m_bits // 8 + 1) def _idx(self, key): h = hashlib.sha256(key.encode()).digest() return [int.from_bytes(h[4 * i:4 * i + 4], "big") % self.m for i in range(self.k)] def add(self, key): for i in self._idx(key): self.bits[i // 8] |= 1 << (i % 8) def might(self, key): return all(self.bits[i // 8] >> (i % 8) & 1 for i in self._idx(key)) b, kept_naive = Bloom(8_000_000, 4), 0 for i in range(args.keys): key = f"event-{i}" if not b.might(key): b.add(key); kept_naive += 1 fill = sum(bin(x).count("1") for x in b.bits) / b.m b2, exact, kept_oracle, lookups = Bloom(8_000_000, 4), set(), 0, 0 for i in range(args.keys): key = f"event-{i}" if b2.might(key): lookups += 1 if key in exact: continue # only the exact set may say "seen" b2.add(key); exact.add(key); kept_oracle += 1 summary = {"collision_measured": measured, "collision_formula": formula, "kept_naive": kept_naive, "lost_naive": args.keys - kept_naive, "lost_pct": (args.keys - kept_naive) / args.keys * 100, "fill_pct": fill * 100, "final_fp_pct": fill ** 4 * 100, "kept_oracle": kept_oracle, "exact_lookups": lookups} print(f"A: {args.bits}-bit digests, n = {args.n:,}: collision in {measured:.0%} of {args.runs} runs (formula {formula:.0%})") print(f"B: Bloom filter as the dedup: kept {kept_naive:,} of {args.keys:,} distinct keys (lost {summary['lost_pct']:.2f}%); filter {fill:.0%} full, final false-positive rate {summary['final_fp_pct']:.1f}%") print(f" Bloom filter in front of an exact set: kept {kept_oracle:,}; the exact set was consulted {lookups:,} times") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run digest-collisions-and-bloom-filter-data.py uv run digest-collisions-and-bloom-filter-data.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --n100,000 | Part A: digests per run. |
| --bits32 | Part A: digest width. |
| --runs200 | Part A: runs. |
| --keys1,000,000 | Part B: distinct keys. |
| --seed0 | Random seed. |
Our run
A: 32-bit digests, n = 100,000: collision in 64% of 200 runs (formula 69%)
B: Bloom filter as the dedup: kept 994,458 of 1,000,000 distinct keys (lost 0.55%); filter 39% full, final false-positive rate 2.4%
Bloom filter in front of an exact set: kept 1,000,000; the exact set was consulted 5,542 timesWhat we found
32-bit digests collided in 0.645 of the runs against a formula value of 0.688. Used as a dedup, the Bloom filter kept 994K of a million distinct keys, dropping 0.554%; it ended 39.4% full with a final false-positive rate of 2.4%. As a negative oracle in front of an exact set, it kept 1M, and the exact set was consulted only 5.54K times.
Read it
This is the whole argument of the "error direction" rule: a structure that can be wrong must be wrong in the direction that costs only time. A Bloom filter may say "maybe" wrongly, so it can only ever save work, never decide to discard data.