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).
HomValue in the debugger; or rely on the compile-time report later. See Inspecting the pipeline.Shape & slot packing
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.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.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.Whichever axis wastes the least space against the number of available slots becomes the n axis automatically.
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.
Same rule as NumPy-style broadcasting, plus this one addition: mismatched n axes never combine, even if the shapes would otherwise line up.
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.
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.
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]
forward_clear uses this same layout. Each operator sees the padding and the repeats, and the tensor you get back is the one decryption returns. How to call it is on Build the pipeline.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.tensor_shape, and whether it touches the n axis.Scale
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.HomAdd, HomConstAdd: the output keeps the scale of its inputs.
HomMul, HomSquare output the product of their input scales; HomConstMul multiplies by the constant's own scale.
HomMatMul multiplies and sums, so the multiplication cost applies, and it's the fastest-growing operator in practice.
Reshapes and other layout-only operations rearrange values without arithmetic, so scale passes through unchanged.
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
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.The indices you see are the rows and columns still available on that ciphertext, not necessarily a contiguous prefix of the full chain.
If a ciphertext has R active rows and C active columns, it has R × C remaining active levels.
HomValue while you debug.Why it has to stay in sync
Visualizing it yourself
shape, scale, and remaining q-list on each node. After you build, add --print_graph to lattica-build.# 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_graphq=[…] is the remaining modulus chain.└─ [0.1.0] Mul keep_axis=False with_modswitch=False ├─ x shape=(@100, 1, 50) scale=1099511627776 q=[61] [45] ├─ x shape=(@100, 1, 50) scale=1099511627776 q=[61] [45] └─ → v0 shape=(@100, 1, 50) scale=1208925819614629174706176 q=[61] [45]
The tensor layout at that node. @ marks the n axis.
pt_scale at that point in the graph. Watch it grow after multiplies and come back down after mod-switch.
Remaining modulus-chain state: the q-list still active on that value. That is the ciphertext's remaining levels.
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.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.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
with_modswitch) lives.