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_bits and the modulus chain. In CKKS each level consumes roughly scale_bits of the coefficient modulus, plus a larger first and last prime. A chain of depth+2\text{depth} + 2 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 NN — from the modulus, not from preference. Security tables map (total modulus bits, security level) to a minimum NN. Choosing NN 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 N/2N/2 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
Counting depth before choosing parameters A grouped bar chart of four spatial circuits with their multiplicative depth and the coefficient-modulus width each requires. An encrypted sum needs no levels. A distance computation needs one. Adding a degree-four threshold takes it to three, and adding a normalisation step takes it to five — which is the change that silently invalidates a parameter set chosen for the previous circuit. Counting depth before choosing parameters 40 bits of coefficient modulus per level, plus first and last primes 0 10 20 30 depth / bits ÷10 0 12 encrypted sum 1 16 distance to a point 3 24 distance + deg-4 threshold 5 32 + normalisation multiplicative depth modulus bits needed ÷10
The last bar is the trap: one extra normalisation step doubles the depth and the parameters chosen for the previous circuit decrypt to noise.

Reference Implementation

python
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

python
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()
The ring dimension follows from the modulus, not from preference A grouped bar chart across four ring dimensions showing the maximum coefficient modulus each supports at 128-bit security, and the cost of one homomorphic multiplication. Both grow together: a deeper circuit needs a larger modulus, which forces a larger ring, which makes every operation more expensive. The ring dimension follows from the modulus, not from preference security tables map modulus size to a minimum ring dimension 0 500 1000 bits / ms 109 3 N = 4096 218 9 N = 8192 438 33 N = 16384 881 118 N = 32768 max modulus bits at 128-bit security one multiply (ms)
Choosing N first and hoping the modulus fits is how deployments end up unable to state their security level. Derive depth → modulus → ring, in that order.

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_bits or 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.
Bigger rings are cheaper per point A falling curve of per-slot multiplication cost against the number of available SIMD slots, on a logarithmic axis. Cost per slot falls steadily as the ring grows, from about 1.5 microseconds at 2,048 slots to about 7 at 16,384 — wait, it falls — showing that a larger ring is economical precisely when the workload can fill it. Bigger rings are cheaper per point if you have enough points to fill the slots 10000 0 2 4 6 SIMD slots available (N/2) µs per slot per multiply per-slot cost (µs)
Measure per-slot, not per-operation. A ring that looks 30× slower is cheaper per point once its slots are full — and pure overhead when they are not.

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.

Up one level: Homomorphic Encryption Basics · Section: Secure Multi-Party Computation in Spatial Analytics.