[Bug report] OpenFHE 128-bit CKKS can silently wrap a scaled real plaintext coefficient modulo the active tower and decode near zero

Hi OpenFHE team,

I would like to report a reproducible CKKS correctness issue in the NATIVEINT == 128 COMPLEX encoding path.

Summary

With a public parameter set that OpenFHE accepts:

  • multiplicative_depth = 0
  • scaling_mod_size = 88
  • first_mod_size = 88
  • batch_size = 1
  • scaling technique = FIXEDAUTO
  • finite input first slot (1048576, 0)

the public MakeCKKSPackedPlaintext(...) -> Encrypt(...) -> Decrypt(...) round trip returns normally, but the decoded first slot is near zero instead of near the input value.

In my local runs, the same boundary case reproduced on both:

  • OpenFHE v1.5.1 (1306d14f8c26bb6150d3e6ad54f28dfe1007689e)
  • current main commit ed361af22049007db2107e7c69bcff209e8c420d

Two controls decode correctly:

  • a small real input (16, 0) with scaling_mod_size = 80, first_mod_size = 96
  • a more ordinary CKKS case (0.25, 0.125) with multiplicative_depth = 1, scaling_mod_size = 50, first_mod_size = 60, batch_size = 8

This does not require malformed serialized data, mismatched keys, mismatched parameter objects, or non-finite inputs. The input is an ordinary finite real value, and GenCryptoContext(...) accepts the parameter set.

The observed behavior appears to come from a range mismatch inside the 128-bit real encode path: the computed scaled coefficient fits the broad 128-bit conversion guard, but exceeds the active single-tower plaintext modulus. The value is then reduced modulo the active tower by FitToNativeVector(...), and Decode(...) consumes the residue.

Environment

  • OpenFHE current-main commit tested dynamically:
    ed361af22049007db2107e7c69bcff209e8c420d
  • Also reproduced on release v1.5.1 commit: 1306d14f8c26bb6150d3e6ad54f28dfe1007689e
  • OpenFHE configuration: NATIVE_SIZE=128, MATHBACKEND=4, WITH_OPENMP=OFF
  • Generated config observed locally: NATIVEINT=128, HAVE_INT128=TRUE
  • OS: Linux x86_64

Minimal reproduction

The following standalone program uses only public OpenFHE APIs and runs three cases: the failing boundary case plus two controls.

#include "openfhe.h"

#include <cmath>
#include <complex>
#include <iomanip>
#include <iostream>
#include <string>
#include <vector>

using namespace lbcrypto;

namespace {

struct CaseParams {
    const char* name;
    double real;
    double imag;
    uint32_t depth;
    uint32_t scalingModSize;
    uint32_t firstModSize;
    uint32_t batchSize;
};

void runCase(const CaseParams& c) {
    CryptoContextFactory<DCRTPoly>::ReleaseAllContexts();
    CryptoContextImpl<DCRTPoly>::ClearEvalMultKeys();
    CryptoContextImpl<DCRTPoly>::ClearEvalAutomorphismKeys();
    CryptoContextImpl<DCRTPoly>::ClearEvalSumKeys();

    CCParams<CryptoContextCKKSRNS> parameters;
    parameters.SetMultiplicativeDepth(c.depth);
    parameters.SetScalingTechnique(FIXEDAUTO);
    parameters.SetScalingModSize(c.scalingModSize);
    parameters.SetFirstModSize(c.firstModSize);
    parameters.SetBatchSize(c.batchSize);
    parameters.SetCKKSDataType(COMPLEX);

    auto cc = GenCryptoContext(parameters);
    cc->Enable(PKE);

    auto keys = cc->KeyGen();
    std::vector<std::complex<double>> input(c.batchSize, {c.real, c.imag});
    auto pt = cc->MakeCKKSPackedPlaintext(input, 1, 0, nullptr, c.batchSize);
    auto ct = cc->Encrypt(keys.publicKey, pt);

    Plaintext decoded;
    auto result = cc->Decrypt(keys.secretKey, ct, &decoded);
    decoded->SetLength(1);
    const auto& values = decoded->GetCKKSPackedValue();
    const double err = values.empty() ? std::nan("") : std::abs(values[0] - input[0]);

    std::cout << c.name
              << " decrypt_valid=" << (result.isValid ? "true" : "false")
              << " ring_dim=" << cc->GetRingDimension()
              << " decoded0=" << std::setprecision(17)
              << (values.empty() ? std::complex<double>{std::nan(""), std::nan("")} : values[0])
              << " max_abs_error=" << err << "\n";
}

}  // namespace

int main() {
    runCase({"real-boundary", 1048576.0, 0.0, 0, 88, 88, 1});
    runCase({"small-real-safe", 16.0, 0.0, 0, 80, 96, 1});
    runCase({"known-valid", 0.25, 0.125, 1, 50, 60, 8});
}

Representative local build command against an existing OpenFHE 128-bit build:

clang++ -std=gnu++17 main.cpp \
  -I/path/to/openfhe-development/src/core/include \
  -I/path/to/openfhe-development/src/pke/include \
  -I/path/to/openfhe-development/src/binfhe/include \
  -I/path/to/openfhe-development/third-party/cereal/include \
  -I/path/to/openfhe-build/src/core \
  -Wl,-rpath,/path/to/openfhe-build/lib \
  /path/to/openfhe-build/lib/libOPENFHEpke.so \
  /path/to/openfhe-build/lib/libOPENFHEcore.so \
  /path/to/openfhe-build/lib/libOPENFHEbinfhe.so \
  -ldl -o openfhe_ckks_real_boundary_probe

Run:

./openfhe_ckks_real_boundary_probe

Actual behavior

On current main commit ed361af22049007db2107e7c69bcff209e8c420d, the program prints:

real-boundary decrypt_valid=true ring_dim=8192 decoded0=(-5.1625404513301414e-15,7.1085833891275816e-26) max_abs_error=1048576
small-real-safe decrypt_valid=true ring_dim=8192 decoded0=(16,4.0035541647566539e-22) max_abs_error=4.0035541647566539e-22
known-valid decrypt_valid=true ring_dim=16384 decoded0=(0.25000000000281331,0.12499999999878758) max_abs_error=3.0634369257603773e-12

On v1.5.1, the same failing boundary case also decoded near zero:

real-boundary decrypt_valid=true ring_dim=8192 decoded0=(-5.1625404530749755e-15,-7.5932595292953712e-25) max_abs_error=1048576

So the boundary case does not throw, does not mark decryption invalid, and does not produce a normal CKKS approximation of the input. It returns a near-zero decoded first slot while the absolute error remains essentially the full input magnitude.

Expected behavior

If this input and parameter combination is supported, the decoded first slot should be close to (1048576, 0) up to ordinary CKKS approximation error.

If the combination is outside the supported CKKS range because the scaled plaintext coefficient does not fit the active plaintext modulus for the current level/tower set, the API should reject it explicitly or document the unsupported range. It should not appear to succeed while silently reducing the encoded coefficient modulo the active tower and then decoding an unrelated near-zero value.

Cause analysis

In the 128-bit CKKS encode path, OpenFHE computes the real and imaginary scaled coefficients into a temporary std::vector<int128_t> and checks only against a broad 128-bit conversion bound:

int64_t re64       = std::llround(dre);
int32_t pRemaining = pCurrent + n1;
int128_t re        = 0;
if (pRemaining < 0) {
    re = re64 >> (-pRemaining);
}
else {
    int128_t pPowRemaining = ((int128_t)1) << pRemaining;
    re                     = pPowRemaining * re64;
}
...
temp[i] = (re < 0) ? MaxBitValue + re : re;
...
if (is128BitOverflow(temp[i]) || is128BitOverflow(temp[i + slots])) {
    OPENFHE_THROW("Overflow, try to decrease scaling factor");
}

After that, each active tower is populated via FitToNativeVector(...):

FitToNativeVector(temp, MaxBitValue, &nativeVec);

and the 128-bit FitToNativeVector(...) implementation stores:

(*nativeVec)[gap * i] = n.Mod(modulus);

or the corresponding signed-path ModSub(...) residue, depending on the high-half test.

For the failing case, an auxiliary standalone diagnostic that mirrors the 128-bit real encode path reported:

scaled_coefficient=324518553658426726783156020576256
scaled_coefficient_msb=109
tower0_modulus=309485009821345068726304769
tower0_modulus_msb=89
modeled_real_residue=309485009821343470997422081
real_coefficient_exceeds_tower_modulus=true
recovered_real_coeff=309485009821343470997422081
signed_pre_fft_real=-1597728882688

So the computed real-side coefficient fits the broad 128-bit path, but is much larger than the active single-tower modulus. The value entering Decode(...) is the residue, not the intended coefficient.

Decode(...) then interprets the recovered coefficient relative to qHalf, converts it back to double, scales it, and runs the final FFT. That is consistent with the observed near-zero decoded slot.

Relationship to existing issues

This appears distinct from the previously reported 128-bit CKKS imaginary-branch shift issue fixed by PR #1237.

My reproducer is real-only, uses finite input (1048576, 0), and still reproduces on current main. The issue here is not a >= 64 shift expression in the imaginary path; it is the apparent lack of an active-tower range check before the encoded 128-bit coefficient is reduced modulo the active tower.

I also checked the current upstream issue tracker for obvious duplicates around CKKSPackedEncoding, FitToNativeVector, 128-bit CKKS encoding, and single-tower/depth-0 near-zero decode behavior, but did not find an obvious existing report for this exact issue.

Impact

The confirmed impact is a silent correctness failure in the public 128-bit CKKS API for an accepted parameter/input combination:

  • GenCryptoContext(...), MakeCKKSPackedPlaintext(...), Encrypt(...), and Decrypt(...) all return normally;
  • the decoded first slot is near zero instead of near the finite input value;
  • the boundary failure is input-dependent, while nearby control cases decode correctly.

The confirmed problem is silent wrong-result behavior or missing input-range validation in the 128-bit CKKS encode/decode path.

Relevant source locations

Current main commit ed361af22049007db2107e7c69bcff209e8c420d:

Suggested direction

If this range is intentionally unsupported, OpenFHE could reject it explicitly before the encoded 128-bit coefficient is reduced modulo the active tower.

If it is intended to be supported, the encode path likely needs an additional range check against the active plaintext modulus for the current level/tower set, rather than only the broad 128-bit conversion guard.

In either case, the public API should not silently return a successful encrypt/decrypt round trip for this combination while decoding an unrelated near-zero value.

Question

For 128-bit CKKS COMPLEX encoding, when the computed scaled plaintext coefficient fits the broad 128-bit conversion guard but exceeds the active plaintext modulus for the current level/tower set, is the intended contract:

  • reject the input or parameter combination;
  • document the range as unsupported; or
  • support it without silent modular wrap during FitToNativeVector(...)?