← HomOps Reference
HomExpand
Turn the ciphertext's packed block into a new tensor axis.
ExSlots
RunsServer
Changes shapeYes - inserts the packed period at
expand_axisChanges scaleYes - managed per mod-switch group
Levels spent
len(stage_sizes) / stages_per_levelRotatesYes - stage plan via
stage_sizesKeysRotation key
What it does
HomExpand replicates the input value and gives it a new tensor axis as wide as the block it is packed into, input.n_slots. The copies are built across slots with a rotation-sum tree: each stage doubles up (or multiplies up) until the block is full.The usual reason to expand is a multiply that follows. A short encrypted query is broadcast across a plaintext database axis, then combined with
HomConstMul or HomMul and summed. That is the pattern behind exact search and similar retrieval flows. When that query is encrypted in a sub-ring, HomRingSwitch must be the first server operator; expand runs after the lift.You choose the expansion width before the operator runs: set
HomParams.n_slots for the primary input, or HomomorphicPipeline.custom_n_slots for another named encrypted input. Encryption repeats that block across all internal_n physical slots. There is no separate client-side repeat step.During shape inference, the new axis comes in with size
input.n_slots. When n_axis is known, the packed axis is shown at its physical size, internal_n, while n_slots stays the block size of the ciphertext. Layout is covered on Ciphertext state.Because it rotates,
HomExpand takes a stage plan. If stages, offsets, and rounds are new to you, read Slots, rotations & stages first; this page only states how the rules land on this operator.Signature
HomExpand( expand_axis=0, stage_sizes=None, stages_per_level=None, rows_budget=None, )
HomExpand takes no weights, so there is no set_data call. The masks it multiplies by are generated at compile time; rows_budget can constrain the internal mod-switches.Parameters
ParameterTypeDefaultDescription
expand_axisint0Where the new axis is inserted, as an index in the output shape.stage_sizessequenceNoneThe stage plan: one entry per stage, each entry the stage's group size. Unset means (2,) × log₂(input.n_slots), the smallest-key plan.stages_per_levelintNoneHow many stages share one mod-switch. Unset means 1: a mod-switch after every stage.rows_budgetSequence[int] | NoneNoneAbsolute rows allowed for the internal mod-switches, useful when reserved rows must be preserved.Rules that depend on the value
SettingRuleIf you break it
stage_sizesThe entries must multiply out to the packed block: prod(stage_sizes) == input.n_slots.Compilation fails when the plan does not cover input.n_slots.stage_sizes unsetThe default plan (2,) × log₂(input.n_slots) requires input.n_slots to be a power of two.Compilation fails; n_slots must be a power of two.stages_per_levelMust divide the number of stages exactly: len(stage_sizes) % stages_per_level == 0.Compilation fails: stages_per_level must divide len(stage_sizes).Requirements
- Rotation key entries. Every rotation offset in the stage plan must be present in the evaluation key. The compiler derives the exact set from
input.n_slotsandstage_sizesand generates the key with those entries; you don't list them yourself. - Packed block size. Set
n_slotson the input before tracing. It must be a power of two no larger thanHomParams.internal_n, and the n axis must fit inside it. - Level headroom. The chain must have
len(stage_sizes) / stages_per_levellevels left when the expand runs. See Level budget.
These surface as compilation errors; see Compilation errors.
Shape effect: yes
Rule: first insert an axis of size
input.n_slots at expand_axis. When n_axis is known, represent that packed axis at its physical size, internal_n.shape.pyPYTHON
# logical output: insert input.n_slots at expand_axis output = (*shape[:expand_axis], input.n_slots, *shape[expand_axis:]) # when n_axis is known, tracing sets that packed axis to internal_n
Input shapeinput.n_slotsexpand_axisOutput shape
(64,)641(internal_n, 64) when n_axis = 0
(128,)1281(internal_n, 128) when n_axis = 0
(d0, d1)80(8, d0, d1) logical
The n axis itself is not consumed; its position shifts by one when
expand_axis is inserted before it. Shape and the n axis are covered on Concepts.Pipeline chain view
Exact search pattern, query expanded across the database's record axis:
Scale effect: yes
Rule: scale is managed per mod-switch group. A group is
stages_per_level consecutive stages.Inside each group:
- Each stage multiplies by a plaintext mask. The mask scales are chosen so the whole group's growth equals one prime's worth.
- At the end of the group, one mod-switch runs and divides the scale back down.
So the scale climbs within a group and is brought back at the group boundary, once per group instead of once per stage. The net effect over the whole operator is one mod-switch worth of scale movement per group. How scale moves through a pipeline is on Ciphertext state → Scale.
Stages & rotations
The general rules live on Slots, rotations & stages; here is how they apply to
HomExpand:QuestionAnswer for HomExpand
Where does expansion width come fromFrom
input.n_slots. Configure it on the params or pipeline, not on this operator.Default plan
stage_sizes unset gives (2,) × log₂(input.n_slots).Plan constraint
prod(stage_sizes) == input.n_slotsDistinct rotation offsets
Σ (entry − 1) over the plan. Sets the rotation key size.Rounds
len(stage_sizes). Sets how many passes the server makes; fewer rounds means a faster query.Levels spent
len(stage_sizes) / stages_per_level, controlled by stages_per_level.The three plans from the concepts page, on this operator:
plans.pyPYTHON
# Default: log-depth tree (smallest key) HomExpand() # input.n_slots=128: 7 stages, 7 offsets, 7 levels # Collapsed: one wide stage (bigger key, fastest) HomExpand(stage_sizes=(128,)) # 1 stage, 127 offsets, 1 level # Small key, grouped mod-switches (fewest levels for this tree) HomExpand(stage_sizes=(2,)*7, stages_per_level=7) # 7 stages, 7 offsets, 1 level
Level budget
ConfigurationLevels spent
Defaults (
stage_sizes and stages_per_level unset)log₂(input.n_slots) - one per stageExplicit plan
len(stage_sizes) / stages_per_levelEach mod-switch drops one prime from the chain, at the end of each group of
stages_per_level stages. The rotations and mask multiplies themselves spend nothing; only the mod-switches do.Use
rows_budget (the same mechanism as on HomMul) when you need to restrict which levels these mod-switches may touch. How primes are organized in the chain, and what exactly gets dropped, is explained on Level budget → The chain.Keys
Rotation key. Every offset in the stage plan needs an entry in the rotation key. The set is derived at compile time from
input.n_slots and stage_sizes, and the key is generated for you during key generation with exactly those entries. Wider stages mean more offsets and a bigger key; that trade is on Slots, rotations & stages.No square key:
HomExpand never multiplies two ciphertexts.Example
Expand a query, then hit a database
The exact-search pattern: set
n_slots=64, encrypt (which repeats that block across the ring), expand it along the database axis, multiply, and reduce. When the query is encrypted in a smaller ring, put HomRingSwitch first on the server, then this expand.pipeline.pyPYTHON
from lattica_build.params import HomParams from lattica_build.operators import ( HomExpand, HomConstMul, HomAxisSum, HomSquare, ) from lattica_build.base_classes.hom_pipeline import HomomorphicPipeline from lattica_build.operators.composite.sequential import SequentialHomOp K = 64 params = HomParams(..., n_slots=K) # encoder repeats the K-slot block across the ring pipeline = HomomorphicPipeline( hom=SequentialHomOp( HomExpand( expand_axis=1, stage_sizes=[4, 4, 4], # 4 * 4 * 4 = 64 stages_per_level=3, # one mod-switch for all three stages ), HomConstMul(dims=db.shape, with_modswitch=True), HomAxisSum(dim=2, keep_dim=True), *(HomSquare(with_modswitch=True) for _ in range(2)), HomAxisSum(dim=0, keep_dim=False), ), input_shape=(K,), ) pipeline.set_data(1, db)
The plan
[4, 4, 4] costs 3 + 3 + 3 = 9 rotation offsets over three rounds, and with stages_per_level=3 spends one level.Minimal expand
pipeline.pyPYTHON
K = 128 params = HomParams(..., n_slots=K) pipeline = HomomorphicPipeline( hom=SequentialHomOp( HomExpand(expand_axis=1, stage_sizes=[8, 16], stages_per_level=2), ), input_shape=(K,), )
[8, 16] covers 128 in two stages: 7 + 15 = 22 offsets, two rounds, one level.See also
- HomRotateSum: the rotate-and-add primitive expand trees build on
- HomRingSwitch: first server operator when the query is encrypted in a sub-ring
- Choosing parameters: configure
n_slotsfor primary and extra encrypted inputs - Ciphertext state:
n_slotsvsinternal_n - HomConstMul · HomMul: the multiply that usually follows an expand
- HomAxisSum: reduce after the multiply
- Slots, rotations & stages: stage plans, offsets, and the key-size trade