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["CKKSContext"] C["encode(values)"] D["encrypt(plaintext)"] E["encrypted compute"] F["decrypt(ciphertext)"] G["decode(plaintext)"] A --> B --> C --> D --> E --> F --> G

First computation

import numpy as np

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


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

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 CKKSContext, CKKSParams, decode, encode


params = CKKSParams.python_test()
ctx = CKKSContext(params)
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()
ctx = keygen(params)

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 ring, slot count, explicit level schedule, nominal scales, key-switching primes, optional bootstrapping parameters, and runtime flags. levels[level] gives the active modulus basis and nominal scale at that level; default_level is the fresh-encryption compute level; highest_level is the top of the full schedule, including any bootstrap support tail.

CKKSContext holds the active parameters and any configured key material. keygen(params) creates the local context used by examples and tests. Use lower-level context construction when callers already have secret/public key material.

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.
  • 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.
  • bootstrap(ct) refreshes a ciphertext when the parameter set supports bootstrapping.

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()
ctx = keygen(params)

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()
ctx = keygen(params)

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()
ctx = keygen(params)

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 params.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; those transitions drop moduli and move downward through the schedule. Bootstrapping uses support levels above the normal compute range when the parameter set enables it.

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()
ctx = keygen(params)

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()
ctx = keygen(params)
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 and swaps the contained polys, not the outer Python object identity. 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()
ctx = keygen(params)

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