Skip to main content
Stage 1 · Build

Build the pipeline

This is the part you write. You compose a pipeline from HomOps, load your weights, and set the encryption parameters. The result is one object you hand to Deploy. Everything here runs on your machine.

What you produce

Building a pipeline gives you two things:
A pipeline (HomomorphicPipeline): your computation, built from operators, with weights loaded.
Parameters (HomParams): the encryption settings the pipeline runs under.
You pass both to Deploy. Until that call, nothing leaves your machine.

The three parts of a pipeline

A pipeline has a body and two optional client-side blocks. You define them as arguments to HomomorphicPipeline.
client_prepreprocessYour machine
Runs on your input before it is encrypted, to prepare it for the pipeline.
hombodyWorker
The main computation, run on ciphertext by the worker.
client_postpostprocessYour machine
Runs on the decrypted result. Format the output.
Only the body runs remotely. The pre and post blocks run where your data is, so your input is shaped and your result is read without anything leaving your side.

A worked example: digit recognition

Here is a small classifier ported to run under encryption. It takes a batch of 28×28 images and returns ten class scores per image. The structure is simple: reshape in, two linear layers with a squaring activation, softmax out. The End-to-End Example loads this network from example_mnist_fc. Below is the same pipeline written out, so you can see how to assemble it yourself.
pipeline.pyPYTHON
import os
import torch
from lattica_build.base_classes.hom_pipeline import HomomorphicPipeline
from lattica_build.operators.composite.sequential import SequentialHomOp
from lattica_build.client_ops import Softmax
from lattica_build.operators.ml.h_linear import HomLinear
from lattica_build.operators.shape.h_reshape import HomReshape
from lattica_build.operators.polynomials.h_square import HomSquare

BATCH = 100
INPUT_SHAPE = (BATCH, 1, 1, 28 * 28)

# pretrained MNIST classifier · same weights as example_mnist_fc
model_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "model.pt")
model = torch.load(model_path, weights_only=True, map_location="cpu")
fc1_weight = model["l1.weight"]
fc2_weight = model["l2.weight"]

pipeline = HomomorphicPipeline(
    client_pre=[HomReshape(INPUT_SHAPE)],          # preprocess
    hom=SequentialHomOp(                          # body (on the worker)
        HomLinear(fc1_weight.shape, bias=False, with_modswitch=False),
        HomSquare(with_modswitch=False),
        HomLinear(fc2_weight.shape, bias=False, with_modswitch=False),
    ),
    client_post=[Softmax(-1)],                     # postprocess
    n_axis=0,
    input_shape=INPUT_SHAPE,
)

# load the trained weights into the body operators, by position
pipeline.set_data(0, fc1_weight)   # first HomLinear
pipeline.set_data(2, fc2_weight)   # second HomLinear

Why HomSquare and not HomReLU? Both run encrypted. HomSquare is a cheap quadratic (HomMul of a value with itself). HomReLU is an encrypted composite via HomSign and costs more levels. Simple networks often use squaring when the model can train against it; use ReLU when you need that activation and can afford the Sign depth.

Before you deploy, print the compiled graph to see every operator, shape, and scale, still on your machine. How: Inspecting the pipeline.

Loading weights

The pipeline you composed is architecture only: operators in the body start empty. You load your data into them with set_data, addressing each operator by its position in the SequentialHomOp.
load_weights.pyPYTHON
pipeline = HomomorphicPipeline(
    hom=SequentialHomOp(
        HomLinear((50, 784), bias=False, with_modswitch=False),   # index 0: takes weights
        HomSquare(with_modswitch=False),                                    # index 1: takes nothing, still counts
        HomLinear((10, 50), bias=False, with_modswitch=False),    # index 2: takes weights
    ),
    input_shape=(100, 1, 1, 28 * 28),
)

pipeline.set_data(0, fc1_weight)   # first HomLinear
pipeline.set_data(2, fc2_weight)   # second HomLinear
0

One call per operator

pipeline.set_data(0, fc1_weight) loads the operator at index 0. There is no bulk load; each operator that holds data gets its own call. The arguments after the index are whatever that operator expects.

2

Count every position

HomSquare at index 1 holds no data and takes no call, but it still occupies its position. The next load is index 2, not 1. Count positions, not just the layers you load.

Not every operator takes data. Some operators require data through set_data; most transform the ciphertext itself and take none. Each operator's page in the HomOps Reference states whether it takes set_data and what arguments it expects.

Forgot one? A missing set_data doesn't fail here. The pipeline compiles at Deploy, and that's where an unloaded operator raises an error. If Deploy complains about missing data, check your indices first - miscounting past a no-data operator is the usual cause.

The operators you can use

Every HomOp operator runs on encrypted data. A few examples:
HomMatMulHomRunningSumHomSquareHomReshapeSoftmaxHomReLUHomPolyEval
The full list, with each operator's arguments and where it runs, is in the reference.

Setting the parameters

Parameters control the encryption the pipeline runs under. You build them once and pass them to Deploy alongside the pipeline.
params.pyPYTHON
from lattica_build.params import DecompositionType, HomParams

params = HomParams(
    full_q_list_precision=((61,), (45,)),
    n=2 ** 8,
    pt_scale=2 ** 20,
    decomposition_type=DecompositionType.BV,
)

Parameters depend on your pipeline. The number of levels in full_q_list_precision, the ring size n, and the scale relate to how much computation the body does. Deeper pipelines need more. The demos set these per workload; start from one close to yours and adjust.

The full parameter set, what each field means and how to choose it, is in Choosing parameters.

Hand it to Deploy

Once the pipeline and parameters are ready, one call sends them to the platform. This is the bridge from Build to Deploy.
deploy.pyPYTHON
from lattica_studio import LatticaStudio

studio = LatticaStudio(license_key)

model_id = studio.deploy_pipeline(
    pipeline,
    params,
    "MY_MNIST_MODEL",
    display_graph=True,
)
What happens inside deploy_pipeline (register, upload, compile) is the next stage. Access token creation and key generation come after that, on the same page.

Next

Stage 2

Deploy the Pipeline

Upload and compile, then create a token and generate keys.