Skip to content

CKKS workflow

Use this page to choose a parameter set, create a context, encode values, encrypt, compute, decrypt, and decode. Read CKKS concepts for levels, scale, keys, and bootstrapping.

CKKS is an approximate-number homomorphic encryption scheme. It packs real or complex values into encrypted slots, then lets code add, multiply, rotate, and combine those slots without decrypting them. Ciphertexts carry scale, level, and noise metadata, and CKKS operations update that metadata while Ren schedules the underlying polynomial work.

flowchart TB A["CKKSParams"] B["CKKSKeySet"] C["CKKSContext"] D["encode(values)"] E["encrypt(plaintext)"] F["encrypted compute"] G["decrypt(ciphertext)"] H["decode(plaintext)"] A --> B --> C --> D --> E --> F --> G --> H

First computation

import numpy as np

from ren.schemes.ckks import CKKSParams, decode, decrypt, encode, encrypt, keygen


params = CKKSParams.python_test()
keys = keygen(params)
ctx = keys.context()

values = np.array([0.25, 0.5, 0.75, 1.0])

with ctx:
    pt = encode(values, msg_bound=1.0)
    ct = encrypt(pt)
    result = ct + 1
    level = result.level
    decoded = decode(decrypt(result), length=len(values))

print("level:", level)
print("slots:", [round(z.real, 3) for z in decoded])
level: 5
slots: [1.25, 1.5, 1.75, 2.0]

The example decodes the first four slots: [0.25, 0.5, 0.75, 1.0] becomes [1.25, 1.5, 1.75, 2.0]. Sequence encoding pads the remaining CKKS slots with zeros, and scalar constants apply to every slot, including that padded tail. The level stays at the fresh-encryption level because adding an integer scalar does not rescale.

CKKSParams.python_test() gives you a small, non-secure local-test parameter set. Use it for examples, unit tests, and CPU-only local experiments. For production or user-data workloads, choose parameters after reviewing security, precision, depth, and bootstrap requirements.

Encode and decode slots

Encoding is the boundary between Python values and CKKS plaintext slots. A sequence shorter than params.slot_count fills the first slots and pads the rest with zeros; a scalar encodes the same value in every slot. decode(pt, length=...) returns a prefix of the decoded slot vector, while omitting length returns the full vector.

import numpy as np

from ren.schemes.ckks import CKKSParams, decode, encode, keygen


params = CKKSParams.python_test()
keys = keygen(params)
ctx = keys.context()
values = np.array([0.25, 0.5, 0.75, 1.0])

with ctx:
    pt = encode(values, msg_bound=1.0)
    decoded = decode(pt, length=len(values))

print("decoded:", [round(float(x.real), 3) for x in decoded])
decoded: [0.25, 0.5, 0.75, 1.0]

Basic workflow

A local CKKS run has these steps:

from ren.schemes.ckks import CKKSParams, decode, decrypt, encode, encrypt, keygen


params = CKKSParams.python_test()
keys = keygen(params)
ctx = keys.context()

with ctx:
    pt = encode([1.0, 2.0, 3.0], msg_bound=4)
    ct = encrypt(pt)
    out = (ct + 1) * 0.5
    values = decode(decrypt(out), length=3)

print("slots:", [round(z.real, 3) for z in values])
slots: [1.0, 1.5, 2.0]

The with ctx: block makes ctx active. The active context provides parameters and key material for encode, encrypt, decrypt, arithmetic alignment, key switching, rotations, and level transitions.

Message bounds are public

msg_bound must be independent of secret data. Do not compute it from secret values, such as max(abs(v) for v in secret_values). Choose a bound from the public input contract.

Objects you will see

CKKSParams describes the slot count, explicit level schedule, nominal scales, key-switching primes, and optional bootstrapping parameters. levels[level] gives the active modulus basis and nominal scale at that level; bootstrap_output_level is where bootstrapping returns ciphertexts; highest_level is the top scheduled level; and highest_ring is both the ring at that level and the canonical Q basis owned by key material. Every scheduled ring is a contiguous window of highest_ring, but it need not be a prefix. When bootstrapping is enabled, bootstrap_output_level is derived from the highest level and the depth consumed by the bootstrap. log_range bounds ordinary messages by 2**log_range; for bootstrapping parameters it is also the maximum accepted bootstrap log_bound.

CKKSKeySet owns device-resident key material. CKKSKeySet.generate(params) creates the secret, public, and relinearization keys plus any Galois keys requested through rotations= or conjugation=; bootstrapping requirements are included automatically, and hoist_bootstrap_rotations=True adds a direct key per bootstrap baby-step rotation so the linear transforms hoist, at a cost of a few times more rotation-key memory. keygen(params) is a convenience wrapper for the same operation. Pass secret_key to either function to generate keys from an existing secret, or construct CKKSKeySet(...) directly when assembling existing material without generating anything. keys.context() creates the runtime context for those keys.

CKKSContext is the active runtime view of a key set. Access its key material through ctx.keyset, for example ctx.keyset.sk or ctx.keyset.get_galois_key(...). Entering it selects the key set's device for values created inside the block. Runtime policy lives on the context, not on the parameters: keys.context(track_plaintext_values=True) keeps diagnostic slot values on plaintexts and ciphertexts for tests and examples, and keys.context(strict_noise_bounds=True) raises when a tracked message bound exceeds q/2 instead of saturating it to unbounded. Contexts are not serialized; serialize the key set and create a fresh context after loading it.

Devices

CKKSKeySet.generate(...) and keygen(...) use the ambient DEV device by default. Pass device to select one explicitly, for example keygen(params, device="cuda:1"). When secret_key is provided, its device determines placement.

When an explicit RNG is provided, its device determines key placement. Passing both device and rng requires them to match.

keys.context() always uses the key set's device, regardless of the ambient device when the context is created. Entering the context selects that device for new plaintexts, ciphertexts, and polynomials, then restores the previous device on exit.

Move an existing key set before creating its context with cuda_keys = keys.to("cuda:1").

Device placement is runtime state and is not serialized. Deserialization uses the ambient device by default, or the device passed at the load boundary, such as CKKSKeySet.from_dict(payload, device="cuda:1").

Pass multiple device names to the example below, such as uv run docs/examples/ckks-devices.py cuda:0 cuda:1, to create one resident key set and context per GPU. Each with contexts[device]: block runs on that device; this does not split a single CKKS operation across devices.

import sys

from ren.schemes.ckks import CKKSParams, decode, decrypt, encode, encrypt, keygen


devices = tuple(sys.argv[1:]) or ("python",)
params = CKKSParams.python_test()
keys = keygen(params, device=devices[0]).realize()
contexts = {device: keys.to(device).realize().context() for device in devices}

for device, ctx in contexts.items():
    with ctx:
        ciphertext = encrypt(encode([1.0, 2.0], msg_bound=2)).realize()
        decoded = decode(decrypt(ciphertext), length=2)

    values = [round(float(value.real), 3) for value in decoded]
    print(f"{device}: ciphertext={ciphertext.device}, values={values}")
python: ciphertext=python, values=[1.0, 2.0]

Plaintext wraps one encoded slot vector in the Poly field m.

Ciphertext wraps an encrypted value in two Poly fields, a and b, plus scale/noise metadata. Ren uses the a/b field names consistently; do not read them as a universal notation requirement outside this codebase. Because Ciphertext is a dataclass containing Poly values, you can pass it to ren.jit.

Common operations

  • encode(values, msg_bound=...) turns Python values into a CKKS Plaintext, padding any unused slots with zeros.
  • decode(pt, length=...) converts a decrypted plaintext back to Python complex values. Pass length to read a prefix, or omit it to read all slots.
  • encrypt(pt) turns a plaintext into a Ciphertext using the active context.
  • decrypt(ct) returns a Plaintext using the active context's secret key.
  • ct + other, ct - other, and ct * other build encrypted arithmetic over ciphertexts, plaintexts, or scalars.
  • pt + other, pt - other, -pt, and pt * other build plaintext arithmetic over plaintexts or scalars. As with ciphertexts, a plaintext-plaintext or plaintext-float multiply rescales and consumes a level, because a plaintext's scale is fixed by its ring; multiplying by an integer does not.
  • Ciphertext.sum(...), Ciphertext.multiply(...), and Ciphertext.dot(...) provide higher-level reductions.
  • ct.rotate(step) rotates packed slots when the context has the required rotation key.
  • ct.to_level(level) drops a ciphertext to a lower level when operations need alignment. Quotient-only ranges use CRT truncation followed by one rescale when the working ring has enough capacity. Other ranges rescale directly to the destination ring and nominal scale.
  • ct.bootstrap(log_bound=...) refreshes a ciphertext when the parameter set supports bootstrapping. The public bound must satisfy abs(message) <= 2**log_bound. Setting BootstrappingParams(sparse_encapsulation_weight=h, ...) uses an ephemeral sparse secret of weight h (down to 32) only around the bootstrap mod-raise, which shrinks the eval-mod interval and supports negligible failure probabilities (Bossuat et al.). The scheme secret then defaults to dense, but a sparse one can still be requested with generate_prefix(secret_key_dist=...) — the interval is sized by h either way.

Reusable circuit

Keep circuit code separate from local encode, encrypt, decrypt, and decode code. A reusable circuit should accept encrypted or plaintext Ren objects and return encrypted or plaintext Ren objects.

from ren.schemes.ckks import CKKSParams, Ciphertext, decode, decrypt, encode, encrypt, keygen


def score(features: Ciphertext) -> Ciphertext:
    neighboring_sum = features + features.rotate(1)
    return (neighboring_sum * 0.25) + 0.5


params = CKKSParams.python_test()
keys = keygen(params, rotations={1})
ctx = keys.context()

inputs = [1.0, 2.0, 3.0, 4.0]

with ctx:
    encrypted = encrypt(encode(inputs, msg_bound=4))
    output = score(encrypted)
    decoded = decode(decrypt(output), length=len(inputs))
    level = output.level

print("level:", level)
print("slots:", [round(z.real, 3) for z in decoded])
level: 4
slots: [1.25, 1.75, 2.25, 1.5]

Weighted sums

Use Ciphertext.dot(...) when you need a slotwise weighted sum across ciphertext, plaintext, scalar, or per-slot inputs.

from ren.schemes.ckks import CKKSParams, Ciphertext, decode, decrypt, encode, encrypt, keygen


params = CKKSParams.python_test()
keys = keygen(params)
ctx = keys.context()

temperature = [1.0, 2.0, 3.0, 4.0]
humidity = [0.5, 1.0, 1.5, 2.0]
weights = [0.5, -0.25]

with ctx:
    encrypted_temperature = encrypt(encode(temperature, msg_bound=4))
    encrypted_humidity = encrypt(encode(humidity, msg_bound=2))
    weighted_sum = Ciphertext.dot([encrypted_temperature, encrypted_humidity], weights)
    decoded = decode(decrypt(weighted_sum), length=len(temperature))

print("weighted sum slots:", [round(z.real, 3) for z in decoded])
weighted sum slots: [0.375, 0.75, 1.125, 1.5]

Rotations

Rotations move packed slots cyclically across the full CKKS slot vector. If an example decodes only a prefix, wrapped values may be outside the printed window. The context must have rotation key material for the steps your circuit uses.

from ren.schemes.ckks import CKKSParams, decode, decrypt, encode, encrypt, keygen


params = CKKSParams.python_test()
keys = keygen(params, rotations={1})
ctx = keys.context()

values = [1.0, 2.0, 3.0, 4.0]

with ctx:
    encrypted = encrypt(encode(values, msg_bound=4))
    rotated = encrypted.rotate(1)
    window_sum = encrypted + rotated

    rotated_slots = decode(decrypt(rotated))
    sum_slots = decode(decrypt(window_sum))

rotated_slots = [round(z.real, 3) for z in rotated_slots]
sum_slots = [round(z.real, 3) for z in sum_slots]

print("rotated:", rotated_slots[:4], "...", rotated_slots[-1])
print("window sum:", sum_slots[:4], "...", sum_slots[-1])
rotated: [2.0, 3.0, 4.0, -0.0] ... 1.0
window sum: [3.0, 5.0, 7.0, 4.0] ... 1.0

Levels and scale in practice

Fresh encryption starts at the context's default_level, which defaults to params.default_level and can be overridden at keys.context(default_level=...). Addition keeps the level for aligned operands. Multiplication grows scale and noise. Ren uses rescale and level transitions to bring the represented value back to the level schedule. Level alignment converts to the requested destination without imposing intermediate rounding. An explicit sequence of rescale operations retains each rounding boundary, so it can produce different coefficients. Bootstrapping starts from the highest scheduled level and returns at params.bootstrap_output_level, which need not equal default_level.

A bootstrap base may use the same physical modulus ring as computation level 0 while assigning it a different logical scale. Ring-based scale lookup returns the scheduled computation scale; bootstrap code reads the distinct scale explicitly from params.bootstrap_base_level.scale.

Keep operands aligned before combining them. Most arithmetic paths align levels through the active CKKSContext; use to_level(level) when you want a specific boundary before a larger computation.

from ren.schemes.ckks import CKKSParams, decode, decrypt, encode, encrypt, keygen


params = CKKSParams.python_test()
keys = keygen(params)
ctx = keys.context()

with ctx:
    x = encrypt(encode([1.0, 2.0, 3.0], msg_bound=3))
    y = encrypt(encode([0.5, 0.25, 0.125], msg_bound=1))

    product = x * y
    aligned_x = x.to_level(product.level)
    combined = product + aligned_x
    decoded = decode(decrypt(combined), length=3)

    print("fresh level:", x.level)
    print("product level:", product.level)
    print("combined level:", combined.level)
    print("slots:", [round(z.real, 3) for z in decoded])
fresh level: 5
product level: 4
combined level: 4
slots: [1.5, 2.5, 3.375]

In this example, x starts at level 5. The product x * y rescales to level 4, so x.to_level(product.level) drops the original ciphertext to level 4 before the addition. The first three decoded slots are approximately x*y + x: [1*0.5 + 1, 2*0.25 + 2, 3*0.125 + 3], or [1.5, 2.5, 3.375].

Replay a CKKS circuit

Use ren.jit when you will call the same CKKS circuit repeatedly. The first matching call captures the circuit: it runs the Python function, realizes the output, and records the scheduled polynomial work. Ren pays that capture cost on the first call, so a one-off call is usually not worth jitting.

Later matching calls replay the recorded work with new ciphertext buffers. They run faster because Ren skips graph construction and scheduling.

import ren
from ren.schemes.ckks import CKKSParams, Ciphertext, decode, decrypt, encode, encrypt, keygen


params = CKKSParams.python_test()
keys = keygen(params)
ctx = keys.context()
captures = 0


@ren.jit
def affine(x: Ciphertext) -> Ciphertext:
    global captures
    captures += 1
    return (x + 1) * 0.5


with ctx:
    x1 = encrypt(encode([1.0, 2.0, 3.0], msg_bound=4))
    y1 = affine(x1)
    print("captures after first call:", captures)
    print("first output:", [round(z.real, 3) for z in decode(decrypt(y1), length=3)])

    x2 = encrypt(encode([2.0, 3.0, 4.0], msg_bound=5))
    y2 = affine(x2)
    print("captures after second call:", captures)
    print("second output:", [round(z.real, 3) for z in decode(decrypt(y2), length=3)])
captures after first call: 1
first output: [1.0, 1.5, 2.0]
captures after second call: 1
second output: [1.5, 2.0, 2.5]

CKKS and the engine

CKKS runs on the same polynomial runtime as raw Poly code. A ciphertext addition builds two polynomial additions, one for a and one for b. A ciphertext multiplication builds polynomial products, relinearization, and rescale work. Rotation builds automorphism and key-switching work. All of those operations enter the same lazy graph, scheduling, memory planning, backend lowering, and execution pipeline.

JIT sees the Poly fields inside CKKS objects. A jitted function can accept Ciphertext and Plaintext values directly or inside ordinary containers and dataclasses. JIT keys the contained Poly specs and binds their buffers during replay. The outer Python object's identity is not part of the cache key. See ren.jit replay for cache-key and replay rules.

This example prints the outer CKKS type, the contained component types, and the component root ops.

from ren.schemes.ckks import CKKSParams, encode, encrypt, keygen


params = CKKSParams.python_test()
keys = keygen(params)
ctx = keys.context()

with ctx:
    encrypted = encrypt(encode([1.0, 2.0, 3.0], msg_bound=3)).realize()
    output = (encrypted + 1) * 0.5

print("ciphertext:", type(output).__name__)
print("components:", type(output.a).__name__, type(output.b).__name__)
print("a root:", output.a.node.op.name)
print("b root:", output.b.node.op.name)
ciphertext: Ciphertext
components: Poly Poly
a root: RESCALE
b root: RESCALE

Multiplying by 0.5 uses CKKS plaintext-scalar multiplication. Ren applies the multiply to both ciphertext components, then adds a RESCALE step to keep the ciphertext on the expected scale and level schedule. Both component root ops print as RESCALE for that reason. The printed root op hides lower nodes such as the scalar multiply; use scheduling traces when you need to debug lowering or materialization.

Next pages