Skip to main content

← HomOps Reference

HomExpand

Turn the ciphertext's packed block into a new tensor axis.

ExSlots
RunsServer
Changes shapeYes - inserts the packed period at expand_axis
Changes scaleYes - managed per mod-switch group
Levels spentlen(stage_sizes) / stages_per_level
RotatesYes - stage plan via stage_sizes
KeysRotation 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_slots and stage_sizes and generates the key with those entries; you don't list them yourself.
  • Packed block size. Set n_slots on the input before tracing. It must be a power of two no larger than HomParams.internal_n, and the n axis must fit inside it.
  • Level headroom. The chain must have len(stage_sizes) / stages_per_level levels 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:
tensor shape: (internal_n,)physical n axis; n_slots = 64
HomExpand(expand_axis=1)
tensor shape: (internal_n, 64)ready for the database multiply

Scale effect: yes​

Rule: scale is managed per mod-switch group. A group is stages_per_level consecutive stages.
Inside each group:
  1. Each stage multiplies by a plaintext mask. The mask scales are chosen so the whole group's growth equals one prime's worth.
  2. 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 planstage_sizes unset gives (2,) × log₂(input.n_slots).
Plan constraintprod(stage_sizes) == input.n_slots
Distinct rotation offsetsΣ (entry − 1) over the plan. Sets the rotation key size.
Roundslen(stage_sizes). Sets how many passes the server makes; fewer rounds means a faster query.
Levels spentlen(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 stage
Explicit planlen(stage_sizes) / stages_per_level
Each 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​

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​