Refresh a full-slot CKKS ciphertext with composable bootstrapping
Example source: examples/17_ckks_bootstrap_logn16.py
This example depletes a full-slot logN = 16 ciphertext, constructs an engine-bound bootstrap, generates the three primitive key inputs required by that callable, and refreshes the ciphertext. The tutorial explains the mathematical range and state assumptions that the factory cannot establish from encrypted data.
1. Construct the documented bootstrap configuration
import torch
import fhelium as fh
from fhelium.core import EvaluationKeySet
from fhelium.experimental.bootstrap.presets import cosine_depth_refresh_logn16_v1
config = fh.CkksConfig.parse(
fh.Preset.slots32768_scale50_levels27_int64,
base_prime_bits=50,
)
engine = fh.CkksEngine(
config,
device="cuda:0",
allow_sk_gen=False,
galois_generator=5,
)
bootstrap = cosine_depth_refresh_logn16_v1(engine)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
The global CKKS default remains 40 bits; this measured configuration selects 50-bit scale primes and one 50-bit structural base Q prime. The factory returns a compiled FullSlotBootstrap, not a descriptive circuit awaiting another compilation step.
The logn16 name documents the measured configuration. The factory itself checks transform slot counts, structural-base/default-scale proximity, and depth, but it does not enforce this preset or certify a numerical range.
2. Understand the range precondition
The cosine factory sets
bootstrap.modular_reduction.input_bound == 1024
bootstrap.modular_reduction.fuse_input_normalization is True2
Let raw branch coordinate be
and the periodic target is
Fusion means FullSlotBootstrap folds evaluate() receives bootstrap.modular_reduction.reference(values) always expects normalized
For the full pipeline, both raw real and imaginary branch coordinates must lie within
3. Generate the required primitive keys
secret_key = engine.create_secret_key()
public_key = engine.create_public_key(secret_key)
rotation_keys = bootstrap.create_rotation_keys(
secret_key,
rotation_strategy="power_of_two",
)
relinearization_key = engine.create_relinearization_key(secret_key)
conjugation_key = engine.create_conjugation_key(secret_key)
evaluation_keys = EvaluationKeySet(
rotations=rotation_keys,
relinearization=relinearization_key,
conjugation=conjugation_key,
)2
3
4
5
6
7
8
9
10
11
12
13
The compact rotation inventory contains signed powers of two. FullSlotBootstrap composes them when an exact transform rotation is absent. Use rotation_strategy="exact" to trade more key memory for fewer online rotation compositions.
create_rotation_keys() derives only the inventory reported by key_steps(). Built-in polynomial recurrences require the separately generated RelinearizationKey, while full-slot branch splitting requires the ConjugationKey. EvaluationKeySet validates the evaluator-only inventory without mixing in the public or secret key.
4. Create a final-public-level input
A real application reaches the entry level after useful operations. The example below consumes levels with multiplication by encoded ones:
values = torch.linspace(-0.1, 0.1, engine.num_slots, dtype=torch.float64)
ciphertext = engine.encrypt_message(values, public_key)
ones = torch.ones(engine.num_slots, dtype=torch.float64)
while ciphertext.level < engine.final_public_level:
identity = engine.prepare_plaintext_for_multiplication(
engine.encode(
ones,
level=ciphertext.level,
scale=engine.config.default_scale,
)
)
ciphertext = engine.rescale_to_next_level(
engine.ntt_domain_to_coefficient_domain(
engine.multiply_plaintext(
engine.coefficient_domain_to_ntt_domain(ciphertext), identity
)
)
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
The entry ciphertext has axes [component, *batch, limb, coefficient], two components, coefficient domain, standard residues, Q basis, and exact active prime_ids. The built-in topology uses all default_scale or its square.
Each depletion multiplication records the actual pending scale product, and each public rescale divides that scale by the exact dropped Q prime. No public operation silently normalizes to default_scale.
5. Follow the refresh state transitions
Calling
refreshed = bootstrap(
ciphertext,
evaluation_keys=evaluation_keys,
)2
3
4
executes:
If entry scale is near
, multiply by encoded at to obtain pending scale. If already near , pass through.Divide and round by the final public scale prime, leaving the single active structural base Q row
[q_b]. Record the arithmetic scale first, then explicitly reinterpret the unchanged residues at under the private bootstrap policy.Center each component modulo
and extend it into the target Qprime_ids. Centered ModRaise preserves level target, represented centered integers, component count, domain, residue representation, and scale; it is not a rescale.Apply CoeffsToSlots. Every diagonal stage consumes one leading Q row and updates actual scale by
Multiply by
, split by conjugation, apply periodic reduction to both branches, restore the imaginary branch, and recombine.Apply SlotsToCoeffs with the same per-stage actual-scale recurrence.
The final output is a two-component coefficient-domain standard-RNS Q ciphertext at bootstrap.output_level, with exact engine.rns_layout.prime_ids(bootstrap.output_level). The final actual scale is the product of the SlotsToCoeffs recurrences; it is not assumed equal to default_scale.
6. Verify with the secret key
decoded = engine.decrypt_message(refreshed, secret_key, is_real=True)
error = (decoded - values).abs()
print("output level:", refreshed.level)
print("output actual scale:", refreshed.scale)
print("max error:", error.max().item())
print("mean error:", error.mean().item())2
3
4
5
6
Only client verification uses the secret key. Online bootstrapping uses the ciphertext and the supplied RotationKeySet, RelinearizationKey, and ConjugationKey. Evaluate maximum error, mean error, distribution shape, and workload-specific downstream effects; a factory name is not a tolerance guarantee.
7. Choose another built-in composition
from fhelium.experimental.bootstrap.presets import (
cosine_depth_refresh_logn16_8_28_v1,
exponential_depth_refresh_logn16_d16_v1,
)
alternative = cosine_depth_refresh_logn16_8_28_v1(engine)
exponential = exponential_depth_refresh_logn16_d16_v1(engine)2
3
4
5
6
7
The 8/28 cosine composition uses a degree-28 seed and eight double-angle steps. The exponential composition stores ascending power coefficients for input_bound=1024 with fused normalization, but their approximation error, level cost, and CKKS error propagation differ.
8. Compose directly
Factories are optional. The baseline can be assembled from public components:
from fhelium.experimental import bootstrap as bs
compiler = bs.Radix2FourierTransformCompiler(stage_count=2)
evaluator = bs.DiagonalBSGSEvaluator(
baby_step=16,
hoist_baby_rotations=True,
)
reduction = bs.CosineDoubleAngleReduction(
input_bound=1024,
double_angle_iterations=7,
approximator=bs.ChebyshevInterpolator(degree=44),
evaluator=bs.BinaryDecompositionChebyshevEvaluator(skip_near_zero=1e-15),
fuse_input_normalization=True,
)
bootstrap = bs.FullSlotBootstrap(
engine,
coeffs_to_slots_compiler=compiler,
coeffs_to_slots_evaluator=evaluator,
modular_reduction=reduction,
slots_to_coeffs_compiler=compiler,
slots_to_coeffs_evaluator=evaluator,
)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PolynomialApproximation coefficients are ascending. basis="power" means basis="chebyshev" means
Replace DiagonalBSGSEvaluator with DirectDiagonalEvaluator to change only the execution schedule. Both implement the same cyclic-diagonal map and level/scale transition, although their rotation count and rounding order differ.
For a different full algorithm, write an ordinary function or callable class. Document who owns raw-to-normalized conversion, the output target, every tensor axis and state transition, the required key material, and the exact output actual-scale recurrence.
Complete runnable source
r"""Deplete and refresh one full-slot CKKS ciphertext at $\mathtt{logN}=16$.
The measured profile uses 50-bit scale and structural-base primes, generator 5,
and a periodic-reduction raw `input_bound` of 1024. The example message range is
an empirical end-to-end input, not a proof of the encrypted branch bound.
"""
from __future__ import annotations
import time
import torch
import fhelium as fh
from fhelium.core import EvaluationKeySet
from fhelium.experimental.bootstrap.presets import (
cosine_depth_refresh_logn16_v1,
)
def deplete_public_levels(
engine: fh.CkksEngine,
ciphertext: fh.Ciphertext,
) -> fh.Ciphertext:
r"""Consume public Q levels with multiplication by semantic $1$.
Each iteration multiplies a two-component coefficient-domain standard-RNS
Q ciphertext by an unbatched NTT/Montgomery plaintext at scale $\Delta_0$,
then drops the leading Q row. The output remains coefficient-domain
standard RNS with axes `[component, *batch, limb, coefficient]`, unchanged
batch shape and component count, exact next-level `prime_ids`, and actual
scale $\Delta_{\rm out}=\Delta_{\rm in}\Delta_0/q_{\rm drop}$. The
returned ciphertext is functional; the argument's storage is not mutated.
"""
ones = torch.ones(
engine.num_slots,
dtype=torch.float64,
device=engine.device,
)
while ciphertext.level < engine.final_public_level:
identity = engine.prepare_plaintext_for_multiplication(
engine.encode(
ones, level=ciphertext.level, scale=engine.config.default_scale
)
)
ciphertext = engine.rescale_to_next_level(
engine.ntt_domain_to_coefficient_domain(
engine.multiply_plaintext(
engine.coefficient_domain_to_ntt_domain(ciphertext),
identity,
)
)
)
return ciphertext
def main() -> None:
engine = fh.CkksEngine(
fh.CkksConfig.parse(
fh.Preset.slots32768_scale50_levels27_int64,
base_prime_bits=50,
),
device='cuda:0',
allow_sk_gen=False,
galois_generator=5,
)
bootstrap = cosine_depth_refresh_logn16_v1(engine)
secret_key = engine.create_secret_key()
public_key = engine.create_public_key(secret_key)
rotation_keys = bootstrap.create_rotation_keys(secret_key)
relinearization_key = engine.create_relinearization_key(secret_key)
conjugation_key = engine.create_conjugation_key(secret_key)
evaluation_keys = EvaluationKeySet(
rotations=rotation_keys,
relinearization=relinearization_key,
conjugation=conjugation_key,
)
values = torch.linspace(
-0.1,
0.1,
engine.num_slots,
dtype=torch.float64,
)
depleted = deplete_public_levels(
engine,
engine.encrypt_message(values, public_key),
)
torch.cuda.synchronize()
torch.cuda.reset_peak_memory_stats(engine.device)
started = time.perf_counter()
refreshed = bootstrap(
depleted,
evaluation_keys=evaluation_keys,
)
torch.cuda.synchronize()
elapsed = time.perf_counter() - started
decoded = engine.decrypt_message(refreshed, secret_key, is_real=True)
error = (decoded - values).abs()
print(f'input level: {depleted.level}')
print(f'output level: {refreshed.level}')
print(
'pipeline depth: '
f'{bootstrap.output_level - bootstrap.modulus_raise_target_level}'
)
print(
f'periodic raw input bound: {bootstrap.modular_reduction.input_bound}'
)
print(f'output actual scale: {refreshed.scale:.6g}')
print(f'rotation keys: {len(bootstrap.key_steps("power_of_two"))}')
print(f'bootstrap seconds: {elapsed:.3f}')
print(f'max error: {error.max().item():.6g}')
print(f'mean error: {error.mean().item():.6g}')
print(
'peak allocated GPU GiB: '
f'{torch.cuda.max_memory_allocated(engine.device) / 2**30:.3f}'
)
if __name__ == '__main__':
main()2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125