Refresh a full-slot CKKS ciphertext with composable bootstrapping
Example source: examples/25_experimental_bootstrap.py
This example depletes a full-slot logN = 16 ciphertext, constructs a callable configured for one Engine, 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.values import EvaluationKeySet
from fhelium.eager import Engine
from fhelium.experimental.bootstrap.presets import cosine_depth_refresh_logn16_v1
config = fh.CkksConfig.parse(
fh.Preset.slots32768_scale50_depth27_int64,
galois_generator=5,
)
torch.set_default_device("cuda:0")
engine = Engine(
config,
allow_automatic_key_generation=False,
)
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 documented configuration selects 50-bit scale primes and one 50-bit terminal Q prime. The factory returns a compiled, callable FullSlotBootstrap.
The logn16 name documents the 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 a direct transform key is absent. Use rotation_strategy="direct" 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 an input at the circuit entry depth
A real application reaches the entry depth after useful operations. The example below consumes depths 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.depth < bootstrap.input_depth:
identity = engine.prepare_plaintext_for_multiplication(
engine.encode(
ones,
depth=ciphertext.depth,
scale=engine.config.default_scale,
)
)
ciphertext = engine.rescale_to_next_depth(
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 active prime_ids. The built-in topology uses all bootstrap.input_depth = engine.max_depth - 1. Entry preparation uses the actual input scale. The circuit still requires sufficient input precision and branch coordinates within the selected reducer's range.
Each depletion multiplication records the actual pending scale product, and each public rescale divides that scale by the product of its dropped Q group. 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:
- Let
be the product of the input Q group and the default scale. Multiply the input residues and scale by , preserving the message. - Apply nearest group rescale into the terminal Q group at
engine.max_depth, with actual scale . A separate metadata view at scale gives the fixed circuit coordinate . - Center each component modulo the terminal group product
and extend it into the Q basis atmodulus_raise_target_depth. This basis extension preserves the represented centered integers and scale; it is not a rescale. - Apply CoeffsToSlots. A diagonal stage multiplies at its prepared plaintext scale
and removes one Q group with product , giving . Plaintext scales are selected from the circuit's arithmetic targets, rather than fixed to . - Multiply by
, split by conjugation, apply periodic reduction to both branches, restore the imaginary branch, and recombine. - Apply SlotsToCoeffs with the same group-rescale rule, then multiply the output's recorded scale by
to restore coordinate .
The final output is a two-component coefficient-domain standard-RNS Q ciphertext at bootstrap.output_depth. Its refreshed.prime_ids records the active Q rows, and refreshed.scale records the actual output scale including entry-coordinate compensation.
6. Verify with the secret key
decoded = engine.decrypt_message(refreshed, secret_key, is_real=True)
error = (decoded - values).abs()
print("output depth:", refreshed.depth)
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 EvaluationKeySet, whose fields contain the rotation, relinearization, and conjugation capabilities required by the chosen composition. Evaluate maximum error, mean error, distribution shape, and workload-specific downstream effects. Establish a tolerance from those measurements for the selected workload and configuration.
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, depth 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 depth/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 output actual-scale recurrence.
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.values import EvaluationKeySet
from fhelium.eager import Engine
from fhelium.experimental.bootstrap.presets import (
cosine_depth_refresh_logn16_v1,
)
def deplete_to_bootstrap_input(
engine: Engine,
ciphertext: fh.Ciphertext,
target_depth: int,
) -> fh.Ciphertext:
r"""Consume public Q depths 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, next-depth `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=torch.get_default_device(),
)
while ciphertext.depth < target_depth:
identity = engine.prepare_plaintext_for_multiplication(
engine.encode(
ones, depth=ciphertext.depth, scale=engine.config.default_scale
)
)
ciphertext = engine.rescale_to_next_depth(
engine.ntt_domain_to_coefficient_domain(
engine.multiply_plaintext(
engine.coefficient_domain_to_ntt_domain(ciphertext),
identity,
)
)
)
return ciphertext
def main() -> None:
torch.set_default_device("cuda:0")
config = fh.CkksConfig.parse(
fh.Preset.slots32768_scale50_depth27_int64,
galois_generator=5,
)
engine = Engine(
config,
allow_automatic_key_generation=False,
)
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_to_bootstrap_input(
engine,
engine.encrypt_message(values, public_key),
bootstrap.input_depth,
)
torch.cuda.synchronize()
torch.cuda.reset_peak_memory_stats(torch.get_default_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).cpu()
error = (decoded - values.cpu()).abs()
print(f'input depth: {depleted.depth}')
print(f'output depth: {refreshed.depth}')
print(
'pipeline depth: '
f'{bootstrap.output_depth - bootstrap.modulus_raise_target_depth}'
)
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(torch.get_default_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
126
127
128