---
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.6.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:

1. **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.
2. **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.
3. **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.

## 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 twin tier (Stages 1–7) needs nothing installed.** The design, the
  parameter sweep, and the twin-vs-reference validation are pure Python (numpy)
  and run in Claude's own environment as you converse.
- **The FHE tier (Stage 8) needs the container.** Building and running the
  encrypted OpenFHE app is too heavy for that sandbox, so it happens in a
  prebuilt **FHE-dev** image on the user's machine. You **pull** this image —
  you never build OpenFHE from source.

Three one-time steps:

1. Install Docker (Docker Desktop on macOS/Windows) if it isn't already present
   — the only unavoidable local install, and far easier than building OpenFHE.
2. 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).
3. 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.

At Stage 8 the container is invoked with the project folder bind-mounted
(`-v "$PWD":/work`), so it compiles the source Claude wrote and writes results
back into the same folder. In Cowork, Claude authors each `docker run` command
and the user runs it; in Claude Code, Claude runs it directly. Either way the
container is a plain build box — Claude, not the container, is doing the design.

**For detailed guidance:** Read `references/environment-setup.md` (prerequisites,
the mounted-folder data bus, the Cowork vs. Claude Code loop, torch references,
and troubleshooting).

## Stage 1: Establish the Privacy Model

Before thinking about circuits, parameters, or code, work with the user to
answer five questions:

1. **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).

2. **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.

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.

4. **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.

5. **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:

1. **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.

2. **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.

3. **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.

4. **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:

1. 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.

2. 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.)

3. 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.

4. 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.

5. 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.

6. 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:

1. **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.
2. **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).
3. **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.
4. **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:

1. **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.)

2. **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.)

3. **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).

4. **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.

5. **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:

1. **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.
2. **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.
3. **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.
4. **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.

## 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](https://github.com/NiobiumInc/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:

1. **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.

2. **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.

3. **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.

4. **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`:**

```cmake
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:

5. **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.

   **Stage into two homes, even locally — do not use a single shared
   directory.** The server refuses to start if a secret key is present in its
   home (see the demo below), so `run_test` must reproduce the deployment
   topology on disk: keygen writes the **secret key only into a client home**;
   `run_test` then provisions a **server home** containing just the context,
   public key, evaluation keys, the model, and the input ciphertexts — never the
   secret key or plaintext — and launches the server there; decrypt runs back in
   the client home. A single shared directory puts the secret key in the
   server's home and trips its own guard. This is the #1 local-run mistake: the
   convenience of one folder silently violates the trust boundary the four
   programs exist to enforce.

**Demonstrate the architecture as separate processes (gating item).** The
four-program split makes the trust boundary concrete in code; a
deployment-shaped demo must make it concrete in process and network topology.
Before considering an application done, stand the client and server up as
separate OS processes that communicate only over HTTP/sockets, exchanging
serialized ciphertext — never keys or plaintext. A single process that plays
both roles only illustrates the protocol; it cannot demonstrate the security
property, because nothing prevents that process from touching the secret key.
Requirements for the demo:

- **Two processes, two homes.** Provision two directories (standing in for two
  hosts): a client home holding the context, public key, secret key, and the
  private inputs; and a server home holding the context, public key, evaluation
  keys (relin/rotation), and the model. The server home contains no secret key
  and no client inputs.
- **The secret key never crosses the wire.** The client generates all keys and
  ships only the context + public + evaluation keys to the server. Only
  ciphertext (and, at setup, public/eval keys) travels between processes.
- **Enforce the boundary, don't just assert it.** The server process should
  refuse to start if a secret-key file is present in its home, and the
  provisioning step should assert that the secret key and private inputs are
  absent from the server home. These checks turn a claim into a guarantee a
  reviewer can test (e.g. copy the secret key into the server home and confirm
  the server aborts).
- **Show the boundary in the transcript.** The server should log only byte
  counts ("received N bytes of ciphertext", "returned M bytes, still
  encrypted"), making it visible that it never sees plaintext.
- **Remote-host ready.** The server home must be safe to copy to an untrusted
  machine as-is; point the client at it via a configurable server URL.

This is inexpensive — a few dozen lines of glue around the four binaries — and
it is the artifact that most convincingly communicates the shape of an FHE
solution to stakeholders. Treat it as part of the deliverable, not an optional
extra.

**Testing and debugging:**

1. **Run and compare.** Use run_test to execute the full pipeline and review
   the error report. Discrepancies against the plaintext reference usually
   mean: noise budget exhausted (depth too great for parameters),
   insufficient precision, or a missed non-linear operation.

2. **Debug.** Debugging encrypted programs is hard because all intermediate
   values are encrypted. Build the instrumentation into the server as
   standard equipment rather than improvising it later: an env-gated debug
   mode (e.g. `FHE_DEBUG_SK=<path>` + `FHE_DEBUG_STEPS=<n>`) that decrypts
   after each named operation inside a try/catch, printing level and
   max-magnitude (valid and junk slots separately) and stopping early. One
   4-minute instrumented run converts "decode failed at the end" into "this
   exact operation, this exact level" — and a DECODE FAIL on an intermediate
   is itself the binary-search signal. Never ship the mode enabled; the
   server's no-secret-key guard stays independent of it.

   **Triage every expensive failure with a minutes-scale probe first.** The
   two-speed discipline applies to debugging too: an all-slots simulation
   sweep (junk-slot magnitudes), the bootstrap lab (refresh accuracy at the
   exact parameters), and the instrumented-decrypt run each cost minutes and
   each can convict or exonerate a whole failure class before the next
   half-hour encrypted run. A campaign of five consecutive encrypted-run
   failures was resolved entirely by such probes — the full pipeline was
   only re-run once, and it passed.

3. **Profile and iterate.** Review the timing and file-size reports from
   run_test. Both memory and runtime will likely be large — gigabytes of
   memory and orders of magnitude slower than plaintext are normal. If
   unacceptable, loop back: reduce multiplicative depth, use smaller
   parameters, pack data more efficiently, or reduce precision.

**For a detailed walkthrough:** Read `references/building-your-first-fhe-application.md`

## Stage 9: Specify the Protocol and Threat Model

As a final design step, document the full protocol and its security properties:

1. The client-server message flow (what is sent, in what order). Document the
   flow as it runs across the two processes — the setup transfer (client →
   server: context, public key, evaluation keys, once) and the per-request
   transfer (client → server: query ciphertext; server → client: result
   ciphertext). State explicitly which files live only on the client host
   (secret key, private inputs) and which live on the server host (model,
   evaluation keys), and note that the server host is provisioned without the
   secret key so it can be placed on untrusted infrastructure.
2. What each party can and cannot learn, and under what assumptions.
3. Intentional information leakage (the protocol output).
4. Incidental leakage (dataset size from batch count, timing, raw scores).
5. Output integrity: if the decryptor differs from the result consumer, how
   does the consumer verify the result is genuine? Document whether
   transciphering is used, what symmetric cipher, and how the symmetric key
   is managed. If the decryptor *is* the consumer (e.g., a user checking
   their own data), output integrity is not a concern.
6. Coverage and the input-bounds filter: if the design rejects out-of-domain
   inputs (Stages 3, 5), document the bounds the client enforces, the reported
   reject and fallback rates, and the target-class composition of the reject set
   — a model that meets its accuracy floor only by dropping queries must report
   how many, and which kind. Note the incidental leakage this adds: rejection is
   observable, so the bounds reveal a coarse decision boundary on the inputs.
7. Threats not addressed (malicious adversaries, side channels, exhaustive
   query attacks, collusion).

### Close-out deliverables

Ship three documents with the application — part of the deliverable, not
optional extras:

1. **A brief narrative report** of what happened: the gate verdicts, the
   decisions taken at each stage (including the user's choices), the dead ends
   explored and why they were abandoned, and what the final design is. This is
   the document a colleague reads to understand *why* the application looks
   the way it does.

2. **A results report** comparing the three versions of the application with
   task-appropriate metrics (see Stage 3 — for rare-class workloads that means
   precision/recall/F1 on the target class, never raw accuracy alone):
   *reference vs. ground truth* (the model's task quality, including any delta
   against a pre-retrain original), *twin vs. reference* (the cost of the
   polynomial approximation), and *FHE vs. twin* (the cost of encryption —
   measured error against the Stage 7 noise tolerance). Attribute every
   quality delta to the stage that caused it; encryption should be at the
   bottom of that list. Include the deployment profile: parameters, the
   boundary sizes and timings measured by run_test, and the measured peak
   server memory (with the Docker/host provisioning requirement it implies).

   **Open the results with a plain-language "How we know it passes" section**
   written for a non-cryptographer. Its skeleton: (a) the answer key — the
   reference's outputs on the test set, produced independently of the FHE
   work; (b) the test — the same inputs encrypted, computed on blind by the
   server, decrypted by the client, and compared side by side against the
   answer key with the concrete numbers and the tolerance they clear;
   (c) why any difference exists at all — polynomial stand-ins for smooth
   functions and a faint hiss of encryption noise, each quantified, each
   budgeted for in advance; (d) why it isn't a lucky run — the same agreement
   at every verification layer (twin, slot-level simulation, encrypted run);
   and (e) the honest caveat separating fidelity-to-the-model from
   quality-of-the-model, so nobody reads "matches the reference" as "predicts
   the world well." This section is what stakeholders actually read; the
   tables below it are the evidence.

3. **A run README** in the application directory: prerequisites (Docker + the
   FHE-dev image), how to regenerate any non-committed inputs, the
   build+run_test command with expected output and resource needs, and how to
   run the two-process demo including the two-host variant. A recipient with
   Docker and the repository should need nothing else to reproduce the
   encrypted run.

This documentation serves both as a security specification and as a guide
for anyone reviewing or extending the application.

**For an example of thorough threat modeling:** Read
`references/example-set-membership.md`, which includes a complete threat model
and security analysis.

## Reference Files

Read these files as needed during the design process. Each file is
self-contained and can be read independently.

| Reference file | When to read it |
|---|---|
| `references/environment-setup.md` | Stage 0: preparing the build-and-run environment (Docker, the FHE-dev image, the smoke test, the mounted-folder data bus) |
| `references/fhe-privacy-model.md` | Stage 1: establishing the privacy model (parties, adversaries, output privacy, differential privacy) |
| `references/fhe-what-fhe-can-and-cannot-do.md` | Stage 2: assessing whether a workload is FHE-feasible |
| `references/fhe-scheme-selection.md` | Stage 4: choosing between CKKS, BFV, and BGV |
| `references/building-your-first-fhe-application.md` | Stages 3, 6, 8: the development checklist from plaintext through implementation |
| `references/implementing-with-nb-dsl.md` | Stage 8 (optional DSL path): implementing the design in the `nb` FHE DSL (niobium-client) — stage-to-construct mapping, workflow, pitfalls, limitations |
| `references/fhe-application-dialogue.md` | Stages 3–8: a worked example showing all steps for a real anomaly detection application |
| `references/example-set-membership.md` | Stages 5–9: complete CKKS design spec and implementation (squared distance, iterated squaring, column-major packing, threat model) |
| `references/example-fetch-by-similarity.md` | Stage 5: advanced CKKS patterns (Chebyshev approximation, slot replication, running sums, output compression) |
| `references/example-network-intrusion-detection.md` | Stages 3–8: ML inference under encryption (autoencoder ensemble, Chebyshev activations, feature-major packing, streaming batches) |
| `references/openfhe-examples-catalog.md` | All stages: catalog of specific OpenFHE examples mapped to design patterns |

## Key Principles

Throughout the design process, keep these principles in mind:

- **Privacy model first.** Never jump to circuit design without establishing
  who holds what, who the adversaries are, and what the output reveals.

- **Privacy dominates feasibility.** When deciding where each stage runs
  (client-clear / server-clear / server-encrypted), a sensitive value may never
  be placed in the clear on the server just because encrypting it is expensive
  or FHE-infeasible — that is a leak, not an optimization. A stage that is
  sensitive, FHE-infeasible, and not derivable from data the client already
  holds is a genuine dead end: block and report it, don't leak. Minimize the
  encrypted core, but never by relaxing privacy. When the repo is presented as
  an FHE problem but the privacy model is ambiguous, assume the standard model
  (the server computes on the client's encrypted input) and surface the
  assumption, rather than concluding no encryption is needed.

- **Plaintext correctness is ground truth.** Get the algorithm working
  correctly in the clear before introducing encryption. Every subsequent
  version is tested against this reference.

- **The faithful twin is the executable spec.** Distinct from the ideal
  plaintext reference: the twin runs the exact circuit (same polynomials,
  quantization, packing) so it predicts encrypted behavior modulo noise. It is
  the primary design artifact and the fast proxy that lets you search parameters
  in seconds and reserve the expensive encrypted build for confirmation.

- **The twin validates the circuit, not the crypto runtime.** Bootstrap
  magnitude limits, refresh accuracy, level mechanics, and library semantics
  (silent no-ops, correction factors) are invisible to the twin — a design
  can match the reference perfectly in plaintext and still produce garbage
  encrypted. Each runtime property needs its own cheap empirical probe
  (`fhe-boot-lab`, the slot-level simulation, instrumented decrypts) before
  and during the encrypted build.

- **Depth is the critical resource.** Multiplicative depth drives parameter
  sizes, which drive memory and performance. Every design decision should
  be evaluated for its depth cost.

- **FHE protects inputs, not outputs.** The output of the computation is
  legitimately visible to the decryptor. If the output can be used to infer
  private inputs, that's a design problem FHE doesn't solve — you need
  additional protections.

- **The model is a design input you can push back on.** A trained model is not
  fixed scripture. When a non-linearity's operand range is too wide for any
  in-budget polynomial approximation to be both bounded and accurate, the right
  move is to recommend changing the model (normalize the pre-activations, e.g.
  with BatchNorm, and retrain) — not to burn the iteration budget on parameters
  that cannot win. Keep the approximation domain matched to the operand range,
  and escalate to a model recommendation when the range itself is the problem.

- **Test against the plaintext reference at every stage.** When results
  diverge, the three most likely causes are: exhausted noise budget,
  insufficient precision, or a missed non-linear operation.

- **Iterate.** The first working encrypted version is a milestone, not the
  finish line. Real-world performance comes from iterating on the balance
  between depth, precision, parallelism, and parameter size.
