Skip to main content

Ciphertext state

Every ciphertext in your pipeline is represented by a HomValue that carries three pieces of metadata: pt_shape (the plaintext tensor shape), pt_scale (the precision of the encoded message), and levels on the modulus chain (which q-list rows and columns are still active).

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
pt_shape
pt_scale
levels
Op 2
pt_shape
pt_scale
levels
Ciphertext
pt_shape
pt_scale
levels

Shape & slot packing

Internally, a ciphertext stores your tensor in two parts. One axis is packed into a fixed number of slots (what slots are is covered in Slots, rotations & stages); this is the n axis. The remaining axes form the tensor_shape. pt_shape combines these two: tensor_shape plus which axis is the n axis. Whenever a reference page mentions "the n axis," this is what it means.
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.

The last block is filled with zeros.

A block holds exactly n / 2 slots. If your n axis is not a multiple of that, zeros are added to the end at encryption time, and trimmed off again when your result is decrypted. You never see them. 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.

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 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, unfolds, repeats: they 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 object represented by a HomValue for the same three fields: pt_shape, pt_scale, and the active rows and active columns on the q-list. Remaining levels = (number of active rows) × (number of active columns). The indices tell you which levels remain, not only how many.
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.