Skip to content

DO NOT MERGE: changes over google/main for Belfort's pipeline - #3349

Draft
AlexanderViand wants to merge 18 commits into
google:mainfrom
belfortlabs:alex/close-to-main
Draft

DO NOT MERGE: changes over google/main for Belfort's pipeline#3349
AlexanderViand wants to merge 18 commits into
google:mainfrom
belfortlabs:alex/close-to-main

Conversation

@AlexanderViand

@AlexanderViand AlexanderViand commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Creating this simply as a nice place to see the stack and its diff, not intended for review/merge!

This branch tracks the changes we need to make Belfort's pipeline work with the google/main version of HEIR.

I've opened individual PRs for pretty much all of this (except the experimental or fork-specific parts) and tagged them below.

Commits



  • Some more experimental work around lowering convolutional and MLP models to linear_transforms cleanly
  • A failing-on-main reproducer test for a mgmt/bootstrap issue (See Correctly handle bootstrapping with cross-level operation #1642), and a more-workaround-than-fix "solution" in two parts.
  • A somewhat experimental improvement to how the interface generation works, with a clearer path forward to a real client/server split.

@AlexanderViand
AlexanderViand force-pushed the alex/close-to-main branch 3 times, most recently from 5d8a3bf to 9e6d372 Compare August 20, 2026 08:52
@AlexanderViand AlexanderViand changed the title DO NOT MERGE: changes over google/main DO NOT MERGE: Belfort's medusa changes over google/main Aug 20, 2026
@AlexanderViand AlexanderViand changed the title DO NOT MERGE: Belfort's medusa changes over google/main DO NOT MERGE: changes over google/main for Belfort's (lattigo) pipeline Aug 20, 2026
@AlexanderViand
AlexanderViand force-pushed the alex/close-to-main branch 6 times, most recently from ae63b56 to fcc324f Compare August 21, 2026 07:07
@AlexanderViand AlexanderViand changed the title DO NOT MERGE: changes over google/main for Belfort's (lattigo) pipeline DO NOT MERGE: changes over google/main for Belfort's pipeline Aug 21, 2026
@AlexanderViand
AlexanderViand force-pushed the alex/close-to-main branch 13 times, most recently from 7e90a60 to afa7c28 Compare August 27, 2026 06:43
j2kun and others added 5 commits August 27, 2026 14:36
This change:
- Implements the LWE-to-Lattigo lowering pattern for kernel.linear_transform to lattigo.ckks.linear_transform.
- Enables has_kernel_linear_transform = 1 in Lattigo target config.
- Fixes the Lattigo emitter to dynamically convert float32 diagonals to float64 in Go.
- Resolves LevelQ and Scale matching issues in the emitter using dynamic ciphertext level instead of compile-time constants.
- Aligns encoding slots with actual slot counts for CKKS bootstrapping.

After this change, the e2e lattigo tests using linalg.matvec lower to use the linear_transform op.

Still TODO is lowering a conv operator to an intermediate matvec op so it can be preserved as a linear_transform op, which is a bit of a larger change because it requires extracting the lowering for conv ops to occur before layout-propagation, and hard-coding the layout choice.

PiperOrigin-RevId: 960437546
Squashed from edc9c4e, 5fbcbb6 and 9694862 on the belfort fork.

- sky_x86_64_wheel.yaml / sky_arm64_wheel.yaml build the belfort-heir wheels
  with cibuildwheel on a SkyPilot instance.
- sky/warm_cache.yaml populates the BuildBuddy remote cache for x86_64 linux by
  building //tools:all with the exact config setup.py uses, so a consumer's
  `uv sync` hits the cache instead of rebuilding LLVM.
- .skyignore keeps bazel output trees out of the workdir sync.
- .bazelrc / bazel/download_config.cfg / setup.py carry the matching remote-cache
  and pocketfft download settings.

Fork-only infrastructure: not upstreamable, and required for medusa to be able
to pin this fork as an installable package.

The warm-cache setup installs no packages: the build registers a hermetic LLVM
toolchain, so bazel supplies its own compiler and linker, and wget is already on
the image. The apt fetch it replaced downloaded ~200 MB of clang to be ignored,
and was the slowest and least reliable step of a launch.
Squashed from f587d36, 67261d3 and 1b2a2c9 on the belfort fork.

- Rename the published distribution heir_py -> belfort-heir (pyproject.toml,
  frontend/heir/_extras.py) so medusa can depend on the fork without colliding
  with upstream's package name.
- scripts/get_version.py: stop hard-coding the heir_py distribution name and
  derive it, with the matching test update.
- Publish from the ml-pipeline branch rather than main (release workflow).
- Pass GitHub context and job outputs to release scripts through step
  environment variables, avoiding shell template injection.

Fork-only packaging: not upstreamable, and required for medusa to pin this fork.
* Mirror Lattigo's FindBestBSGSRatio exactly for sparse diagonal sets

    findBestBSGSRatio guarded the ratio comparison with nbN1 > 0 and compared in
    integers. Lattigo divides in floating point without that guard, so a sparse
    diagonal set, where nbN1 reaches 0 with nbN2 > 0, must take the > maxRatio
    branch on the resulting +inf (and continue on a NaN 0/0). Guarding skips those
    iterations and can select a different baby-step size than the backend, which
    then generates rotation keys for the wrong Galois elements. Dense diagonal sets
    never reach nbN1 == 0, so this only shows up for sparse transforms such as an
    expanded convolution filter.

* Encode plaintexts at the requested slot count

    Encoding at the ring's full slot count makes every ciphertext full-width, which
    silently changes what a rotation means: rotations are modulo the slot count, so
    a matrix packed at the requested (smaller) width lands in the wrong slots. On a
    convolutional model that shows up as a ~5-orders-of-magnitude accuracy loss
    (2.9e-02 relative error against 6.2e-07) rather than a failure.

    The requested slot count is what the layouts packed to, so encode to that and
    leave the ring as large as the parameters need it. The diagonals of a linear
    transform are then narrower than the ring, which the verifier now allows: the
    transform acts on the leading slots.

* Record the packing width before the bootstrap bump

    Lattigo bootstrapping needs LogN >= 14, and since the ring dimension follows
    from the slot count, the pass bumps minSlotCount to 8192 to get there. That
    bump is about the ring, but it was also written back as the requested slot
    count, which is what the layouts packed to and what plaintexts are encoded at.
    Widening it desyncs the packed layouts from the ciphertexts.

    Keep the pre-bump value for the requested-slot-count attribute.

* Require a linear operand for the kernel ops when placing relinearization

    A linear transform is a bundle of rotations under Galois keys, and a Chebyshev
    evaluation multiplies its input, so both need a linear ciphertext just as
    tensor_ext.rotate does. Without that constraint the model may defer a
    relinearization past them, leaving it applied to an already-linear result:
    lattigo then fails with "ctIn.Degree() should be 2 but is 1".

    This only becomes reachable once linear transforms survive to the backend.

* Only bootstrap ciphertexts

    The bootstrap target walk filtered values by secret type, which does not
    exclude a plaintext: a plaintext has no level for the analysis to track, that
    reads as "needs bootstrap", and the pass then put a mgmt.bootstrap on an
    mgmt.init. Nothing lowers that, and it surfaces far away as a ct-ct multiply
    against a plaintext operand ('ckks.mul' op operand #0 must be
    lwe-ciphertext-like).

    Ask the secretness analysis as well.

* Charge lattigo's bootstrap no compute levels

    Lattigo runs its bootstrapping circuit in its own parameter set, whose ring
    carries extra primes: bootstrapping.NewParametersFromLiteral takes the compute
    parameters as the *residual* ones. A bootstrap therefore returns the ciphertext
    at the top of the compute chain and consumes none of it. Probing the emitted Go
    for tcn confirms it: Bootstrap(in=41) returns out=26 with maxlevel=26.

    Charging 16 levels made every level after a bootstrap 16 too low, which both
    inflated the modulus chain and mis-scheduled the rescales that follow.

    The budget the pass works from is levelBudget + bootstrapLevelsConsumed, so
    a test that stated its budget net of the old 16-level charge now has to name
    the total it meant: if.mlir asks for 18 where it used to ask for 2.
    relu_composite now also inserts a real bootstrap, so its e2e test accepts
    the bootstrapping evaluator and covers the correspondingly slower path.
    loop_invariant_hoist_reproducer instead keeps the old charge as an explicit
    override: its module was captured off tcresnet8small under a 16-level
    charge, and reaching the loop with the levels exhausted is a precondition of
    the hoisting it asserts rather than a property of lattigo.

* Bootstrap a copy so the operand survives

    lattigo's Bootstrap mod-ups its argument into the bootstrapping ring in place
    and leaves it there, which a probe on the emitted Go shows plainly:

        boot BEFORE: ct4 lvl=1  log2scale=45
        boot AFTER : ct4 lvl=29 log2scale=52      <- argument clobbered
                     out ct92 lvl=14 log2scale=45 <- result is fine

    The op is a function of its operand, though, so the operand may still be live,
    and a composite ReLU makes that the common case: x * step(x) needs the
    un-bootstrapped x alongside the refreshed branch that produced step(x). On tcn,
    7 of 28 bootstrap operands are read again afterwards - already at the CKKS
    level, before any in-place assignment - so those reads take a ciphertext that
    is no longer in the compute ring. The values are wrong from there on, and the
    scale it leaves behind (2^52 against a base of 2^45) propagates until a later
    bootstrap fails lattigo's message-ratio check:

        panic: cannot BootstrapMany: initial Q/Scale = 7.999996
               < 0.5*Q[0]/MessageRatio = 256.000000

    Hand lattigo a copy. A ciphertext copy is negligible next to a bootstrap, so
    this is unconditional rather than guarded on the operand having later uses.

    This is the convention the dialect already uses for a value-semantics op whose
    lattigo call is in-place: rescale_new, drop_level_new and negate_new all copy
    the input and hand the copy to the in-place method. bootstrap was the one that
    passed its operand straight in. Keeping the op Pure is right - the copy is what
    makes it true - and it lets CSE deduplicate bootstraps of the same value, which
    is worth having for the most expensive op in the program.

    The op description still says it "applies bootstrapping in-place", which now
    describes the lattigo call rather than the op; worth a separate docs pass.

* Model the level a lattigo chebyshev consumes

    In the lattigo dialect every ciphertext has the same opaque type, so an
    analysis learns a value's level only from the interfaces an op declares.
    ckks.chebyshev declared none and was therefore modelled as consuming nothing,
    while it consumes one level per level of the binary evaluation tree. Nothing
    checks that belief, so the error is silent: alloc-to-inplace hands a
    chebyshev's exhausted buffer to a later op as its destination, the backend
    evaluates into it at min(operands, destination), and the quietly truncated
    result surfaces much later as "cannot Rescale: input Ciphertext level is too
    low". Declare ReducesLevelOpInterface, mirroring the depth
    kernel.eval_chebyshev already models.

    ckks.linear_transform deliberately does NOT declare it, and the op
    description now says why: the transform is built at the input ciphertext's
    own level, so evaluating it consumes no level, and the rescale the lowering
    emits straight afterwards is the single level the multiplication costs.
    Charging the transform as well double-counts, which drifts every value
    downstream of a transform one level too deep until the next bootstrap resets
    it, and makes the alloc-to-inplace level comparison unsound in a way the pass
    cannot see.

* Do not assert in getRotationIndices on a bufferized diagonals operand

    CKKSLinearTransformOp::getRotationIndices cast the diagonals operand to
    RankedTensorType. The operand is a tensor before bufferization and a memref
    after it, and the accessor is reachable in both states, so on an
    already-bufferized module the cast aborts:

        Casting.h:560: cast<To = mlir::RankedTensorType, From = mlir::ShapedType>:
        Assertion `isa<To>(Val)' failed

    The full pipeline runs configure-crypto-context before bufferization, so this
    does not fire during a normal compile. It fires when the analysis is run on a
    dumped module, which is how rotation-key collection gets debugged. Match on
    ShapedType, which is all the accessor needs.
AlexanderViand and others added 13 commits August 27, 2026 14:36
A ciphertext argument does not have to start at the top of the modulus chain.
HEIR asks the client to encrypt at the level the computation actually begins at,
which for a model whose first op is a linear transform is well below the top. On
tcn the entry ciphertext is fresh but sits at level 2 of an eleven-modulus chain:

  func.func @tcn(%0: !lwe.lwe_ciphertext<..., modulus_chain = <elements = <11
  moduli>, current = 2>>, ...)

The lattigo lowering then gives every ciphertext the same opaque
!lattigo.rlwe.ciphertext, and LevelAnalysis::setToEntryState joins every entry
lattice to LevelState(0) -- the top of the chain. Everything derived from that
argument is believed eight levels shallower than it is.

Nothing verifies that belief. lattigo-alloc-to-inplace is the only pass that
runs LevelAnalysis after this lowering, and it compares the believed level of a
candidate buffer against the believed level of an op's result to decide whether
reuse is safe, so a wrong belief costs reuse it should have taken or takes reuse
it should have refused. On tcn this changes 34 reuse decisions.

Record the starting level while the LWE type still carries it, and have
setToEntryState read it back. `current` indexes the module's Q chain, so the
depth is measured against the length of that chain from the scheme parameters
rather than against the type's own element list: a lowered LWE type can carry a
truncated view of the chain -- a fully consumed value in the same function
arrives as `elements = <1 modulus>, current = 0` -- and measuring against a
truncated list silently reports depth 0. Modules without scheme parameters
(hand-written test IR) still fall back to the type's own list.

Note this restores information the lowering drops; it is not what keeps the
backend from truncating a result. Lattigo resolves an in-place result to
min(operand levels, receiver level), so soundness there comes from the emitter
giving the receiver the operand's level, not from the level analysis being right.
RotationAnalysis rebuilds an enclosing scf.for as an ArithmeticDag and
enumerates the shifts its rotations use, so configure-crypto-context can
generate exactly the Galois keys a program needs. DagBuilder covered scf.for,
scf.if and the tensor ops, but not an affine.for nested in that reconstructed
loop.

An unhandled nested op is resolved as an opaque variable, severing everything
upstream of it from the DAG root. The analysis still reports success, but the
rotations inside the affine loop disappear. On tcn, rotate(ct, %i + 64) over
%i = 1..5 therefore omitted the key for rotation 65 and failed at runtime.

Teach DagBuilder to model constant-bound affine.for and affine.yield just like
their scf counterparts. The lit test exercises both the induction variable and
induction-variable-plus-constant forms inside an affine.for nested in scf.for;
the unit test covers DAG evaluation directly.

This does not handle a rotation whose enclosing loop is a bare affine.for.
RotationAnalysis still rejects that case under TODO(google#2712); this change only
prevents nested affine loops from severing an scf.for DAG.
The storage type's element list was built by inserting into a DenseSet<Type>
and then copying the set out. Type is a pointer wrapper, so the set hands its
elements back in the order of their addresses, and that order is not stable
from one run to the next.

The order is not cosmetic: the element list is part of the __preprocessing
function's signature and fixes which memref each store and load site indexes,
so permuting it produces a different module for the same input. A storage with
one element type could not show this; a storage holding several -- plaintexts
next to prepared linear transformations -- can.

Collect the types in first-occurrence order instead, which is what the nearby
uniqueElementTypes() helper already documents as the intended contract.
SecretnessAnalysis runs before walk-based rewriting. Replacing an earlier eval creates a new operand with no lattice entry, while each later eval result remains analyzed. Query the result so chained secret evaluations consistently use native lowering.
Add a ConvertToEmitC dialect interface that emits scalar math.sqrt as std::sqrt and includes <cmath>. This keeps Math operations out of the C++ translator until equivalent support can be upstreamed to MLIR.
Two problems, one shape. A Halevi-Shoup convolution or matvec was materialised
as an explicit rotate / multiply / accumulate DAG, one branch per diagonal, and
the lattigo backend then built and encoded a lintrans.Transformation inside the
hot function, so every inference re-encoded every transform. For a backend that
can evaluate a linear transform directly the DAG is the wrong shape twice over:
it hides from the backend that the whole thing is one transform, so BSGS and
hoisted rotations cannot be used, and it inflates the IR by the number of
diagonals. On the pooling test it is 65 rotations where one op will do, and
under rolled kernels it is an scf.for/scf.if rotation nest that need not exist
at all.

Keep the transform compact, then split preparing it from applying it:

* Emit the transform as a single tensor_ext.rotate_and_reduce carrying the
  diagonals, marked as a linear transform so implement-rotate-and-reduce leaves
  it alone, and lower that to kernel.linear_transform so the abstraction
  survives into the backend rather than being expanded on the way. Zero
  diagonals of a mostly-zero matrix are dropped and their offsets recorded, so
  the backend neither encodes nor rotates for them. A squat packing (rows <
  cols) still needs the partial rotate-and-reduce afterwards, mirroring
  implementHaleviShoup. The convolution paths and the matvec kernel both take
  this route -- every fully-connected layer that torch lowers as
  matmul+transpose reaches that kernel as a linalg.matvec after linalg
  preprocessing -- gated on the target advertising has_kernel_linear_transform,
  so a backend without one still gets the expanded DAG it can handle.

* Take the diagonals as an operand rather than an attribute. Row k is the
  generalized diagonal named by diagonal_indices[k]; when source_row_indices is
  present, that diagonal is read from source_row_indices[k] instead. The packed
  matrix can then be a resource or a preprocessed value instead of inline IR,
  which keeps it out of the emitted source. For a constant producer, the
  lowering folds selection into a compact constant. For any other producer it
  retains the full tensor and carries the selected source rows through
  preparation and backend emission, avoiding a large extract/concat gather in
  preprocessing. Inferring either representation from the row count would be
  ambiguous and would oblige every backend to guess which contract it received.

* Split kernel.linear_transform into kernel.prepare_linear_transform, which
  encodes the diagonals into an opaque !kernel.prepared_linear_transform, and
  kernel.apply_linear_transform, which evaluates it on a ciphertext. The
  prepare op implements PlaintextEncodeOpInterface and its result type
  PlaintextTypeInterface, so the existing generic split-preprocessing pass
  hoists the whole preparation into the __preprocessing helper with no new
  backend machinery, for every backend at once. Once preparation is offline,
  imperfect transforms (row selection, zero-padding, uneven embeddings) cost
  setup time rather than evaluation time.

  The prepared type carries the static parameters the preparation bakes in:
  level, slot count, and BSGS split. Two apply sites with different levels then
  need two distinct prepares by construction, and the apply verifier rejects a
  level mismatch that backends today only discover as a silently mis-scaled
  result, since they read the level off the ciphertext at runtime. (A transform
  inside a rolled loop runs at exactly one level, because scf.for iter_args fix
  the loop-carried ciphertext type, so one prepared transform per op always
  suffices.)

  The pass splits every kernel.linear_transform whose input type determines the
  parameters, gated on the target declaring has_prepared_linear_transform and
  on the module being CKKS -- only the CKKS lowerings implement prepare/apply,
  so splitting anything else would leave ops no backend pattern can convert.
  kernel.linear_transform stays valid sugar for backends that lower it
  directly.

* Lower a prepared transform to lattigo and emit it: new ckks operations over a
  prepared-transformation type map onto NewTransformation plus Encode, which
  split-preprocessing hoists into __preprocessing, leaving __preprocessed with
  the evaluator call alone. The rotations are keyed off the slot count the
  prepare op records, since lattigo reduces its BSGS split and Galois elements
  modulo that count, so deriving them from the width of the diagonals operand
  can omit a required key. `getEncodedSlotCount` centralizes this choice for
  both direct CKKS lowering and prepared transforms, extending the requested
  slot-count handling to both paths. The level comes from lwe::getLevel: both the
  preparation and the direct lowering computed the consumed depth (chain size -
  1 - current) and handed it to lattigo as levelQ, which is the number of
  remaining moduli, i.e. exactly chain.getCurrent(). The transform's bsgs_ratio
  is honoured, of which the prepared type records the log2, rather than
  silently forced to the backend default. Optional source-row indices are
  forwarded to the emitter so it encodes the selected full-width rows directly.
  Applying the prepared transform preserves the ciphertext level; the explicit
  rescale performs the only level drop, so backend level analysis does not
  double-count it and reuse storage from a physically lower level.

* Lower a multi-type preprocessing storage to one memref per type. The lattigo
  preprocessing lowering assumed a storage with exactly one unique element type
  (rlwe plaintexts) and mapped it onto a single flat memref; a hoisted prepared
  transform adds a second. Reuse the generic one-memref-per-unique-type
  machinery from preprocessing-to-memref, moved into the shared conversion
  util, so __preprocessing returns one slice per element type. Backend
  lowerings can collapse several storage element types into one (every
  prepared-transform type becomes the one lattigo transformation type), so the
  converted element list is deduplicated in first-occurrence order, and the
  store/load index lookup matches that order.

The conversion tests move with it: pooling and conv2d_chain asserted the
expanded form (including pooling's rolled-kernel prefix), and linear_transform
checked the diagonals as an attribute. MNIST no longer generates or passes its
two encrypted-zero accumulator helpers because the compact transform does not
materialize those explicit accumulators. implement-rotate-and-reduce declares the
kernel dialect as a dependent dialect, since it now creates
kernel.linear_transform ops.

Co-authored-by: Marc Desgroseilliers <marc.desgroseilliers@belfortlabs.com>
CKKS ciphertext multiplication needs a modulus limb available for its following
rescale. Run a conservative headroom analysis before ordinary waterline
placement and bootstrap operands whose downstream multiplication would
otherwise reach an exhausted level.

The repair treats the refreshed value as the new SSA version: a bootstrap
inserted at a safe point replaces every dominated use of the operand, not only
the multiplication that exposed the shortage. This deliberately broad repair
prevents later uses from continuing with the exhausted value, but can refresh
uses that do not themselves need it; the subsequent scale-aware cleanup removes
redundant detours where the original value already had the required state.

Validate populated scales in the default RLWE pipeline so an impossible CKKS
schedule is rejected by the compiler rather than reaching a backend.
The conservative headroom repair replaces all dominated uses with a refreshed
value. After scale placement, this can leave an adjust-scale, mod-reduce or
level-reduce chain followed by a bootstrap even though an ancestor already has
the exact level, scale and ciphertext dimension the use requires.

Mark only headroom-repair bootstraps with the discardable mgmt.mul_headroom
attribute. Once PopulateScaleCKKS has annotated exact management state, walk
backward through those management-only operations and replace a marked
bootstrap with an ancestor carrying the same MgmtAttr. Remove the marker from
bootstraps that remain so it cannot escape the pipeline.

The regression now keeps the bootstrap required by the square branch while
asserting that no redundant bootstrap or marker remains on the other branch.
Assign explicit roles to the logical entry point and its encryption, decryption, preprocessing, and evaluation helpers. Preserve the original cleartext input and result types so backend interfaces can expose a stable API without reconstructing it from lowered function signatures.

Distinguish server preprocessing from client packing, and update helper classification and split Lattigo emission to use the new role while retaining compatibility with existing packing helpers.
* lattigo: tag the generated configure function with its interface role

    Every other client/server helper carries a role attribute naming the entry it
    belongs to; <entry>__configure carried none, leaving it discoverable only by
    its name suffix. Lattigo fuses context setup and key generation into that one
    function, so it takes client.setup_func alone.

* lwe: index the encrypted-zero client helpers

    client.enc_zero_func and client.enc_zero_arg were bare unit attributes, so
    pairing a helper with the entry argument it feeds meant relying on the order
    the arguments were appended in. Give both the {func_name, index} dictionary
    every other indexed helper role uses; the index is already computed to name
    the function.

* client interface: leave existing helpers out of the entry roles

    The pass ran over every function in the module, so the layout-assignment
    helper convert-to-ciphertext-semantics outlines earlier also picked up
    heir.entry_func, heir.entry_input_types and server.evaluate_func, all naming
    itself as the entry. Skip functions that already carry a helper role, as
    split-preprocessing does.

* split-preprocessing: record the forwarded entry arguments

    The preprocessing function takes a subset of the entry's arguments, chosen by
    dataflow and ordered by walk order. That mapping was only recoverable from the
    operand list of the call in the combined entry function, so a backend building
    a public interface had to re-derive it. Record it as entry_arg_indices in the
    server.preprocessing_func role.

* entry interface: share the role discovery

    Collecting an entry point's client/server roles reads nothing but the
    attributes in ModuleAttributes.h, so it does not belong inside the Cheddar
    EmitC pass. Move it to lib/Utils, anchored on the backend-independent entry
    contract rather than on the setup function, and let a backend that fuses
    key generation into setup, or that has no client encryption helpers at all,
    still find what is there. Encrypted-zero helpers are collected too.

* lattigo: emit a Go entry interface

    A harness written against the generated Lattigo ABI has to know whether the
    program bootstraps (it changes __configure's arity), how many encrypted zeros
    the client supplies, how many storage slices __preprocessing returns, and
    which cleartext arguments it is given -- none of which is visible in Go
    without reading the emitted file.

    Add --emit-lattigo-interface, which reads the entry roles and emits a facade
    whose shape is fixed: a context struct holding what __configure returns, and
    Setup/Encrypt/Preprocess/Evaluate/Decrypt taking and returning [][]float64.
    The Cheddar backend has had the C++ equivalent since it generates its own
    header; this is the Go counterpart, and it additionally covers the encrypted
    zeros. Roles a module does not have yield no method, so a program entered at
    ciphertext semantics still gets the context and the server side.

* lattigo: exercise the entry interface in the preprocessing example

    heir_lattigo_lib gains an opt-in entry_interface flag, and matvec turns it on:
    its test now runs the same computation a third time through the facade and
    gets the same values as the hand-written calls. Preprocessing helpers move to
    a separate package under the split, so the facade qualifies those calls the
    way the main emitter does.

* lattigo: let the entry interface take a fixed prefix

    The interface's types and functions are named after the entry function, so a
    harness has to be generated per model to name them. --interface-prefix pins
    them to a chosen name instead, which is what a static harness needs.

* lattigo: let a harness supply its own ciphertexts to the interface

    A program entered at ciphertext semantics has no encryption helpers, so the
    interface cannot build its encrypted inputs. EncryptedFrom takes the
    ciphertexts the harness made itself, fills the cleartext arguments alongside
    them, and still calls the generated helpers for the encrypted zeros.

* lattigo: emit the server side without an entry contract

    A program entered at ciphertext semantics never ran --add-client-interface, so
    it has no heir.entry_func to anchor on and no logical input types. Fall back to
    the setup role for the entry's name and to the evaluate function for its
    argument list, and emit what such a module does have: the context, the
    preprocessing and evaluation, and EncryptedFrom. Preprocess is emitted whether
    or not the program was split, so a harness calls the same sequence either way.
Make preprocessing.load_resource a destination-style operation so standard
bufferization owns its allocation and aliasing instead of treating a loaded
resource as storage materialized by a custom result-producing bufferization
model.

With tensor semantics the op returns a result tied to its destination, remains
effect-free and is conditionally speculatable. With buffer semantics it writes
the destination and has no result, so the old unconditional Pure trait was not
truthful. Mark the loaded tensor as non-writable during bufferization; a later
tensor write receives separate storage and a copy rather than mutating the
resource.

Externalize-constants now supplies tensor.empty as the destination, and
One-Shot Bufferize lowers that to an ordinary allocation followed by the
memref-form load. The Lattigo, OpenFHE and TFHE Rust emitters recognize that
destination, suppress the ordinary allocation, and retain their static resource
storage. This keeps runtime storage policy in the backends while giving the IR
and bufferization analysis ordinary destination-style semantics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants