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