PSI Cardinality-Only Joins at Scale
Most spatial joins between organisations do not need the intersection — they need its size. “How many of our customers also visited your stores?” is a number, and returning the matching cell tokens alongside it hands over a list of shared locations nobody asked for. Cardinality-only private set intersection returns the count and nothing else, which both removes that disclosure and, at scale, removes the largest part of the protocol’s cost. This page builds it, under private set intersection for spatial joins in Secure Multi-Party Computation in Spatial Analytics.
Parameter Configuration and Calibration
cell_resolution— the join key’s granularity. The dominant parameter for both cost and disclosure. Coarse cells mean fewer tokens (cheaper) and matches that mean less (safer); fine cells mean the opposite. Choose it from what the answer is for: a footfall overlap study works at resolution 7 or 8, and anything finer is asking a different, more revealing question.bucket_bits— the hash prefix used to partition the comparison. Comparing every token against every token is quadratic. Partitioning both sides by a -bit prefix reduces the comparison to independent buckets of roughly items each. The prefix is computed on the blinded token, so it reveals nothing.dp_epsilon_on_count— noise on the released cardinality. The count itself is a statistic over both parties’ data, and repeated queries with slightly different inputs recover individual membership. Noise the released count and account for it, exactly as for any other release.min_set_size— the floor below which a party refuses to run. A party with three cell tokens learns almost everything from a cardinality answer. Refuse small sets rather than relying on the other party’s good behaviour.
| Variant | Returns | Leakage beyond the answer |
|---|---|---|
| Full PSI | the matching tokens | which cells are shared |
| PSI cardinality | a count | nothing, if the count is noised |
| PSI-sum | a sum over matched payloads | the aggregate only |
| Repeated PSI-CA on varied inputs | counts | membership, by differencing |
Reference Implementation
from __future__ import annotations
import hashlib
import math
import random
import secrets
from dataclasses import dataclass
from typing import Iterable, Sequence
# A prime-order group stand-in. Production code uses an elliptic curve with a
# hash-to-curve; the structure — commutative blinding — is identical.
P = (1 << 521) - 1
Q = (P - 1) // 2
def _hash_to_group(token: str) -> int:
digest = hashlib.blake2b(token.encode(), digest_size=64).digest()
return pow(4, int.from_bytes(digest, "big") % Q, P)
def blind(values: Iterable[int], key: int) -> list[int]:
"""Raise each group element to the party's secret exponent."""
return [pow(v, key, P) for v in values]
def bucket_of(value: int, bucket_bits: int) -> int:
"""Partition on the BLINDED value, so the bucket reveals nothing about the token."""
return int.from_bytes(
hashlib.blake2b(str(value).encode(), digest_size=8).digest(), "big"
) % (1 << bucket_bits)
@dataclass(frozen=True)
class CardinalityResult:
exact: int
released: float
epsilon: float
buckets: int
def psi_cardinality(
a_tokens: Sequence[str],
b_tokens: Sequence[str],
*,
bucket_bits: int = 8,
epsilon: float = 1.0,
min_set_size: int = 500,
seed: int = 0,
) -> CardinalityResult:
"""Run a bucketed DH-style PSI and release only a noised cardinality.
Both parties blind with their own secret exponent; because exponentiation
commutes, a token present in both sets produces the same double-blinded value on
both sides. Bucketing on the double-blinded value turns a quadratic comparison
into 2^bucket_bits small ones without revealing anything extra.
"""
if len(a_tokens) < min_set_size or len(b_tokens) < min_set_size:
raise ValueError(
f"set sizes {len(a_tokens)}/{len(b_tokens)} below the floor {min_set_size} — "
"a cardinality answer over a tiny set is close to membership disclosure"
)
key_a, key_b = secrets.randbelow(Q) + 1, secrets.randbelow(Q) + 1
a_once = blind((_hash_to_group(t) for t in a_tokens), key_a)
b_once = blind((_hash_to_group(t) for t in b_tokens), key_b)
a_twice = blind(a_once, key_b) # B blinds A's set
b_twice = blind(b_once, key_a) # A blinds B's set
buckets_a: dict[int, set[int]] = {}
for v in a_twice:
buckets_a.setdefault(bucket_of(v, bucket_bits), set()).add(v)
exact = 0
for v in b_twice:
if v in buckets_a.get(bucket_of(v, bucket_bits), ()):
exact += 1
rng = random.Random(seed)
u = rng.random() - 0.5
noise = -(1.0 / epsilon) * math.copysign(1.0, u) * math.log1p(-2 * abs(u))
return CardinalityResult(exact, max(0.0, exact + noise), epsilon, 1 << bucket_bits)
def comparison_cost(n: int, bucket_bits: int) -> int:
"""Comparisons performed: quadratic without bucketing, near-linear with it."""
buckets = 1 << bucket_bits
per_bucket = max(1, n // buckets)
return buckets * per_bucket * per_bucket
Validation Checkpoint
def _validate() -> None:
rng = random.Random(7)
shared = [f"cell-{i}" for i in range(1_200)]
a = shared + [f"a-only-{i}" for i in range(2_000)]
b = shared + [f"b-only-{i}" for i in range(3_000)]
result = psi_cardinality(a, b, bucket_bits=8, epsilon=2.0, seed=1)
# 1. The protocol must find exactly the true intersection size.
assert result.exact == 1_200, result.exact
# 2. The released value is noised, so it is not the exact count.
assert result.released != float(result.exact)
assert abs(result.released - result.exact) < 20
# 3. Disjoint sets must produce a cardinality of zero before noise.
disjoint = psi_cardinality(
[f"x-{i}" for i in range(800)], [f"y-{i}" for i in range(800)],
bucket_bits=8, epsilon=2.0, min_set_size=500, seed=2,
)
assert disjoint.exact == 0
# 4. Bucketing must not change the answer — only the cost.
coarse = psi_cardinality(a, b, bucket_bits=2, epsilon=2.0, seed=1)
assert coarse.exact == result.exact
# 5. Bucketing must actually reduce the comparison count substantially.
assert comparison_cost(100_000, 8) < comparison_cost(100_000, 0) / 100
# 6. Small sets must be refused, not answered.
try:
psi_cardinality(["a"] * 10, ["a"] * 10, min_set_size=500)
except ValueError as exc:
assert "below the floor" in str(exc)
else:
raise AssertionError("tiny sets must be refused")
print("PSI cardinality: all assertions passed")
_validate()
Assertion 5 is the scaling result. Without bucketing, a hundred-thousand-token join performs ten billion comparisons; with an 8-bit prefix on the double-blinded values it performs roughly forty million, and the prefix is computed on a value neither party can invert.
Incident Response and Edge Cases
- A party requests the matching tokens after seeing the count. That is a different protocol with a different disclosure, and it should require the same approval the original study did. The cardinality variant exists precisely so that request has to be made explicitly.
- The same study is re-run with one record removed. Differencing two cardinality answers reveals that record’s membership exactly. This is the attack the noise and the ledger exist for: account every run, and refuse repeated runs over near-identical inputs.
- The counts disagree between the two parties’ computations. Both sides should derive the same double-blinded set; a mismatch means a token normalisation difference — trailing whitespace, case, or a different cell resolution — rather than a protocol failure. Normalise tokens to a published canonical form before hashing.
- One party’s set is far larger than the other’s. The cardinality still leaks less than a full intersection, but the smaller party learns a proportionally larger amount about its own overlap. Consider a floor on the ratio as well as on the absolute sizes.
- Bucket sizes are badly skewed. The prefix is a hash of a group element, so skew indicates a bug — a constant blinding factor, or bucketing on the unblinded token. Assert on bucket occupancy variance in tests.
Agree the tokenisation before agreeing the protocol. Two organisations running a correct PSI over differently-normalised cell tokens will compute a correct intersection of the wrong sets, and the result — a plausible, too-small overlap — is indistinguishable from a genuine finding. Publish the canonical form: the grid system, the resolution, the coordinate reference system, the rounding rule, and the string encoding, with a version number that both sides assert on before the first blinding round.
Frequently Asked Questions
Why return only a count when the tokens are already computed?
Because the tokens are the disclosure. The double-blinded values are meaningless on their own, but returning the matched set lets a party map them back to its own inputs and learn exactly which locations are shared. If the study only needs a size, returning more is an unforced disclosure.
Does the count need differential privacy too?
Yes, if the protocol can be re-run. A single exact cardinality over large sets reveals little; two cardinalities over sets differing by one record reveal that record's membership exactly. Noise the count and account for repeated runs in the ledger.
Is bucketing safe, or does it leak?
Safe, provided the bucket is computed on the double-blinded value. That value is a group element neither party can invert to a token, so the partition carries no information about the underlying cells — it is purely a performance structure.
How does this compare to running the join in an enclave?
PSI needs no trusted hardware and no trusted party, at the cost of being restricted to set operations and being slower per record. An enclave handles arbitrary joins at native speed and moves the trust to a hardware vendor. For a pure overlap count, PSI is usually the better trade; for a rich join, the enclave is.
Related
- Private Set Intersection for Spatial Joins — the parent protocol and its tokenisation.
- Spatial PSI for Proximity Queries — extending exact matching to a distance threshold.
- Enclave-Side Spatial Joins with Sealed Data — the hardware-rooted alternative.
- Privacy Budget Management — accounting for repeated cardinality releases.
Up one level: Private Set Intersection for Spatial Joins · Section: Secure Multi-Party Computation in Spatial Analytics.