← HomOps Reference
HomExpand
Replicate an encrypted value k times along a new axis.
ExSlots
RunsServer
Changes shapeYes - inserts an axis of size
k at k_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 takes an encrypted value and replicates it k times, creating a new axis of size k in the output shape. The copies are built across slots with a rotation-sum tree: each stage doubles up (or multiplies up) the number of copies until all k exist.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.HomExpand is often paired with Repeat on the client side: repeat fills the ciphertext slots before encryption, expand creates the k-wide axis on the server.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( k, # required k_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
kintrequiredReplication factor. Size of the new axis.k_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₂(k), 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 k: prod(stage_sizes) == k.Compilation fails: Product of stage_sizes must be equal to k.stage_sizes unsetThe default plan (2,) × log₂(k) only exists when k is a power of two.For any other k, write the plan yourself.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
kandstage_sizesand generates the key with those entries; you don't list them yourself. - Slot data in place. The value to replicate must fill the slots the tree reads from. When the input is shorter than the slot count, run Repeat in
client_prefirst. - 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: the output shape is the input shape with an axis of size
k inserted at k_axis.shape.pyPYTHON
# input shape: (d0, d1, ..., dn) # output shape: insert k at position k_axis output = (*shape[:k_axis], k, *shape[k_axis:])
Input shapekk_axisOutput shape
(4096,)641(4096, 64)
(n_slots,)1281(n_slots, 128)
(d0, d1)80(8, d0, d1)
The n axis itself is not consumed; its position shifts by one when
k_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
k come fromYou pass it. k is a required parameter: the size of the axis you're creating.Default plan
stage_sizes unset gives (2,) × log₂(k). Requires k to be a power of two.Plan constraint
prod(stage_sizes) == kDistinct 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(k=128) # 7 stages, 7 offsets, 7 levels # Collapsed: one wide stage (bigger key, fastest) HomExpand(k=128, stage_sizes=(128,)) # 1 stage, 127 offsets, 1 level # Small key, grouped mod-switches (fewest levels for this tree) HomExpand(k=128, 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₂(k) - 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
k 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: repeat the query to fill the slots, expand it 64 ways, multiply against the database, and reduce. When the query is encrypted in a sub-ring, put HomRingSwitch first on the server, then this expand.
pipeline.pyPYTHON
from lattica_build.client_ops import Repeat 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 pipeline = HomomorphicPipeline( client_pre=[Repeat()], hom=SequentialHomOp( HomExpand( k=64, k_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=(N_SLOTS,), ) 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 pipeline = HomomorphicPipeline( client_pre=[Repeat()], hom=SequentialHomOp( HomExpand(K, k_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
- Repeat: fill the slots on the client before expanding
- 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