Choosing HE Parameters for Spatial Circuits
Homomorphic encryption parameters cannot be tuned after the fact. The ring dimension, the coefficient-modulus chain and the scaling factor are fixed at key generation, and they encode an assumption about the circuit that will be evaluated — specifically, its multiplicative depth. Choose them for a depth-2 distance comparison and then add a normalisation step, and the deployment does not slow down or warn: it decrypts to noise. This page is the procedure for deriving parameters from the circuit instead of guessing them, under homomorphic encryption basics in Secure Multi-Party Computation in Spatial Analytics.
Parameter Configuration and Calibration
depth— count it by hand, before anything else. Every ciphertext-ciphertext multiplication consumes one level; additions and plaintext multiplications are nearly free. A squared Euclidean distance is depth 1 (one multiplication of a difference by itself); comparing it against a threshold via a polynomial approximation adds the degree of that polynomial. Write the circuit out and count.scale_bitsand the modulus chain. In CKKS each level consumes roughlyscale_bitsof the coefficient modulus, plus a larger first and last prime. A chain of primes at 40 bits each is the usual starting point; the total modulus size then determines the minimum ring dimension for a given security level.ring_dimension— from the modulus, not from preference. Security tables map (total modulus bits, security level) to a minimum . Choosing first and hoping the modulus fits is backwards, and it is how deployments end up at 128-bit security they cannot prove.slots— the batching capacity you get for free. CKKS packs complex values per ciphertext. If the workload has fewer points than slots, the parameters are oversized; if it has far more, the per-slot cost is excellent and a larger ring is a bargain.
| Spatial circuit | Depth | Chain (40-bit levels) | Comment |
|---|---|---|---|
| Sum of encrypted counts | 0 | 2 | additions only |
| Squared distance to a fixed point | 1 | 3 | plaintext centre is free |
| Squared distance, both encrypted | 1 | 3 | same depth, more slots used |
| Distance + threshold (deg-4 poly) | 3 | 5 | the usual production case |
| Distance + normalise + threshold | 5 | 7 | the “one more step” trap |
Reference Implementation
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Sequence
class DepthExceeded(RuntimeError):
"""A circuit consumed more levels than its parameters provide."""
# Minimum ring dimension per total coefficient-modulus size, at 128-bit security.
# Values follow the homomorphic-encryption security standard's tables.
SECURITY_TABLE: dict[int, int] = {
109: 4096, 218: 8192, 438: 16384, 881: 32768,
}
@dataclass(frozen=True)
class HEParams:
ring_dimension: int
modulus_bits: int
scale_bits: int
depth: int
@property
def slots(self) -> int:
return self.ring_dimension // 2
def multiply_cost_ms(self) -> float:
"""Empirical scaling: cost grows roughly as N log N per multiplication."""
n = self.ring_dimension
return 3.1 * (n / 4096) * math.log2(n) / math.log2(4096)
def per_slot_cost_us(self) -> float:
return self.multiply_cost_ms() * 1000.0 / self.slots
def derive_params(depth: int, *, scale_bits: int = 40, security_bits: int = 128) -> HEParams:
"""Derive parameters FROM the circuit depth, in the only sound order.
The chain needs one prime per level plus a larger first and last prime; the
total modulus then selects the smallest ring dimension the security tables
permit. Picking the ring dimension first and hoping is how deployments end up
unable to state their security level.
"""
if depth < 0:
raise ValueError("depth cannot be negative")
if security_bits != 128:
raise ValueError("only the 128-bit table is encoded here")
total_bits = scale_bits * depth + 2 * (scale_bits + 20)
for limit in sorted(SECURITY_TABLE):
if total_bits <= limit:
return HEParams(SECURITY_TABLE[limit], total_bits, scale_bits, depth)
raise ValueError(
f"a depth-{depth} circuit needs {total_bits} modulus bits, beyond the "
"tabulated range — restructure the circuit rather than growing the ring"
)
@dataclass
class DepthCounter:
"""Wrap the evaluator so depth is measured in code, not tracked in a document."""
budget: int
used: int = 0
def multiply(self) -> None:
self.used += 1
if self.used > self.budget:
raise DepthExceeded(
f"circuit used {self.used} levels, parameters provide {self.budget} — "
"decryption would return a plausible but meaningless value"
)
def add(self) -> None:
"""Additions consume no level. Present so call sites read symmetrically."""
def remaining(self) -> int:
return self.budget - self.used
def squared_distance_circuit(counter: DepthCounter, n_points: int) -> int:
"""Depth-1 circuit: (x-a)^2 + (y-b)^2 against a PLAINTEXT centre."""
counter.add() # x - a, free
counter.multiply() # (x - a)^2
counter.add() # + (y - b)^2
return n_points
def threshold_circuit(counter: DepthCounter, degree: int) -> None:
"""Polynomial threshold of the given degree, evaluated by Horner's rule."""
for _ in range(int(math.ceil(math.log2(max(2, degree))))):
counter.multiply()
Validation Checkpoint
def _validate() -> None:
# 1. Parameters must grow with depth, and the ring must follow the modulus.
shallow = derive_params(1)
deep = derive_params(5)
assert deep.modulus_bits > shallow.modulus_bits
assert deep.ring_dimension >= shallow.ring_dimension
# 2. A bigger ring is slower per multiplication and cheaper per slot.
assert deep.multiply_cost_ms() >= shallow.multiply_cost_ms()
assert deep.per_slot_cost_us() <= shallow.per_slot_cost_us()
# 3. A depth-1 circuit fits depth-1 parameters exactly.
counter = DepthCounter(budget=shallow.depth)
squared_distance_circuit(counter, n_points=1_000)
assert counter.remaining() == 0
# 4. Adding a threshold step to depth-1 parameters must RAISE, not silently
# return a wrong answer — the failure this whole page exists to prevent.
counter = DepthCounter(budget=shallow.depth)
squared_distance_circuit(counter, n_points=1_000)
try:
threshold_circuit(counter, degree=4)
except DepthExceeded as exc:
assert "plausible but meaningless" in str(exc)
else:
raise AssertionError("exceeding the level budget must be detected")
# 5. Sized correctly, the same circuit fits.
params = derive_params(3)
counter = DepthCounter(budget=params.depth)
squared_distance_circuit(counter, n_points=1_000)
threshold_circuit(counter, degree=4)
assert counter.remaining() >= 0
# 6. Additions never consume a level, however many there are.
counter = DepthCounter(budget=0)
for _ in range(10_000):
counter.add()
assert counter.remaining() == 0
# 7. An unreachable depth must fail loudly rather than silently downgrade security.
try:
derive_params(40)
except ValueError as exc:
assert "restructure the circuit" in str(exc)
else:
raise AssertionError("an out-of-range depth must raise")
print("HE parameter derivation: all assertions passed")
_validate()
Assertion 4 is the whole argument for instrumenting depth in code. A circuit that exceeds its level budget produces a decryption that is a well-formed number — a distance, a count, a score — with no indication that it is meaningless. There is no exception at run time and no anomaly in the output distribution; the only defence is counting.
Incident Response and Edge Cases
- Results are subtly wrong for large inputs only. Depth is fine; the scale is not. CKKS
precision degrades with each rescale, and inputs at the top of their range exhaust the remaining
significand first. Raise
scale_bitsor normalise inputs to a tighter range before encryption. - A refactor added a multiplication. The depth counter catches it in CI, which is the entire reason to have one. Without it, the change ships and the failure appears as inexplicably wrong numbers weeks later.
- The ring dimension was chosen to hit a latency target. Then the security level is whatever the modulus and that ring imply — possibly well below 128 bits. Derive in the correct order and, if the latency is unacceptable, restructure the circuit or batch more aggressively.
- Batching is unused because the workload has few points. Then the parameters are oversized and every operation is paying for slots that sit empty. Either batch across queries rather than within one, or accept that homomorphic encryption is the wrong tool for a workload of a dozen points.
- A bootstrapping step is proposed to escape the depth limit. It works and it is expensive — typically orders of magnitude more than a multiplication. For spatial circuits it is almost always cheaper to restructure: return an encrypted intermediate and let the client finish in plaintext.
Frequently Asked Questions
Why can't parameters be changed after key generation?
Because the keys, the ciphertexts and the modulus chain are all defined relative to them. Changing parameters means new keys, which means re-encrypting every stored ciphertext. Treat parameter selection as a schema decision with a migration cost, not as a tuning knob.
How do I count depth for a comparison?
Comparisons are not native — they are polynomial approximations, and the depth is roughly the base-two logarithm of the polynomial degree when evaluated efficiently. A degree-4 approximation is depth 2, a degree-16 one is depth 4. The approximation's accuracy near the threshold is the other thing to check.
Is a larger ring dimension always slower?
Per operation, yes. Per data point, no: the slot count doubles with the ring, so a larger ring is cheaper per point provided you have enough points to fill it. Measure per-slot cost, not per-operation cost, and let the workload size decide.
What if the circuit's depth is data-dependent?
It must not be. A data-dependent depth means a data-dependent runtime, which leaks through timing, and it means the parameters cannot be sized. Restructure to a fixed-depth circuit, padding where necessary — the same discipline that oblivious access patterns require.
Related
- Homomorphic Encryption Basics — the parent method and its noise budget.
- Encrypted Spatial Range Queries with CKKS — the circuit these parameters are sized for.
- Practical Homomorphic Encryption for Spatial Queries — the latency breakdown at these parameter sets.
- Coordinate Masking with Paillier Encryption — the additive-only alternative with no depth to manage.
Up one level: Homomorphic Encryption Basics · Section: Secure Multi-Party Computation in Spatial Analytics.