| name | fhe-application-design |
| description | Design and build Fully Homomorphic Encryption (FHE) applications using OpenFHE. Guides developers from problem statement through privacy model, feasibility assessment, scheme selection, circuit design, SIMD data layout, parameter selection, and code generation. Use this skill whenever the user mentions FHE, homomorphic encryption, encrypted computation, computing on encrypted data, OpenFHE, CKKS, BFV, BGV, privacy-preserving computation, or wants to design any application that processes data without decrypting it. Also use when the user asks about FHE feasibility, whether a workload can run under encryption, or how to structure a client-server protocol for encrypted data. Use this skill even if the user doesn't explicitly say "FHE" — if they describe a scenario where computation must happen on data that the computing party cannot see, this skill applies.
|
| license | Apache-2.0 |
| compatibility | OpenFHE (C++ or Python); Niobium nb FHE DSL (niobium-client) |
| metadata | {"author":"Niobium Microsystems","version":"0.11.0"} |
FHE Application Design ("FHEanna")
Informally known as FHEanna (pronounced "fee-AH-na"). Patent pending.
The install/trigger name remains fhe-application-design.
This skill guides developers through designing and building FHE applications
using OpenFHE. It targets developers who are new to FHE but know how to code.
The approach is top-down: start with the privacy problem, establish feasibility,
then progressively work down through scheme selection, circuit design, data
layout, parameter selection, and implementation.
How to Use This Skill
Follow the stages below in order. Each stage has a brief description here in
the SKILL.md, with pointers to reference files that contain deeper guidance.
Read the relevant reference file when you need the detail for a given stage.
The stages mirror a real FHE design process. Do not skip stages — each one
produces inputs that the next stage depends on.
Three versions of the application. Keep these distinct throughout — the
whole methodology is the disciplined path from the first to the third:
- The reference — the full plaintext computation, the ground truth. The
user provides this (for ML workloads it is a firm requirement; see Stage 3).
Every later version is measured against it.
- The twin — a plaintext model of the FHE-shaped computation: the same
algorithm but with every operation replaced by an FHE-friendly one
(non-linearities → polynomial approximations), FHE-friendly data types, and
precision quantized to match the CKKS scaling. Building the twin is ours
(the skill's / the agent's) job. It is introduced in Stage 3, parameterized
through Stages 4–6, and completed and validated against the reference in
Stage 7 — the gate before any encrypted code is written.
- The FHE program — the encrypted implementation (Stage 8). If the twin is
decode-safe and matches the reference, this step should reproduce it modulo
encryption noise.
Communicating with the user
The typical user of this skill is a capable developer or domain practitioner who
has little or no exposure to Fully Homomorphic Encryption (FHE). Calibrate
the conversation accordingly. This governs user-facing dialog and report prose;
it does not lower the technical precision of the design reasoning, the code, or
the reference material.
-
Keep the FHE register plain by default. Assume no FHE background. In
conversation, explain what you are doing in everyday terms and keep deep FHE
jargon out of it — terms like bootstrapping, modulus/level budget, rescaling,
ring dimension, RNS limbs, relinearization, Chebyshev approximation, SIMD slot
packing, decode/noise budget. When such a concept genuinely must appear, give
a one-line plain-language gloss the first time (e.g. "bootstrapping — a
refresh step that restores headroom for more computation"). Only shift into
fluent FHE terminology if the user demonstrates FHE fluency or explicitly asks
for the depth. Reserve the full vocabulary for code, references, and the
evidence tables in reports — not the back-and-forth.
-
Spell out every acronym on first use. In conversation and in report
prose, write the expansion the first time and the acronym thereafter —
"Fully Homomorphic Encryption (FHE)", "convolutional neural network (CNN)",
"remaining useful life (RUL)", "multi-layer perceptron (MLP)", and so on. This
applies to both FHE-domain acronyms (FHE, CKKS, BFV, BGV, SIMD, RNS) and
application-domain acronyms. Do this consistently; do not assume any acronym
is common knowledge.
-
Ask about application-domain familiarity early, and adapt to it. FHE
familiarity is assumed low (rule 1) and is not worth asking about. But
application-domain familiarity varies widely and is worth one question. At
the start of Stage 1 — once you know the workload's domain (from the reference
package or the user's problem statement) — ask the user to self-rate their
familiarity with that specific domain, e.g.:
How familiar are you with <domain, e.g. "predictive-maintenance / remaining-useful-life modeling">?
(a) New to it — please explain domain terms as we go
(b) Some working knowledge
(c) Expert — use the domain's terminology freely
Record the answer and calibrate application-domain terminology to it: for
(a), introduce domain terms in plain language and avoid unexplained jargon;
for (c), use domain terms freely. If the user doesn't answer, default to (a).
This axis is independent of the FHE register, which stays plain regardless.
Attribution notice on every generated artifact
Every file this skill produces — all code and scripts (C++, Python, shell,
CMake, .niob) and all reports/docs (design narrative, results report,
protocol/threat model, run README, and any other markdown) — must carry a notice
identifying it as machine-generated:
Generated by the Niobium FHE Application Design AI Assistant (FHEanna).
Placement and syntax:
- Code / scripts / CMake: as the first line(s), using the file's native
comment syntax —
// for C++, # for Python / shell / CMake. Example:
// Generated by the Niobium FHE Application Design AI Assistant (FHEanna).
- Markdown reports/docs: as a line directly under the title, italicized —
_Generated by the Niobium FHE Application Design AI Assistant (FHEanna)._
Keep it to that one line (optionally append the skill version, e.g. — v0.9.0).
Do not add it to files the skill did not author (the user's reference model, test
data, third-party sources) or to transient run artifacts (key dirs, .fhetch
traces, serialized ciphertexts). The notice states provenance; it does not claim
copyright and does not replace the project's own LICENSE/NOTICE.
Stage 0: Prepare the Build-and-Run Environment
Before any design work, make sure there is a place where both the twin and the
FHE program can actually be built and run. Do this once, up front — a broken
toolchain discovered three stages into a design is the most avoidable kind of
frustration. Treat it as a gate: do not start Stage 1 until the smoke test
passes.
The setup is deliberately small because the work runs at two speeds:
- The light tier (Stages 1–7) — always run it yourself. The design, the
parameter sweep, and the twin-vs-reference validation are pure Python (numpy)
and run in your own environment as you converse.
- The heavy, containerized tier (Stage 8, and the optional Fog variant) — the
prebuilt FHE-dev image. Building and running the encrypted OpenFHE app is too
heavy for a light environment, so it happens in a prebuilt FHE-dev image.
You pull this image — you never build OpenFHE from source. Who runs the
container depends on your execution mode (below), not on which product the user
is in.
Three one-time steps:
- Install Docker (Docker Desktop on macOS/Windows) if it isn't already present
— the only unavoidable local install, and far easier than building OpenFHE.
- Pull the image:
docker pull ghcr.io/niobiuminc/fhe-dev:v0.7.0 — the
pinned release this skill version was validated against (:latest tracks
the newest release; prefer the pin for reproducibility).
- Run the smoke test:
docker run --rm ghcr.io/niobiuminc/fhe-dev:v0.7.0 fhe-smoke-test. It builds
and runs a trivial OpenFHE program and a numpy stub; a final SMOKE OK means
the environment is ready.
Determine your execution mode at this step — probe, don't assume. Try to run
the smoke test in your own shell. Two outcomes:
- self-run — the command executes and you can read its output (a shell with
Docker access, e.g. Claude Code on a Docker-capable machine). Then you run the
containerized tier yourself throughout: execute the build,
run_test, the
two-process demo, and any Fog validation; read the output; and iterate on build
errors autonomously — surfacing results and decisions, not each command. Do not
ask the user to run or paste things you can run yourself, and do not gate each
step on a user turn.
- hand-off — your shell cannot reach Docker (the Cowork sandbox, or any
Docker-less environment). Then author each exact command, ask the user to run
it, and iterate on what they paste back (the careful one-command-at-a-time
rhythm).
Record the mode once, and let the user override it in a sentence ("I'm in Claude
Code — run things yourself" / "just give me the commands"). Emit the same
copy-pasteable commands in either mode — only who executes changes. At Stage 8
the container is invoked with the project folder bind-mounted (-v "$PWD":/work),
compiling the source you wrote and writing results back into the same folder; the
container is a plain build box — you, not the container, do the design.
A capacity limit is separate from execution mode. Even in self-run mode a
particular step may exceed the local machine (e.g. replaying a deep bootstrapped
circuit that needs a large-memory host). That is a capacity hand-off of one
step to a bigger box or the compilation service — decided on resources, not on
whether you can run Docker at all.
For detailed guidance: Read references/environment-setup.md (prerequisites,
the mounted-folder data bus, the self-run vs. hand-off loop, torch references,
and troubleshooting).
Stage 1: Establish the Privacy Model
First, ask the application-domain familiarity question (see "Communicating
with the user," rule 3) and record the answer — it sets how you use
application-domain terminology for the rest of the engagement. Keep the FHE
register plain regardless.
Then, before thinking about circuits, parameters, or code, work with the user to
answer five questions:
-
Who are the parties, what do they hold, and who are the adversaries?
Map out every participant: what data they hold, what must stay private,
who might try to learn it, what vantage point the adversary has (observing
network traffic, server memory), and what the adversary can do (semi-honest
vs. malicious).
-
Who encrypts the data, and at what cadence? These two questions together
set the packing strategy (Stage 5) — and packing later determines whether
you can filter out-of-domain records, so settle it here.
Who encrypts — two fundamentally different patterns:
- Single encryptor: one party holds all the input data and encrypts it
(e.g., a company encrypting its own dataset for server processing). This
party can freely pack records across SIMD slots within a ciphertext.
- Independent encryptors: each data owner encrypts their own data
independently (e.g., individual customers each submitting encrypted
records to a bank). Different owners' data cannot share a ciphertext
— doing so would require sharing encryption keys, letting one owner
decrypt another's data.
If independent encryptors, cross-record SIMD batching is not possible without
violating privacy.
Query cadence — does the workload run one query at a time (interactive,
latency-oriented) or over a batch the caller already holds
(throughput-oriented)? This is orthogonal to who-encrypts, and it decides
packing_mode:
| Encryptor | Cadence | packing_mode |
|---|
| Single (owns all data) | Batch | batched (column-major SIMD: one ciphertext per feature, records across slots) |
| Single | One-at-a-time | per_record (or micro-batch) |
| Independent encryptors | (any) | per_record |
Why it matters downstream: per_record decrypts each query independently, so
individual out-of-domain failures can be isolated, skipped, and counted
(proportional filtering). batched decrypts the whole batch in one
Decode, so a single out-of-domain slot value throws and takes the entire
batch down — there is no per-record isolation, and any filtering must happen
pre-flight and be airtight (Stages 3 and 5). Record packing_mode; it is
an input to the feasibility check in Stage 3.
-
Who should see the output?
Determine who holds the decryption key. Flag key proliferation as a risk.
Consider threshold (multi-party) decryption if multiple parties need output
access. Consider whether the same keys should be used across successive
runs of the program.
-
Is input privacy sufficient, or is there an output privacy problem?
FHE protects inputs during computation but says nothing about what the
output reveals. Consider whether the decrypted result could leak information
about private inputs through repeated queries, aggregate precision, model
inversion, or protocol side channels. Consider mitigations: query rate
limiting, output coarsening, differential privacy.
-
Is there an output integrity problem? FHE guarantees that the
computation is correct, but the party who decrypts the result controls
what gets reported. Ask: does the decryptor have an incentive to
misrepresent the result to another party? If a customer decrypts their
own credit score, they could claim any score they like — the bank cannot
verify it. This is a fundamental protocol flaw, not a privacy problem.
When the consumer of the result (the party who acts on it) is different
from the decryptor, the protocol must ensure the consumer can verify or
directly obtain the true result. The standard technique is
transciphering: embed an FHE-friendly symmetric cipher (e.g., Rubato,
HERA, Pasta) inside the homomorphic circuit so a copy of the result is
encrypted under a key the consumer holds. The decryptor strips the FHE
layer and obtains two things: the plaintext result (which they can read)
and an opaque symmetric ciphertext of the same result (which they pass
to the consumer). The consumer decrypts the symmetric layer with their
key and obtains the verified result. This dual-output pattern gives
the decryptor transparency (they see their own result) while giving the
consumer integrity (they know the result is genuine). It adds modest
depth (4–6 levels for Rubato) and avoids digital signatures under FHE
(which would be prohibitively deep). See Stage 5 for circuit-level
details.
After this stage, you should be able to state clearly: who the parties are,
who encrypts (single vs. independent encryptors), what the adversary model is,
who decrypts, whether FHE alone is sufficient or needs to be combined with
other techniques, and whether the protocol needs output integrity protection
(transciphering or another mechanism).
Derive the clear/encrypted decomposition (don't guess it)
Most real applications are NOT whole-program FHE. They are a pipeline with a
trust boundary, and where each stage runs should be derived from three lenses,
not chosen by feel:
- Dataflow — what each stage reads and writes (so you know what actually
has to cross the boundary).
- Privacy — which values are sensitive (must be hidden from the server) vs.
public.
- Feasibility — which operations are FHE-friendly vs. FHE-hostile (hashing,
sorting, argmax/top-k, data-dependent branching are hostile).
There are exactly three placements for a stage:
| placement | runs on | sees plaintext? | legal for |
|---|
client_clear | client | yes (its own) | anything the client holds in plaintext |
server_clear | server | yes | public data only (offload without FHE cost) |
server_encrypted | server | no (under CKKS) | FHE-feasible computations |
The invariant — privacy dominates feasibility. A sensitive value may never
run in server_clear (that is a leak), no matter how FHE-hostile it is.
Feasibility only bounds server_encrypted; client_clear is legal only for data
the client already holds in plaintext. Put positively: minimize the encrypted
core — a stage belongs in server_encrypted only when it is sensitive and
must run server-side and is FHE-feasible. If it can run on the client's own
data (even if FHE-hostile), put it in client_clear (this is why a phonetic hash
or a top-k ranking runs client-side); if it is public, put it in server_clear
(e.g. a recommender's item catalog is a plaintext operand, so scoring becomes a
cheap ciphertext×plaintext multiply). Model weights / a public catalog are
plaintext operands the encrypted core reads — not a separate stage.
The dead end. If a stage is sensitive and FHE-infeasible and not
runnable on the client (it needs a foreign secret — e.g. a sensitive-on-sensitive
join with data-dependent control flow), there is no legal placement. Do not
demote it to server_clear to make it "work" — that leaks. Stop and redesign:
reformulate the hostile step into an FHE-friendly representation (Stage 3/5),
relax a specific privacy label only with the owner's explicit consent, or
restructure so the client can precompute it.
When the privacy model is ambiguous, assume the standard FHE model — the
client's input is sensitive and the server computes on it without seeing it (so
there is an encrypted core) — and state that assumption, rather than concluding
"no FHE needed." Only mark data non-sensitive when it is clearly public.
For detailed guidance: Read references/fhe-privacy-model.md
Stage 2: Assess FHE Feasibility
Now determine whether the computation itself is tractable under FHE. Apply
four tests, in order of importance:
-
Can the computation be made data-oblivious? The complete sequence of
operations must be determined before any input data arrives. If the
algorithm branches based on encrypted values, it cannot run under FHE
as-is. It must be restructured into a fixed arithmetic circuit.
-
Does the computation stay in one lane — arithmetic or logical? FHE
schemes are optimized for either bulk arithmetic (add/multiply on packed
numbers) or logical operations (Boolean circuits on bits). Workloads that
cross between these domains pay enormous costs. A workload that stays
cleanly in one lane is far more tractable.
-
How deep is the multiplication chain? Every homomorphic multiplication
adds noise. The longest chain of dependent multiplications is the
multiplicative depth, and it drives nearly every parameter choice.
Shallow circuits (a handful of sequential multiplications) are practical.
Treat the no-bootstrap depth ceiling (Stage 6: roughly depth 25–30 at
N = 2^16, 128-bit, scaling 45) as a hard feasibility bound, like the ring
floor: a circuit above it needs depth reduction, a user-approved
client-assisted recryption split, or bootstrapping — the escalation ladder
in Stage 6, every rung of which beyond depth reduction requires the user's
advance approval.
-
Is there natural SIMD parallelism? FHE schemes pack thousands of
independent values into a single ciphertext. A single operation processes
all of them simultaneously. Workloads that apply the same operation to many
independent data points (scoring a model across thousands of records,
evaluating a function on batched inputs) exploit this beautifully.
Important: SIMD parallelism depends on the encryption model from
Stage 1 Question 2. Single-encryptor scenarios get full SIMD utilization
— one party packs all records across slots. Independent-encryptor
scenarios often have poor SIMD utilization — each party uses only a
few slots (e.g., 15 features out of 32,768 available slots). The
remaining slots are wasted. Do not confuse task-level concurrency
(processing multiple independent requests on separate CPU threads) with
SIMD parallelism (packing multiple data items into one ciphertext). The
former is always available; the latter depends on the encryption model.
Be honest about SIMD utilization in the assessment — poor utilization
is a real cost of the independent-encryptor model, not a showstopper,
but it should be acknowledged.
If the answer to #1 is "no, and it can't be restructured," FHE is not the
right tool. If #2 reveals constant lane-crossing, the cost may be prohibitive.
If #3 yields very deep circuits with no way to reduce depth, bootstrapping
costs may dominate. If #4 yields no parallelism, performance will suffer
(though task-level concurrency on the server can still provide throughput).
For detailed guidance: Read references/fhe-what-fhe-can-and-cannot-do.md
Stage 3: Design the Plaintext Algorithm
Get the computation working in plaintext first, with the FHE client-server
structure already in place:
-
Write the entire program without encryption and run it against thorough
test data. This becomes the ground truth that every subsequent version is
tested against.
Firm requirement for machine-learning workloads: the user must bring a
complete plaintext implementation of the model AND a representative test
set with expected outputs before any FHE design work proceeds. This is not
optional. Every FHE design decision downstream — Chebyshev approximation
degree, scaling modulus size (q_i), multiplicative depth, ring dimension —
trades accuracy against security and performance, and those trades can
only be evaluated against END-TO-END MODEL ACCURACY on real test data, not
against per-operation error bounds. Without the plaintext model and test
set there is no way to tell whether a cheaper parameter choice costs 0.1%
accuracy or destroys the model.
Pick the metric to match the task. For rare-class workloads (fraud,
intrusion, anomaly detection) raw accuracy and even decision-agreement are
misleading: the negative class dominates, so a polynomial that destroys the
detector can still show >99% agreement (a low-degree approximation can score
99.6% agreement while detecting zero anomalies). Set the accuracy floor in
the task metric — recall/precision/F1 on the target class — and evaluate
every gate, sweep point, and comparison against it.
-
Separate client-side and server-side responsibilities cleanly. The server
must complete its work in one pass — no round trips, no branching on
intermediate values. (Sole exception: a client-assisted recryption split
for circuits over the depth ceiling, which the user must approve in
advance — see Stage 6.)
-
Remove all data-dependent control flow. Replace conditional branches with
branchless arithmetic (evaluate both sides and use an arithmetic selector).
Note: data-dependent data flow (e.g., y = x[i]) is fine — it's
data-dependent control flow (e.g., if x > 5) that must go.
-
Count the multiplicative depth. Look for ways to reduce it: favor addition
over multiplication, use tree-structured reductions, reorder operations to
minimize the critical path.
-
Replace non-linear functions (division, comparison, square roots, sigmoid,
tanh) with polynomial approximations — typically Chebyshev series. Each
approximation adds depth, so there's a direct tradeoff between accuracy
and cost. Ensure inputs are normalized to the approximation's valid range,
and run the activation-range feasibility check below before committing
to the model as given — a polynomial approximation is only as good as the
match between its domain and the operands that actually reach it.
-
Constrain data types and precision. Move from floating-point to integer or
fixed-point arithmetic. Aim for 32 bits or less of precision. Consider
non-linear scaling of inputs to reduce dynamic range.
Steps 2–6 are the beginning of the twin. The reference (step 1) stays as the
user gave it — the ground truth. Steps 2–6 start the second version, the
faithful twin: the FHE-shaped plaintext computation (branchless, polynomial
non-linearities, fixed-point types). You cannot finish it yet — the exact
Chebyshev degrees/domains are fixed in Stage 5 and the quantization step
(Δ = 2^scaling) in Stage 6 — so at this stage the twin is a skeleton with those
knobs still open. Keep it parameterized: it is the object you will sweep to
select those parameters in Stages 5–6, then freeze, complete, and validate
against the reference in Stage 7. Do not fold the two versions together; the gap
between them is exactly what Stage 7 measures.
Activation-range feasibility check (and when to recommend changing the model)
This is a pre-design precondition, not a mid-design check — run it as a gate
and stop if it fails. For the ML-inference template the decisive entry
condition is that the reference model keeps every layer's pre-activations in a
compact band on the representative data (roughly within ±8 — the widest a
low-degree Chebyshev fits and still decodes inside the no-bootstrapping depth
budget). Confirm this BEFORE authoring anything: it is cheap (one forward pass
of the plaintext reference) and it is the difference between "fill in the
template" and days of degree/domain/depth/filter thrashing. Grade it:
- bulk within band, no tail → easy: low degree, no filter.
- bulk within band, thin benign tail → workable: tight domain + a pre-flight
filter (the composition guard must confirm the rejected set is not enriched
for the target class).
- bulk exceeds the band → not feasible as-is. Do not proceed to design and
do not try to rescue it with a higher degree (that overruns the depth budget
and overflows decode). Hand back a model-side recommendation — weight decay /
BatchNorm at train time, clipped activations, input normalization — which
returns as a new reference that re-enters this gate.
The agent enforces this deterministically (it will refuse to design an
out-of-band model rather than rely on judgment to stop). The rest of this
section is the mechanism behind the grades.
A Chebyshev (or Taylor) approximation is only valid inside its fit interval;
outside that interval it does not merely lose accuracy, it diverges — by many
orders of magnitude for a steep function like sigmoid. So before locking in the
model, check that each non-linearity's approximation can be made both safe
(bounded) and accurate within the depth budget. There are two distinct
failure modes, and they have two different owners:
-
Out-of-range (a design-side bug). The approximation domain does not cover
the operands that actually reach the function. The polynomial evaluates to
astronomical values, which overflow the CKKS scale (decrypt fails with
"approximation error too high") or, at low degree, return bounded garbage.
Fix on the design side: set the domain from the measured operand range. Use
a per-call domain when different call sites see different ranges (e.g., a
per-layer domain for each activation in a network), since a tighter interval
is a better fit at a fixed degree.
-
Range-too-wide-for-budget (escalate beyond domain tuning). Even with the
domain matched to the operand range, no approximation degree affordable within
the depth budget meets the end-to-end accuracy floor — widening the domain to
cover the full range forces a low-degree polynomial to smear across the
function's steep region. Domain tuning alone cannot fix this. When you hit it,
walk the escalation ladder in step 4 below: filter the out-of-domain
records if that stays within budget, else change the incoming model.
(Often the offending operands are rare outliers, and filtering them keeps a
tight, accurate domain — see "Filter, don't force.")
Run the check like this, using the plaintext model and representative test set
that Stage 3 requires:
- Measure the input range to every non-linearity over the test set — the
min/max and a high percentile band (e.g. 0.1–99.9%) to separate the genuine
operating range from rare outliers.
- Budget the degree. From the depth budget (Stages 5–6) and the number of
approximations on the critical path, back out the maximum degree affordable
per call (
chebyshev charges ceil(log2(degree+1)) + 1 levels).
- Estimate achievable accuracy — on the real polynomial, not the twin.
Build the actual Chebyshev approximation at that maximum degree over the
measured range, run the forward pass with it in plaintext, and compare to the
plaintext model. Do not use the generated
_ref cleartext twins for this
measurement: the twins apply the true activation, so they report ~perfect
accuracy and hide exactly the polynomial degradation you are trying to bound
(this masking is what lets domain problems survive to the encrypted run). If
the real polynomial clears the floor over the full measured range, proceed
with the matched domain.
- If it cannot clear the floor, escalate cheapest-first — don't burn the
iteration budget on parameters that cannot win. Out-of-domain operands are
usually a tiny fraction of records, so the cheapest fix is often not to widen
the domain (which craters accuracy) but to keep the domain tight and filter
the offenders. The ladder, in order:
- a. Filter the out-of-domain records (keep the tight, accurate domain;
reject the records whose pre-activations escape it; accept a small,
reported reject rate). This is a two-threshold, packing-aware decision —
see "Filter, don't force" below.
- a′. Clip (winsorize) instead of rejecting — when semantics allow. For
continuous, standardized features, saturating each feature at the box
bounds is often strictly better than rejecting: it is airtight by
construction (every record is admitted, so batched packing is safe), the
reject rate is zero and the composition guard is vacuous, and on scaled
data it typically flips very few decisions (measure and report the flip
rate on the reference model). Only valid where an extreme value saturated
to the bound still means "extreme" — never clip categorical/ordinal
encodings, hashes, or identifiers, where a clipped value is a different
value, not a larger one.
- b. (batched only) Drop batching. If an airtight pre-flight filter would
reject too many, recommend switching to per-record packing so failures can
be isolated post-flight instead of taking the whole batch down.
- c. Change the model. If filtering can't stay within the reject budget —
or its reject set is enriched for the target class — recommend bounding
the pre-activations and retraining: normalization before each
non-linearity (BatchNorm/LayerNorm), weight/activation normalization, or
folding a per-layer scale into the linear weights and the activation
closure. BatchNorm is usually cleanest — it keeps each activation's input
near unit scale (a degree-5–7 sigmoid/tanh over a small domain is then
accurate) and at inference folds into the adjacent linear layer at zero
added multiplicative depth.
State whatever you recommend explicitly, with the evidence: the measured range,
the max in-budget degree, the resulting polynomial accuracy, the reject rate
and its target-class composition, and the floor(s) missed. Model changes require
retraining (BatchNorm cannot be bolted onto a trained model — its effect
depends on being present during training); check whether the user already has a
normalized/bounded variant before asking them to train one. Always deliver a
best-effort runnable design (tight domain + the filter) so the user has a working
pipeline to compare against.
Three field notes on rung c (from applying it in practice):
- Normalization bounds the bulk, not the tail. BatchNorm plus input
clipping still leaves worst-case pre-activations several× beyond the p99.99
band (rare inputs aligned with learned weights), so expect the re-entered
model to land in the workable grade — tight domains plus a box — rather
than automatically in easy.
- Report the retrain's price. Measure the task-metric delta between the
original and the FHE-friendly reference and put it in the gate ledger. The
quality cost of an FHE design usually lives in the model change, not in
encryption (which should be decision-exact downstream) — attribute it there
so nobody blames FHE for it.
- Prefer empirical envelopes over provable bounds. Interval-arithmetic
worst-case bounds on pre-activations tend to be uselessly loose, and
training-time hard projections that force tight provable bounds can fight
the optimizer and crater the task metric. Before committing to
bounded-by-construction training, cost it against the alternative: calibrate
approximation domains on the pre-activation envelope over the full training
set plus a safety margin, and document the residual escape risk (Stage 9)
with its packing-dependent blast radius and mitigation.
Filter, don't force: the two-threshold check and the composition guard
When a tight domain is accurate but a few records escape it, the right move is to
reject those records, not to distort the approximation for everyone. Make it
a bounded, measured decision:
-
Where the filter runs — client input-prep, NOT the FHE program. The
rejection happens before encryption, as part of the client's input
preparation: the client reads feature_bounds.csv, drops out-of-domain
records, and only then encodes/encrypts the admitted batch. Do not
implement the bounds check inside the FHE program (the server circuit). An
out-of-domain record isn't a "slightly wrong" answer — the polynomial diverges
on it and (in a batched pack) overflows the shared scale so the entire batch
fails to Decode; the float reference twin can't see this (no scale), so it
must be prevented at the input, not patched in-circuit. Authoring the check
into the server circuit also forces fragile column-major compaction and
wire-serialization workarounds for no benefit. Keep the server circuit a plain
forward pass over the admitted batch; let the client (the encrypt program)
enforce the box.
-
What you filter against (and why it's a proxy). The true constraint is the
activation's domain on its pre-activations (W·x + b), but the client
can't evaluate that — the weights are the server's. So the runtime check is a
per-feature input-bounds box (feature_bounds.csv), a deliberately loose
outer approximation of the true acceptance polytope. The design phase
(which has the model) calibrates that box: seed it from training-data
per-feature min/max, then tighten until admitted records keep their
pre-activations inside the domain on the representative set. The design emits
the CSV (input ranges only — no weights, so it's trust-model-safe); the
client enforces it pre-flight.
-
Where the box lives (fixed path — do not improvise). Treat
feature_bounds.csv as part of the client's fixed I/O contract, shipped and
read from the same agreed location as the input data (the encrypt program reads
it before packing). Pin that path once and use it on both the design side (which
emits the CSV) and the client side (which reads it). Do not invent a second
location: a path the client isn't looking in makes it fail with
Cannot open .../feature_bounds.csv and the whole run fails.
-
The two thresholds. Search (domain, degree) for a point satisfying both
reject_rate ≤ R_max (default 1%) and polynomial_accuracy ≥ A_min
(the goal floor), within the depth budget. Widening the domain trades accuracy
for fewer rejects, so this is a joint search, not a one-way "widen until it
passes."
-
Packing-aware enforcement (set by packing_mode from Stage 1):
- per_record: pre-flight box filter plus a per-record
try/catch around
decrypt; measure the residual fallback rate; pass if reject + fallback ≤ R_max.
- batched: the pre-flight filter must be airtight (zero overflowing
records admitted — one bad slot fails the whole batch
Decode). The single
encryptor filters its own plaintext before packing. Post-flight is binary
(the batch decrypts or it doesn't), so there is no proportional safety net.
If the airtight filter's reject rate exceeds R_max, take ladder rung b.
-
The composition guard (do not skip). The reject set is not random — for
some workloads out-of-domain inputs correlate with the positive class, so a 1%
reject rate can drop a large share of the cases you care about. Measure the
target-class rate within the reject set against the base rate. If it is
enriched beyond a configurable factor, filtering is disallowed — route to
rung b/c instead. Always report the reject rate together with its
target-class composition; never surface "1% rejected" without it.
For detailed guidance: Read references/building-your-first-fhe-application.md
and references/fhe-application-dialogue.md (a worked example showing all
these steps applied to a real anomaly detection application).
Stage 4: Select the FHE Scheme
Choose among the three arithmetic FHE schemes supported by OpenFHE:
-
CKKS — Approximate arithmetic on real/complex numbers. Best for ML
inference, signal processing, analytics on real-valued data. Highest
throughput for bulk numerical computation. Packs N/2 SIMD slots per
ciphertext.
-
BFV — Exact modular integer arithmetic. Best for workloads requiring
precise integer results: financial calculations, identity verification,
encrypted database operations. Packs N SIMD slots per ciphertext.
-
BGV — Also exact integer arithmetic, similar to BFV. Largely superseded
by BFV for new development. Choose only if you have a specific reason.
The core decision: if your computation works on real-valued data and can
tolerate small approximation errors, use CKKS. If you need exact integer
results, use BFV.
For detailed guidance: Read references/fhe-scheme-selection.md
Stage 5: Design the Homomorphic Circuit
This is the core of the FHE application design — translating the plaintext
algorithm into an arithmetic circuit that operates on encrypted data. Key
decisions at this stage:
Choose the boundary representation first. Before packing or authoring the
core, fix the representation of the data that crosses the trust boundary — it
is where most of the design cleverness lives, and it sets packing and depth for
everything downstream. Often a client-side cleartext transform (Stage 1's
client_clear pre-stage) can reshape an FHE-hostile problem into an FHE-friendly
one: a phonetic hash turns fuzzy name matching into an exact match over short
codes (set-membership); L2-normalizing embeddings turns cosine similarity into a
plain inner product (fetch-by-similarity); folding public model weights into a
plaintext operand turns scoring into a depth-1 ciphertext×plaintext multiply.
Pick that encoding first and write both halves against it — don't pack, then
discover the representation was wrong.
SIMD data layout. How to pack data into ciphertext slots to maximize
parallelism. The packing strategy must be consistent with the privacy model
from Stage 1 — specifically, who encrypts the data determines what can
share a ciphertext:
- Single-encryptor batching (column-major): when one party holds all the
data (e.g., a client encrypting its own dataset for server processing),
pack one ciphertext per feature/field with each slot holding that feature
for a different record. This enables massive SIMD parallelism. All three
worked examples (set membership, fetch-by-similarity, NID) use this
pattern because a single party legitimately holds all records.
- Independent-encryptor (per-record): when each data owner encrypts
independently (e.g., individual customers submitting to a bank), each
owner produces their own ciphertexts. SIMD slots within a single
ciphertext may still be used (e.g., packing multiple features of one
record into that record's ciphertext), but most slots will be unused
— e.g., 15 features out of 32,768 slots is 0.05% utilization. This is
a real cost of the independent-encryptor model. The server can still
achieve throughput via task-level concurrency (processing multiple
customers' requests on separate threads), but each individual FHE
computation underutilizes the SIMD capacity. You cannot pack different
owners' data into the same
ciphertext without violating the privacy model — one owner's key would
decrypt another owner's data.
- Multi-batch handling: when the dataset exceeds the slot count (typically
N/2 = 32,768 for CKKS at ring dimension 2^16), split into batches and
aggregate results across batches.
Check: revisit Stage 1. If multiple independent parties each encrypt
their own data, do not use column-major packing across those parties.
Column-major packing is for single-encryptor scenarios. Misapplying it
creates a design where one party's key can decrypt another party's data.
Where the out-of-domain filter lives (set by packing_mode). If Stage 3's
feasibility check chose to filter out-of-domain records (rather than change
the model), the packing mode dictates where and how:
- per_record packing — each query is its own ciphertext, so filtering is
proportional and has two layers: a pre-flight input-bounds check in the
client's encrypt step (reject before encrypting), plus a per-record
try/catch around decrypt that catches anything the proxy box let through
(mark it a fallback). Both are counted toward the reject budget.
- batched / column-major packing — the whole batch decrypts in a single
Decode, so one out-of-domain slot throws and fails every record. There is
no per-record try/catch to fall back on. The filter must therefore be
pre-flight and airtight: the single encryptor screens its own plaintext
against the bounds box and drops offenders before packing, so the batch it
encrypts is guaranteed in-domain. Post-flight is binary. If too many records
would be dropped to stay airtight, do not pack — fall back to per-record
(Stage 3 ladder rung b).
In both cases the bounds box is the design-emitted feature_bounds.csv
(input ranges only); the client enforces it. Report the reject and
fallback rates as protocol outputs (Stage 9).
Multi-batch protocol (dataset > slot count). When records exceed the slots
per ciphertext (N/2 = 32,768 for CKKS at 2^16), the extension is protocol,
not circuit: the identical circuit runs over ⌈records / slots⌉ slot-batches.
Codify the loop rather than improvising it:
- Keys and context ship once; only ciphertext batches repeat. Per-batch
metadata (record count, batch index) travels with each batch, and the client
aggregates results client-side.
- The server may process batches task-parallel; the binding constraint is
memory (peak ≈ one batch's working set of live ciphertexts), not depth or
keys. Per-record amortized cost is unchanged.
- Failure isolation gains a middle granularity. A decode failure now costs
one slot-batch, not the whole dataset — between
batched and per_record
on the isolation spectrum. This softens the airtight-filter requirement in
proportion to the batch count, and gives a natural bisection unit when
isolating an out-of-domain record.
- run_test and the two-process demo should accept a batch count so the
multi-batch path is exercised before deployment. For a worked streaming
example, see
references/example-network-intrusion-detection.md.
Patterns for NN inference under column-major packing. When the workload is
neural-network inference with feature-major (column-major) packing, four
structural moves cost little or nothing and repeatedly pay off:
- Per-unit approximation domains are free. Each hidden unit is its own
ciphertext, so every unit can carry its own polynomial coefficients and
domain at the same degree — zero extra depth. A pooled (per-layer) domain
must span the widest unit; per-unit domains shrink most units' intervals
dramatically and can be the difference between a failing and an exact
polynomial circuit. Fit per unit, not per layer.
- Fold dead units away. Regularized models often contain units whose
pre-activation is (nearly) constant; fold their activation output into the
next layer's bias exactly. Besides shrinking the circuit, this prevents
degenerate zero-width domains from poisoning per-unit fitting (symptom:
median per-unit envelope width ≈ 0).
- Emit a margin, not logits. For a two-class argmax head, compute the single
logit difference in-circuit and let the client decide by sign — one output
ciphertext instead of two, and the decision rule matches the reference by
construction (see Stage 7 on decision rules).
- Expect rotation-free circuits. Column-major linear layers are pure
cipher×plaintext multiply-accumulate across ciphertexts — no rotations,
hence no rotation keys (normally the dominant key cost). Rotation keys enter
only if slot aggregation (rotate-and-sum) is used.
- Range-grouped domains when units share a ciphertext. Recurrent and packed
layouts often put all hidden units in ONE ciphertext (slot = unit×record),
where per-unit polynomials aren't free. The middle ground: group units by
operand envelope, mask per group (one level), and evaluate one polynomial
per group over its own tight domain. Four groups turned an LSTM cell-state
tanh from a failing pooled domain (11-cycle error) into a passing one
(0.77) at the same total depth. The group masks can skip the post-mask when
the interpolant is odd (masked-out slots evaluate to ~0).
- Emit normalized outputs; scale in cleartext at decrypt. Any output scale
(×125 to physical units, ×class-count, …) folds out of the circuit for
free, shrinking the decode budget by its magnitude — and enabling a final
refresh in bootstrapped designs (see Stage 6).
Comparison strategies in CKKS. Since CKKS operates on approximate reals,
exact comparison is not directly possible. Common approaches include:
- Squared Euclidean distance + iterated squaring: compute the distance
between encoded vectors, normalize, then amplify the match/non-match gap
through repeated squaring. Low depth, good for set membership and matching.
- Chebyshev polynomial approximation: approximate indicator functions
(sigmoid for thresholding, Gaussian impulse for equality) using Chebyshev
series. Higher depth but more flexible, good for comparison against
continuous thresholds.
Depth budget. Sum up the multiplicative depth of every operation in the
circuit. This number directly determines the CKKS/BFV/BGV parameters you'll
need. Strategies to reduce depth: tree-structured reductions, deferred
relinearization (EvalMultNoRelin followed by batch Relinearize), and
choosing lower-degree polynomial approximations where precision permits.
Slot aggregation. To reduce a SIMD vector to a scalar (e.g., summing
all match indicators), use rotate-and-sum: repeatedly rotate by powers of 2
and add, collapsing N slots into one in log(N) steps. This consumes no
multiplicative depth (rotations and additions are free in depth), but requires
rotation keys for each power-of-2 shift.
Transciphering for output integrity. If Stage 1 Question 5 identified an
output integrity problem (the decryptor might misrepresent the result to the
party who acts on it), add a transciphering layer to produce a dual output.
The computing server knows a symmetric key K. As the last operations in the
FHE circuit, produce two encrypted values: (a) the result itself, and (b) a
copy of the result encrypted under K using an FHE-friendly symmetric cipher.
Both are FHE-encrypted. After the decryptor strips the FHE layer, they obtain
the plaintext result (which they can read — it's their data) plus an opaque
symmetric ciphertext of the same result (which they cannot read without K).
The decryptor returns the symmetric ciphertext to the consumer (whoever holds
K), who decrypts it and obtains the verified result. This gives the decryptor
transparency about their own outcome while giving the consumer assured
integrity — the consumer knows the result came from the genuine computation,
not from the decryptor's claim.
FHE-friendly symmetric ciphers are designed to minimize multiplicative depth:
- Rubato — stream cipher, ~4–6 levels of depth, designed for CKKS
- HERA — block cipher, ~4–5 levels, supports both CKKS and BFV
- Pasta — ~5 levels, designed for hybrid HE frameworks
- Elisabeth — ~6 levels, designed for TFHE but adaptable
Budget the transciphering depth into the total circuit depth from the start.
For example, if the core computation needs 12 levels and Rubato adds 5, the
total depth budget is 17 — which may push the ring dimension from 2^15 to
2^16. Plan for this in Stage 6.
For worked examples: Read references/example-set-membership.md for a
complete CKKS application using squared distance + iterated squaring with
column-major packing. Read references/example-fetch-by-similarity.md for
a more complex application using Chebyshev approximation, slot replication,
running sums, and output compression. Read
references/example-network-intrusion-detection.md for ML inference under
encryption: an autoencoder ensemble with Chebyshev-approximated activation
functions, feature-major packing, and a streaming batch protocol.
Stage 6: Select Parameters
Parameter selection is tightly coupled to the circuit design from Stage 5.
The key parameters for CKKS (and analogous choices for BFV/BGV):
-
Ring dimension (N). Determines the number of SIMD slots (N/2 for CKKS,
N for BFV/BGV) and the achievable security level for a given modulus size.
The backend floor is currently 2^16 (65,536); the hardware does not yet
support 2^15. Smaller N is attractive — fewer slots but smaller ciphertexts
and faster ops — and may become supported, so treat this as a deployment
constraint, not a permanent rule. Default to N = 2^16. Only author 2^15 if
the user explicitly approves it for performance; if they don't approve, use
2^16 as the floor. Consent does not waive security: N must still be large
enough that the total modulus logQ is secure at the target level — compute
logQ ≈ first_mod + depth × scaling_mod (plus the hybrid key-switching special
modulus, which adds materially) and confirm the chosen N is secure for it. For
the ML workloads here — depth ~12, scaling ~45, first_mod ~55 → logQ ≈ 600 —
128-bit security requires N = 2^16 regardless. Set the ring dimension
explicitly — in OpenFHE, CCParams<CryptoContextCKKSRNS>::SetRingDim(65536)
— rather than relying on it being inferred from the batch/slot count. An
under-set ring silently drops below the floor and fails key generation. (If you
take the optional DSL path, the equivalent is a literal ring_dim in the
scheme block; don't carry it only on the Instance struct, or codegen falls
back to n_slots = N/2.)
-
Multiplicative depth. Set to match your circuit's depth budget from
Stage 5. This is the most important parameter — it drives the modulus chain
size, which in turn constrains the ring dimension needed for security.
-
Scaling modulus size (CKKS). Bits per level in the RNS modulus chain.
Typical values are 40–50 bits. Determines the precision available at each
level. Larger values give more precision but require a larger overall
modulus, which may force a larger ring dimension for security.
-
First modulus size (CKKS). The first (largest) modulus in the chain.
Typically 50–60 bits. Provides headroom for the initial encryption.
-
Security level. Target HEStd_128_classic (128-bit classical security)
unless you have a specific reason for a different level. Do not assume the
backend auto-promotes the ring to meet security — it does not; a ring too
small for the declared security + logQ is a hard key-generation error
("ring dimension N does not comply with HE standards"), which wastes a whole
build. Size N correctly up front (see Ring dimension above): for these ML
workloads that means N = 2^16.
-
Scaling technique. Use FLEXIBLEAUTO in OpenFHE for automatic rescaling
in CKKS. This inserts rescale operations as needed to keep ciphertext
levels aligned, reducing the chance of subtle level-mismatch bugs.
-
Rotation keys. Generate keys for every rotation amount your circuit
uses (powers of 2 for rotate-and-sum, specific offsets for data
rearrangement). Missing rotation keys cause runtime errors.
Decode safety is a modulus-chain budget, not operand magnitude. A CKKS
circuit fails to decode ("approximation error too high") when the live modulus
left after the circuit consumes its multiplicative levels no longer exceeds
the scaled message plus a noise margin — not because intermediate values are
"large." Two failure modes look identical at the Decode call but need
opposite fixes:
-
Per-operation precision erosion — operands are well inside the activation
domain, but the per-level precision (scaling modulus) is too coarse, so noise
overruns the scale. Fix: raise the scaling modulus q_i (more bits per
level). Depth does not help — more levels don't lift the per-operation noise
floor (the long-standing "don't add depth for precision" rule).
-
Noise-budget exhaustion — the cleartext twin is accurate (operands
in-domain, the polynomial is a good fit) yet the encrypted run still overflows
Decode. The modulus chain ran short, not the precision. Fix: raise the
multiplicative depth — more RNS limbs means a larger total modulus and more
budget — before coarsening the polynomial by dropping the approximation
degree. Degree drop is the last resort: it sheds the very accuracy you are
trying to keep.
Calibration anchors (all on the fraud MLP, N = 2^16, first_mod 60):
a degree-7 sigmoid circuit (naive count: 11 levels used) overflows at
depth 12 / scaling 45 but decodes cleanly at depth 15 / scaling 59; a
degree-27 circuit (naive count: 15 levels used, operands ≤ ~1) decodes
cleanly at depth 16 / scaling 45 on a 5,000-record suite with measured
output noise ≈ 5e-5 — but the same circuit at the same parameters
overflows Decode when the slot population changes: a fidelity suite of
19,302 records dominated by large-margin rows (9,651 positives vs the 5k
suite's 26) crossed the decode threshold at depth 16 and needed depth 17.
Read together: the discriminator is the effective spare-level margin, not
the degree or the operand size — and that margin is data-composition-
dependent, because CKKS multiplication noise is message-dependent (≈ m·e
terms) and the decode check estimates error across all populated slots. A
circuit validated on a thin or class-imbalanced sample has not been
validated for a deployment where many slots carry large values. The
depth-12 failure at a nominal one-level spare is the cautionary detail —
naive per-op level counts can undercount actual consumption by a level or
more under FLEXIBLEAUTO (encoding and rescale overheads), turning a nominal
spare of one into an effective spare of zero. When the naive count leaves
exactly one spare level, treat it as possibly zero and budget one more; for
production parameters, verify decode on the worst-case slot population
(e.g. an all-positive batch for a rare-class detector), not only on a
base-rate sample. The decode-safety inequality above should be evaluated
with that pessimism applied.
Predict decode failure from the cleartext side — don't spend an encrypted run
to discover it. The float reference twin can't see the CKKS scale, but you can
model the budget it implies. Estimate the levels the circuit consumes — roughly
one Chebyshev eval per hidden activation at ceil(log2(degree+1)) + 1 levels
each, plus one rescale per linear (cipher × plaintext) layer under FLEXIBLEAUTO,
plus a small margin — then require
first_mod + (depth − levels) × scaling > scaling + log2(worst_operand) + noise_margin
(a ~10-bit noise margin is a safe default). If the left side is underwater, the
circuit will overflow Decode; raise depth (or scaling) before running
encryption. This turns the two-speed loop's fast cleartext pass into a decode
gate, so the expensive encrypted run is reserved for designs already predicted
safe. Erring conservative is cheap: a falsely-"unsafe" verdict only adds depth,
which is the correct move anyway.
The no-bootstrap depth ceiling (and the escalation ladder above it). The
modulus a given ring can carry at the target security level caps the depth you
can buy: max depth ≈ (logQP_ceiling − first_mod − key-switching overhead) /
scaling. At N = 2^16 and 128-bit classical, logQP ≲ ~1780, so with scaling 45
the ceiling is roughly depth 25–30. Treat it as a hard feasibility bound
(like the ring-dimension floor) and surface it in Stage 2's depth test. When a
circuit exceeds it, escalate in order:
- a. Reduce depth with the Stage 5 tools (tree reductions, lower degrees,
folding, boundary-representation changes). This is the only rung the agent
may take on its own.
- b. Client-assisted recryption — split the circuit at a level boundary;
the server returns the intermediate ciphertext, the client decrypts and
re-encrypts fresh, and the server continues. Cheap (no extra crypto
machinery) and privacy-clean in the standard model — intermediates are
revealed to the client only, who owns the data. But it changes the
protocol shape: it breaks the one-pass rule (Stage 3) and adds round trips
the user will not be expecting. Requires the user's advance approval —
never adopt round trips silently. Document in Stage 9 what each
intermediate reveals to the client (e.g., hidden activations partially
expose a server-held model).
- c. In-circuit bootstrapping — supported in OpenFHE (EvalBootstrap) but
costs ~10–15 levels of overhead plus large constant factors, and is not
supported on the target hardware backend: designing for it commits the
deployment to a software-only path. Only with the user's explicit acceptance
of that trade.
The choice between rungs b and c belongs to the user, made ahead of time
with the trade-offs in front of them (round trips + intermediate exposure vs.
software-only performance) — present both and ask; do not pick silently.
Designing with bootstrapping (rung c in practice)
Everything in this section was learned the hard way on a real deep-recurrent
workload (a 30-step LSTM, 92 refreshes per batch). The core discovery:
the faithful twin validates the circuit, but not the crypto runtime.
Magnitude limits, refresh accuracy, and level mechanics live outside the
twin's model, and each one needs its own cheap empirical probe before the
expensive encrypted build. In order:
-
Run the bootstrap lab FIRST — before designing anything. The FHE-dev
image ships fhe-boot-lab <scaling> <first> <depth> [slots] [dnum] [budget] [sparse] [iters] [corrFactor]: it measures REAL bootstrap error
at candidate parameters in minutes. Two traps make naive validation
worthless:
- EvalBootstrap is a silent no-op whenever the input has more levels
remaining than a refresh would return — a fresh ciphertext is never
really bootstrapped. Any test that doesn't first deplete the input
validates nothing (this invalidated an entire campaign's worth of
"passing" checks, including a dedicated precision benchmark). The lab
burns inputs down with plaintext mults and tags every refresh
[real] / [NO-OP!].
- Bootstrap accuracy is a measured property, not a derived one.
Parameters that pass keygen and run full circuits can still produce
garbage refreshes. Measured law for UNIFORM_TERNARY at N = 2^16:
error ≈ 2^(47 − scaling) — decode-fatal at scaling 38, ~2^2 at 45,
2^-3 at 50. SPARSE_TERNARY is the difference-maker: at scaling 50
it measures 2^-11 per refresh, 2^-10 across a chain, clean even from
depth−2. (Runner-up: 2-iteration uniform bootstrap ≈ 2^-6, at +1 level
and 2× time.)
-
Derive the noise requirement from the twin, then match the lab to it.
Sweep per-refresh noise in the twin (count every refresh site × steps)
and find the coarsest level that still meets the goal floor — that is the
acceptance bar the lab measurement must clear, with margin. (The LSTM
passed at 2^-7 and failed at 2^-6; sparse@50's 2^-11 gave 16× margin.)
-
Every ciphertext entering EvalBootstrap needs magnitude ≲ 1 — the
internal sine approximation assumes small messages, and violations corrupt
SILENTLY and value-dependently (a cell state that grows past ~1 around
step 3 poisons everything after, and only the final decode complains).
Check the twin's measured envelopes at every refresh site and fold scales
into adjacent plaintext operands to comply — it is depth-free (into
Chebyshev output coefficients, into the next layer's weights; positively
homogeneous functions like ReLU tolerate exact scale-commuting).
-
Budget mechanics, all empirically confirmed:
GetBootstrapDepth is a reserve, not consumption; the real output
level is what counts (observed: reserve 20, actual 15 at {3,3}).
Budget with the measured number and print both.
- first − scaling must be ≤ the correction factor (~7) or
EvalBootstrap throws; use first = scaling + 1 like every OpenFHE
example. The "first_mod 60 for headroom" habit is incompatible with
bootstrapping.
- Level budget {2,2} minimizes depth but explodes rotation-key COUNT
(a 24 GB OOM in practice); {3,3} is the memory-sane default.
- The special modulus P rounds UP to whole ~60-bit primes:
logQP ≈ logQ + 60·ceil((first + (ceil(towers/dnum)−1)·scaling)/60).
Use this pessimistic form against the N = 2^16 cap (~1772) — the naive
logQ·(1+1/dnum) underestimates and cost three keygen rejections.
- Split-step segmentation is the depth lever: divide one logical step
into k bootstrap segments to shrink the usable-depth requirement, which
shrinks logQ, dnum pressure, and key sizes all at once (a 16-level step
became two ~8-level segments; more, cheaper refreshes beat fewer,
costlier levels).
- Refreshes self-regulate via the no-op behavior: place them at
segment boundaries and they fire only when depth demands — but only
after the lab has proven near-bottom refreshes clean at your config.
-
Emit normalized outputs (≤ 1) and bootstrap the result as the last
server op. The client applies any output scale in cleartext. This makes
the final decode unconditionally safe at a fresh level and removes output
magnitude from the decode budget entirely.
Size estimation. After choosing parameters, estimate the physical sizes
of ciphertexts and keys. These determine client memory requirements and
network bandwidth — designers are often surprised by how large FHE objects
are. Use these formulas (assuming 64-bit RNS limbs):
- Ciphertext at level L: 2 × N × (L + 1) × 8 bytes. A ciphertext is two
polynomials, each with N coefficients represented in RNS with (L + 1)
limbs. Example: N = 65536, L = 12 → 2 × 65536 × 13 × 8 ≈ 13.6 MB.
- Public key: same size as one ciphertext at the maximum level.
- Relinearization key: roughly dnum × 2 × N × (L + 1) × 8 bytes, where
dnum is the number of key-switching digits (typically 2–3 in OpenFHE).
- Each rotation key: same size as a relinearization key. If the circuit
needs K distinct rotation amounts, the total rotation key material is
K × (size of one rotation key). This often dominates total key size.
- Secret key: N × 8 bytes (small, held only by the decryptor).
Minimize ciphertext level. Ciphertext size scales linearly with (L + 1).
Set multiplicative depth to the exact circuit depth needed — do not
over-provision "for safety." If the circuit requires 8 levels, setting depth
to 20 makes every ciphertext 2.4x larger than necessary. The depth budget
from Stage 5 should be tight.
Estimate total transfer sizes. For each direction of communication,
compute the total bytes:
- Client → Server: (number of input ciphertexts) × (ciphertext size at
level L) + public key + relinearization key + rotation keys. In many
protocols, keys are sent once during setup and reused across sessions.
- Server → Client: (number of output ciphertexts) × (ciphertext size at
output level). Output ciphertexts are smaller than inputs because the
computation consumes levels — an input at level 12 that uses all 12
levels produces output at level 0, which is 13x smaller.
Present these estimates to the user so they can assess whether the
deployment is practical for their network and memory constraints.
The security-vs-accuracy sweep. For ML workloads, treat parameter
selection as an empirical optimization over the test set from Stage 3: sweep
(approximation degree, q_i, depth, N) and measure end-to-end model accuracy at
each point against the plaintext reference. The interplay is mechanical —
higher approximation degree buys accuracy but costs depth; depth and q_i set
log2(Q) = first_mod + depth x q_i; log2(Q) and the security target set the
minimum ring dimension N; N sets performance. Run this sweep against the
faithful twin (Stage 7), not the encrypted build: the twin measures each
sweep point's accuracy in seconds without re-running encryption. (If you take
the optional DSL path, its compiler also surfaces this frontier at compile time —
Chebyshev depth charging and a params note mapping logQ to the minimum N per
security level.)
When the sweep cannot win, walk the Stage 3 ladder — ending in a model
change. If the whole in-budget sweep is exhausted and no point meets the
accuracy floor — typically because an activation's operand range is too wide for
any affordable degree (the range-too-wide-for-budget mode from the Stage 3
feasibility check) — do not report only failure. Apply the Stage 3 escalation
ladder, cheapest first: (a) if a tight, accurate domain plus an out-of-domain
filter stays within R_max and the reject set is not enriched for the
target class, ship that; (b) if a batched design can't filter airtightly
within budget, recommend dropping batching to per-record; (c) otherwise emit a
consolidated recommendation to change the model — bound the pre-activations
via normalization (BatchNorm/LayerNorm) and retrain, or apply equivalent
weight/input scaling, so a low-degree approximation over a narrow domain becomes
both safe and accurate. Back whatever you recommend with the sweep evidence (the
frontier explored, the best accuracy reached, the reject rate and its
target-class composition, and the floor missed), and name the limiting factor —
model dynamic range, not the FHE parameters. This is the end-of-run sibling of
the Stage 3 early gate: the gate catches it before iterating; this catches it if
iteration confirms no parameter choice suffices.
For reference on parameter choices in practice: The example applications in
the references directory show concrete parameter selections with rationale.
Stage 7: Build and Validate the Faithful Twin
By this point Stages 4–6 have fixed the scheme, the circuit, and the parameters,
so the twin can finally be completed. The faithful twin is a plaintext
(e.g. numpy) model of the exact circuit — the same Chebyshev polynomials, the
same fixed-point quantization step (Δ = 2^scaling), the same packing and
rescale points the FHE program will run. It is not the reference (that is the
ideal algorithm); the twin predicts encrypted behavior modulo random noise.
This is the same twin you were already sweeping in Stages 5–6 — there it was the
search proxy that let you choose the parameters cheaply; here you freeze
it at the chosen point, complete it, and validate it. Two properties are why
the twin carries this much weight:
- It is the executable spec. Every parameter the design turns — depth,
scaling modulus, Chebyshev degree/domain, packing — is a knob in the twin. A
decode-safe, accurate twin is the design; the FHE build (Stage 8) is a
mechanical downstream step that should reproduce it modulo noise.
- It makes iteration cheap. A twin sweep runs in seconds; a full encrypted
build+run is minutes to hours. That asymmetry is the whole point of validating
here, before the expensive build — catch a bad parameter choice against the
twin, not against a multi-hour encrypted run.
Build and validate it:
- Complete the twin at the frozen parameters and confirm it is
decode-safe — the modulus-chain budget from Stage 6 must hold, or the
encrypted run will overflow no matter how good the plaintext accuracy looks.
- Run it on the representative test data and compare to the reference.
Report the delta the way Stage 3 requires: the end-to-end metric(s) against
the goal floor, the max absolute output error, and — where the workload
filters — the reject rate with its target-class composition.
- Match the reference's decision rule exactly. A subtle but common defect: a
binary classifier with a single-logit head decides by threshold
(
logit > 0), not argmax over one output. The twin (and later the decrypt
step) must use the reference's actual rule, or fidelity will look broken when
the circuit is fine.
- Present the comparison and gate on it. The twin-vs-reference result is the
decision point between design and the expensive encrypted build:
- Interactive runs: pause and get explicit user approval before
proceeding to Stage 8. Show the metrics, the deltas, and any rejects.
- Autonomous runs: if the twin clears the goal floor, proceed and record
the twin-vs-reference result as a logged assumption; if it misses the
floor, stop and report rather than build an encrypted version of a design
that already fails in plaintext.
Also derive and record the application-level noise tolerance from the twin:
for classifiers, the minimum decision margin over the test set (optionally
confirmed by injecting synthetic per-level noise into the twin and showing zero
decision flips at noise far coarser than CKKS delivers); for continuous
outputs, the maximum output error the application can absorb. For bootstrapped
designs, sweep per-refresh noise at every refresh site — the coarsest
passing level is the accuracy bar the bootstrap lab must clear (Stage 6). This
turns Stage 8's run_test comparison into an objective pass criterion —
measured encrypted error below the recorded tolerance — instead of an
arbitrary error threshold.
For packed/rotational designs, add a slot-level simulation gate. Export
every plaintext vector the server will multiply by (BSGS diagonals, masks,
bias vectors) from the design side, then replay the exact rotation/multiply/
add program in numpy (np.roll for EvalRotate) and require it to reproduce the
twin bit-exactly before any C++ is written. This moves all indexing cleverness
into testable Python; the C++ server becomes a mechanical transcription of a
verified program. In practice the encrypted intermediates then match this
simulation to four decimal places — when they don't, the divergence is crypto
runtime, not circuit (see the bootstrapping section's probes).
A validated, decode-safe twin is the primary design deliverable. Stage 8 turns it
into encrypted code.
Write the design dossier now — at the gate, before the encrypted build
Because the validated twin is the design, most of the Stage 9 close-out
documentation should be written here, at the Stage 7 gate, not deferred until
after the expensive encrypted build. Everything design-derived is already known;
only the measured encryption results are not. Author now:
- The protocol / threat-model document — in full. Parties, message flow,
what each party can and cannot learn, intentional and incidental leakage, the
output-integrity mechanism, and filter coverage (reject/fallback rates are the
twin's) are all fixed by Stages 1, 5, and 6. None of it depends on the
encrypted run.
- The design narrative — in full but for a short addendum. Privacy model,
feasibility pivot, scheme/circuit/parameter decisions, the dead ends explored,
and the twin-gate verdict are all settled. Leave one open slot for an
"implementation notes" addendum in case Stage 8 forces a change (a depth bump
for decode headroom, a twin-fidelity fix, and so on).
- The results report — skeleton plus the two plaintext ledger rows. Write
reference → ground truth (task quality) and twin → reference (polynomial
cost) now — both are plaintext facts. Fill the parameter table and the Stage 6
size estimates. Draft the "How we know it passes" narrative except the
encrypted-layer numbers. Leave the FHE → twin row, the measured resource
profile, and the final PASS as clearly-marked blanks.
- The run README — commands and structure, minus the measured
"expected output" block.
Only three things are genuinely unknown until Stage 8's run_test: the measured
FHE-vs-twin error (against the noise tolerance recorded just above), the measured
deployment numbers (peak server RSS, timings, actual boundary sizes vs. the
estimates), and therefore the final PASS. Stage 9 then becomes filling those
blanks and confirming the pre-written docs, not writing from scratch.
Stage 8: Implement the FHE Program
Only after the twin is validated and approved. Implement in OpenFHE C++ —
the single first-class path here: the models this skill runs on are
OpenFHE-native (far more public OpenFHE than DSL code to learn from), so it is
the most reliable target.
Build in the current working directory by default. Generate the application
in your current working directory unless the user explicitly directs otherwise.
Do not create files inside the niobium-client repository (e.g., under
dsl_fhe/examples/) or modify its Makefile/build files unless the user
explicitly asks to do that.
Optional DSL path. Niobium also ships an nb FHE DSL (in
niobium-client's dsl_fhe/) that
generates the whole pipeline — the four-program split, serialization, CMake, key
generation, record/replay — from ~3 short .niob files, with compiler-enforced
@client/@server trust boundaries. It is CKKS-only and has far fewer public
examples than OpenFHE, so treat it as an optional convenience, not the default.
If you use it, read references/implementing-with-nb-dsl.md for the
design-output → DSL mapping, workflow, and limitations. The rest of this stage
describes the OpenFHE implementation the DSL would otherwise generate.
The four-program architecture
Build the FHE version using OpenFHE as four separate executable programs.
This structure enforces the trust boundaries from the protocol — each program
runs in a distinct security context, communicates via serialized files, and
can be deployed independently:
-
keygen — Generates the crypto context, key pair, relinearization keys,
and rotation keys. Serializes the public key, evaluation keys (relin +
rotation), and secret key to separate files. The secret key file stays
on the client; the public and evaluation keys are sent to the server
(typically once, during setup). This program also serializes the crypto
context parameters so all other programs can reconstruct the same context.
-
encrypt — Reads plaintext input data and the public key. Encodes the
data into plaintexts (with appropriate SIMD packing from Stage 5),
encrypts each plaintext, and serializes the resulting ciphertexts. These
are the files sent from client to server for each request.
-
server — Reads the crypto context, public key, evaluation keys, and
input ciphertexts. Executes the homomorphic circuit from Stage 5.
Serializes the output ciphertexts. This program never sees the secret
key. If transciphering is used (Stage 5), the server also holds the
symmetric key and applies the transciphering layer before serializing
output.
-
decrypt — Reads the secret key and output ciphertexts. Decrypts and
decodes the results, applying the reference's exact decision rule
(Stage 7). Validation against the twin belongs to run_test (item 5 below),
not to decrypt — keep the production binary free of test-only inputs.
Use CMake with find_package(OpenFHE) for all four programs. Use this exact
include/link block — OpenFHE splits its headers across core/, pke/, and
binfhe/, and pke transitively includes binfhecontext.h from binfhe/, so
omitting any of them fails with fatal error: binfhecontext.h: No such file or directory:
find_package(OpenFHE REQUIRED)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${OpenFHE_CXX_FLAGS}")
add_executable(server server.cpp) # likewise keygen / encrypt / decrypt
target_include_directories(server PRIVATE
${OpenFHE_INCLUDE}
${OpenFHE_INCLUDE}/third-party/include
${OpenFHE_INCLUDE}/core
${OpenFHE_INCLUDE}/pke
${OpenFHE_INCLUDE}/binfhe)
target_link_libraries(server PRIVATE ${OpenFHE_SHARED_LIBRARIES})
Link via ${OpenFHE_SHARED_LIBRARIES} rather than naming targets by hand
(robust across OpenFHE releases). Enable PKE, KEYSWITCH, and LEVELEDSHE
features on the crypto context. Enable ADVANCEDSHE if using Chebyshev
evaluation or advanced rotation patterns. Use OpenFHE's serialization API
(Serial::SerializeToFile / Serial::DeserializeFromFile) for all inter-program
data exchange.
Why four programs: This structure makes the trust boundaries from Stage 1
concrete in the code. Each boundary between programs is a serialization point
where you can measure exactly what crosses the wire — validating the size
estimates from Stage 6. It also prevents accidental leakage of the secret key
into the server binary, which is easy to do in a monolithic implementation.
Also produce a fifth program:
- run_test — A local test runner that orchestrates the full pipeline for
development. It invokes keygen → encrypt → server → decrypt in sequence,
passing serialized files between them, then automatically compares
decrypted outputs against the faithful twin's outputs (the executable
spec — the twin-vs-reference delta is already known from Stage 7, so
comparing to the twin isolates encryption noise; report the two deltas
separately, never conflated). It should report: per-sample error (absolute
and relative) against the noise tolerance recorded in Stage 7 — that
comparison is the pass criterion — mean and max error across the test set,
serialized file sizes at each boundary (keys, input ciphertexts, output
ciphertexts — for comparison against Stage 6 estimates), wall-clock
time for each stage, and the peak resident memory of the server stage
(on Linux, wrap the server invocation and read
resource.getrusage(RUSAGE_CHILDREN).ru_maxrss — no extra packages
needed). Peak server RSS is the number every deployment-sizing
conversation asks for, and Stage 6's estimates cover key/ciphertext
sizes but not the working set with temporaries (bootstrapping keys and
scratch can dominate); measure it, don't infer it. This program is a
development tool, not a production artifact — in deployment, the four
core programs run on separate machines. But during the edit-test-iterate
cycle, run_test makes it fast to validate changes without manually
invoking each stage.