Skip to main content

Ciphertext state

Every ciphertext in your pipeline is represented by a HomValue. It carries its layout (tensor_shape, n_axis, n_slots), its precision (pt_scale), and the levels it has left on the modulus chain (active_rows and active_cols).

Homomorphic computations change that state as the ciphertext moves through operators. Print the compiled graph to see shape, scale, and levels on every node; inspect a single HomValue in the debugger; or rely on the compile-time report later. See Inspecting the pipeline.
Ciphertext
Op 1
tensor_shape · n_axis · n_slots
pt_scale
levels
Op 2
tensor_shape · n_axis · n_slots
pt_scale
levels
…
Ciphertext
tensor_shape · n_axis · n_slots
pt_scale
levels

Shape & slot packing​

One axis of your tensor is packed into the ciphertext's slots (what slots are is covered in Slots, rotations & stages). That axis is the n axis, and it is what every reference page means by the term. The HomValue tells you all three parts of the layout: tensor_shape is the shape of your tensor, n_axis says which axis went into slots, and n_slots says how large the packed block is.
Three sizes are easy to mix up, so keep them apart. HomParams.n is the ring degree, always a power of two. HomParams.internal_n is how many slots a ciphertext physically has: n / 2 in CKKS. It is read-only and follows from n. HomValue.n_slots is the block your data is packed into: a power of two, 1 ≤ n_slots ≤ internal_n. Leave it unset and it is internal_n. When it is smaller, the block is repeated internal_n / n_slots times to fill the slots.
A smaller block does not make the ciphertext smaller. The polynomial still has degree n, and you still send the same number of bytes. What it buys you is cheaper work later: HomExpand, bootstrapping, ring switch, and matmul can all work on the small block instead of the full ring.
The n axis is picked for you, by default.

Whichever axis wastes the least space against the number of available slots becomes the n axis automatically.

You can override it.

n_axis, set on the pipeline, forces a specific axis to be the n axis instead. Reach for this when the axis you actually want to matmul or broadcast over isn't the one the default picked.

Two ciphertexts can be used together in a binary operation (e.g. HomAdd, HomMul) only if their n axes match and their shapes broadcast.

Same rule as NumPy-style broadcasting, plus this one addition: mismatched n axes never combine, even if the shapes would otherwise line up.

Dense packing uses every physical slot.

A physical block holds internal_n slots (n / 2 in CKKS). When n_slots equals internal_n, unused room on the n axis is zero-padded at encryption and trimmed off at decrypt. A half-empty block costs the same as a full one, which is why the n axis defaults to the axis that leaves the least room empty.

Sparse packing pads to n_slots, then repeats the block.

When n_slots is smaller than internal_n, the n axis must fit in one block (≤ n_slots), or encoding fails with an error. Encode zero-pads that axis to n_slots, then repeats the block internal_n / n_slots times. Decrypt still returns the tensor you started with; the padding and the extra copies are stripped off. Homomorphic ops see the repeated copies.

Example. CKKS with n = 32 gives internal_n = 16. A length-5 vector packed with n_slots = 8:
external:     [a b c d e]
pad to 8:     [a b c d e 0 0 0]
repeat × 2:   [a b c d e 0 0 0 | a b c d e 0 0 0]
decrypt:      [a b c d e]
Set n_slots when the packed axis fits in one block, a later operator needs that repeated layout, and the value is a power of two no larger than internal_n. For HomExpand, n_slots is the expansion width. For HomRingSwitch, it normally matches the small ring the cleartext sits in (n_slots = 2**(log_n_boot_subring - 1) in CKKS). Leave it unset when no operator needs a smaller block; dense packing is the safe default. How to set it (HomParams.n_slots or pipeline custom_n_slots) is on Choosing parameters → Advanced.
The consequence: shape mismatches are the most common reason a pipeline refuses to compile. It's rarely that one operator is misconfigured; it's that the shape it produces doesn't line up with what the next operator expects, usually because the n axis silently doesn't match between two ciphertexts you're combining.
In the HomOps Reference, every operator's Shape effect field is describing exactly this: how it changes tensor_shape, and whether it touches the n axis.

Scale​

Every ciphertext also carries a scale, starting at pt_scale. Each multiplication multiplies the scales of its inputs, so a value at scale S becomes S² after one multiply. Left alone, scale outgrows the room a ciphertext has to represent it accurately.
At a high level, the rules of thumb are:
Addition leaves scale alone.

HomAdd, HomConstAdd: the output keeps the scale of its inputs.

Multiplication multiplies scales.

HomMul, HomSquare output the product of their input scales; HomConstMul multiplies by the constant's own scale.

Anything built on multiplication inherits the rule.

HomMatMul multiplies and sums, so the multiplication cost applies, and it's the fastest-growing operator in practice.

Data movement is free.

Reshapes and other layout-only operations rearrange values without arithmetic, so scale passes through unchanged.

Scale is brought back down by spending a level from the modulus chain: that's HomModSwitch, and it's the subject of the next page, Level budget & the modulus chain. The key takeaway is: multiplication increases ciphertext scale, and that growth must eventually be managed.

In the HomOps Reference, every operator's Scale effect field tells you which of these behaviors applies.

Levels​

Every ciphertext also sits on a modulus chain: the q-list configured as full_q_list_precision. That chain is two-dimensional: rows and columns. As operators mod-switch, primes drop off the chain. What remains are the ciphertext's active rows and active columns.
Those two sets are how you read remaining levels:
Active rows and columns identify which levels remain.

The indices you see are the rows and columns still available on that ciphertext, not necessarily a contiguous prefix of the full chain.

Remaining level count is the product of the two set sizes.

If a ciphertext has R active rows and C active columns, it has R × C remaining active levels.

How the chain is configured, how mod-switch spends it, and how to budget multiplicative depth are covered on Level budget & the modulus chain. This page is about recognizing levels as part of ciphertext state, and reading them from the printed graph or from a HomValue while you debug.

Why it has to stay in sync​

Every operator expects specific things about its incoming state: a matching n axis, a scale it can work with, and, when two ciphertexts meet, compatible active rows and columns on the q-list. Chaining operators means chaining those expectations too. The output state of one operator has to be something the next operator can actually accept.
That is why the operator pages are structured the way they are: Shape effect, Scale effect, and Level budget are the three things worth checking before you place the next operator in your chain.

Visualizing it yourself​

You don't have to track shape, scale, and levels by hand. The most visual way to see all three at once is to print the compiled graph: every operator, with shape, scale, and remaining q-list on each node. After you build, add --print_graph to lattica-build.
bash
# build the MNIST example and print the compiled graph
lattica-build --pipeline-module lattica_build.examples.example_mnist_fc --out /tmp/quickstart_mnist.zip --print_graph
Each node prints the three pieces of state this page covers. Here is a multiply from the MNIST example: shape stays put, scale grows, and q=[…] is the remaining modulus chain.
shape and @

The tensor layout at that node. @ marks the n axis.

scale

pt_scale at that point in the graph. Watch it grow after multiplies and come back down after mod-switch.

q=[…]

Remaining modulus-chain state: the q-list still active on that value. That is the ciphertext's remaining levels.

How to read the rest of the tree (sections, node ids, value flow) and a full example are on Inspecting the pipeline → After build: print the compiled graph.
You can also step through the pipeline in Python's debugger and inspect each HomValue: tensor_shape, n_axis, n_slots, pt_scale, active_rows, and active_cols. Remaining levels = (number of active rows) × (number of active columns). The indices tell you which levels remain, not only how many. internal_n is read-only and lives on HomParams, so look for it there rather than on the value.
Example. Suppose a HomValue shows active rows 5 and 7, and active columns 2, 3, and 5. That is 2 × 3 = 6 remaining active levels, and you know exactly which row/column indices are still available.
Step op by op and watch those sets shrink when the budget is spent. If a chain is too short, this is where you see it run dry. For planning the chain itself, continue to Level budget & the modulus chain.

The printed graph is the visual view of shape, scale, and levels on every node; the debugger inspects a single HomValue. Both stay on your machine. The compiler report runs at deploy. See Inspecting the pipeline for the full loop.

Next​

See how each operator affects shape, scale, and levels in the HomOps Reference, or read on to Level budget & the modulus chain, where the rest of the scale-and-levels story (the modulus chain, multiplicative depth, and with_modswitch) lives.