# \[Bug report\]BFV EvalAdd produces incorrect results when ciphertexts have mismatched DCRTPoly formats (EVALUATION vs COEFFICIENT)

**URL:** <https://openfhe.discourse.group/t/bug-report-bfv-evaladd-produces-incorrect-results-when-ciphertexts-have-mismatched-dcrtpoly-formats-evaluation-vs-coefficient/2304>\
**Category:** Library Questions\
**Tags:** bugs\
**Created:** [March 22, 2026, 7:36am UTC](https://openfhe.discourse.group/t/bug-report-bfv-evaladd-produces-incorrect-results-when-ciphertexts-have-mismatched-dcrtpoly-formats-evaluation-vs-coefficient/2304 "2026-03-22T07:36:30Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![WayneOnEarth](https://avatars.discourse-cdn.com/v4/letter/w/b2d939/32.png) [@WayneOnEarth](https://openfhe.discourse.group/u/WayneOnEarth)\
**Post date:** [March 22, 2026, 7:36am UTC](https://openfhe.discourse.group/t/bug-report-bfv-evaladd-produces-incorrect-results-when-ciphertexts-have-mismatched-dcrtpoly-formats-evaluation-vs-coefficient/2304/1 "2026-03-22T07:36:30Z")

</div>

# Summary

When using `EvalMultNoRelin` in BFV to produce a 3-element ciphertext (vital for lazy relinearization related research), the resulting ciphertext’s `DCRTPoly` elements are in **COEFFICIENT** format. However, freshly encrypted ciphertexts have elements in **EVALUATION** (NTT) format. Calling `EvalAdd` on these two ciphertexts — or even performing direct element-wise `operator+=` — produces a mathematically meaningless result because polynomial addition across different domains (NTT vs coefficient) is undefined.

This affects any BFV/BGV workflow that defers relinearization and then adds the unrelinearized ciphertext to a fresh or independently computed ciphertext.

## Environment

- **OpenFHE version** : 1.2.3, also reproducible on 1.4.2 (stable) and 1.5.0 (dev)

- **Scheme** : BFV (BFVrns)

- **OS** : Windows (MINGW64), but the issue is platform-independent

## Minimal Reproduction

```auto
#include "openfhe.h"
using namespace lbcrypto;
int main() {
    CCParams<CryptoContextBFVRNS> params;
    params.SetPlaintextModulus(65537);
    params.SetMultiplicativeDepth(4);
    auto cc = GenCryptoContext(params);
    cc->Enable(PKE);
    cc->Enable(LEVELEDSHE);
    auto keys = cc->KeyGen();
    cc->EvalMultKeyGen(keys.secretKey);
    uint32_t N = cc->GetRingDimension();
    auto ct_a = cc->Encrypt(keys.publicKey, cc->MakePackedPlaintext(std::vector<int64_t>(N, 100)));
    auto ct_b = cc->Encrypt(keys.publicKey, cc->MakePackedPlaintext(std::vector<int64_t>(N, 200)));
    auto ct_c = cc->Encrypt(keys.publicKey, cc->MakePackedPlaintext(std::vector<int64_t>(N, 333)));
    // 3-poly ciphertext from multiplication without relinearization
    auto ct_ab = cc->EvalMultNoRelin(ct_a, ct_b); // elements in COEFFICIENT format
    // Diagnostic: check formats
    std::cout << "ct_ab format: "
              << (ct_ab->GetElements()[0].GetFormat() == Format::EVALUATION ? "EVALUATION" : "COEFFICIENT")
              << std::endl; // prints COEFFICIENT
    std::cout << "ct_c format: "
              << (ct_c->GetElements()[0].GetFormat() == Format::EVALUATION ? "EVALUATION" : "COEFFICIENT")
              << std::endl; // prints EVALUATION
    // This EvalAdd mixes COEFFICIENT + EVALUATION domains → silent corruption
    auto ct_sum = ct_ab->Clone();
    auto& elems = ct_sum->GetElements();
    const auto& cElems = ct_c->GetElements();
    elems[0] += cElems[0]; // COEFFICIENT += EVALUATION → meaningless
    elems[1] += cElems[1];
    // Decrypt: expect 20333, get garbage
    Plaintext result;
    cc->Decrypt(keys.secretKey, ct_sum, &result);
    std::cout << "Expected: 20333, Got: " << result->GetPackedValue()[0] << std::endl;
    // Fix: align format before addition
    auto ct_c_copy = ct_c->Clone();
    auto& fixElems = ct_c_copy->GetElements();
    for (auto& e : fixElems) {
        e.SetFormat(ct_ab->GetElements()[0].GetFormat()); // EVALUATION → COEFFICIENT
    }

    auto ct_sum_fixed = ct_ab->Clone();
    auto& fixedR = ct_sum_fixed->GetElements();
    fixedR[0] += fixElems[0];
    fixedR[1] += fixElems[1];
    Plaintext result2;
    cc->Decrypt(keys.secretKey, ct_sum_fixed, &result2);
    std::cout << "After format fix: " << result2->GetPackedValue()[0] << std::endl; // prints 20333
    return 0;

}

```

**Expected output** : `20333`  
**Actual output** : Garbage value (e.g., `1934`, varies per run)  
**After manual format alignment** : `20333` (correct)

## Diagnostic Results

A comprehensive diagnostic test was conducted:

| Test | Operation | Result |
| --- | --- | --- |
| 1 | Decrypt 3-poly `ct_ab` directly (baseline) | **PASS** (20000) |
| 2 | ManualAdd (no format align) → Decrypt 3-poly | **FAIL** (1934) |
| 3 | ManualAdd (no format align) → Relin → Decrypt | **FAIL** (-13067) |
| 4 | ManualAdd **WITH format align** → Decrypt 3-poly | **PASS** (20333) |
| 5 | ManualAdd **WITH format align** → Relin → Decrypt | **PASS** (20333) |
| 6 | ctOne fix `EvalMultNoRelin(ct_c, enc(1))` (control) | **PASS** (20333) |
| 7 | Relin both sides first → `EvalAdd` 2+2 | **PASS** (20333) |

**Key finding from format inspection:**

```auto
ct_ab[0] format: COEFFICIENT
ct_c[0] format: EVALUATION
ct_ab num primes: 4
ct_c num primes: 4
All CRT moduli match

```

The CRT moduli are identical, but the polynomial representation formats differ. This is the root cause. Tests 4-5 confirm that aligning the format before addition completely resolves the issue.

## Root Cause Analysis

### Exact location: `base-leveledshe.cpp`, lines 574–590

```auto
// line 574
void LeveledSHEBase<Element>::EvalAddCoreInPlace(Ciphertext<Element>& ciphertext1,
                                                 ConstCiphertext<Element>& ciphertext2) const {
    VerifyNumOfTowers(ciphertext1, ciphertext2);
    auto& cv1 = ciphertext1->GetElements();
    auto& cv2 = ciphertext2->GetElements();
    uint32_t c1Size = cv1.size();
    uint32_t c2Size = cv2.size();
    uint32_t cSmallSize = std::min(c1Size, c2Size);
    cv1.reserve(c2Size);
    uint32_t i = 0;
    for (; i < cSmallSize; ++i)
        cv1[i] += cv2[i]; // ← line 587: NO format check before +=
    for (; i < c2Size; ++i)
        cv1.emplace_back(cv2[i]);
}

```

Line 587 calls `cv1[i] += cv2[i]` without checking or unifying the `Format` of the two `DCRTPoly` operands. When `cv1[i]` is in COEFFICIENT format (from `EvalMultNoRelin`) and `cv2[i]` is in EVALUATION format (from fresh encryption), this produces a mathematically meaningless result.

### The same bug exists in `EvalSubCoreInPlace` (lines 600–617)

Line 613: `cv1[i] -= cv2[i]` has the identical issue.

### Notably, the plaintext version already handles this correctly

In the **same file** , `EvalAddInPlace` for **plaintext** addition (lines 90–95) does align the format:

```auto
// line 90
void LeveledSHEBase<Element>::EvalAddInPlace(Ciphertext<Element>& ciphertext, ConstPlaintext& plaintext) const {
    auto& cv = ciphertext->GetElements();
    auto pt = plaintext->GetElement<Element>();
    pt.SetFormat(cv[0].GetFormat()); // ← line 93: FORMAT ALIGNMENT EXISTS HERE
    cv[0] += pt;
}

```

The ciphertext-plaintext path calls `SetFormat()` to unify the representation. The ciphertext-ciphertext path (`EvalAddCoreInPlace`) does not. This inconsistency strongly suggests the omission was unintentional.

### Why this bug is rarely triggered

In the standard workflow, `EvalMult` (not `EvalMultNoRelin`) automatically calls `Relinearize`, which involves key-switching operations that convert elements back to EVALUATION format. Users almost never encounter mixed-format ciphertexts unless they explicitly use `EvalMultNoRelin` for lazy relinearization — a legitimate and well-documented optimization strategy.

## Suggested Fix

Add format unification in `EvalAddCoreInPlace` before element-wise addition, consistent with the existing plaintext version:

```auto
void LeveledSHEBase<Element>::EvalAddCoreInPlace(Ciphertext<Element>& ciphertext1,
                                                 ConstCiphertext<Element>& ciphertext2) const {
    VerifyNumOfTowers(ciphertext1, ciphertext2);
    auto& cv1 = ciphertext1->GetElements();
    auto& cv2 = ciphertext2->GetElements();
    // Unify format (consistent with plaintext EvalAddInPlace at line 93)
    Format format = cv1[0].GetFormat();
    uint32_t c1Size = cv1.size();
    uint32_t c2Size = cv2.size();
    uint32_t cSmallSize = std::min(c1Size, c2Size);
    cv1.reserve(c2Size);
    uint32_t i = 0;
    for (; i < cSmallSize; ++i) {
        auto tmp = cv2[i];
        tmp.SetFormat(format);
        cv1[i] += tmp;
    }
    for (; i < c2Size; ++i) {
        cv1.emplace_back(cv2[i]);
        cv1.back().SetFormat(format);
    }
}

```

The same fix should be applied to `EvalSubCoreInPlace` (lines 600–617).

Alternatively, a lighter-weight fix would be to add a format check with a clear error message, alerting users to the mismatch rather than silently producing incorrect results.

## Current Workarounds

1. **Always relinearize before adding** ciphertexts of different origins (defeats the purpose of lazy relinearization)

2. **Multiply by `enc(1)`** before adding: `EvalMultNoRelin(ct_fresh, ct_one)` forces the fresh ciphertext through the multiplication pipeline, aligning its format (expensive: costs one full ciphertext multiplication)

3. **Manual format conversion** before addition:

```auto
auto ct_c_copy = ct_c->Clone();
auto& elems = ct_c_copy->GetElements();
Format targetFormat = ct_ab->GetElements()[0].GetFormat();
for (auto& e : elems) {
    e.SetFormat(targetFormat);
}
// Now element-wise addition is safe

```

Workaround 3 is the most efficient (cost: one NTT/INTT per element).

## Impact

This bug affects any research or application that implements **lazy (deferred) relinearization** in BFV — a well-known optimization that reduces the number of expensive relinearization operations by deferring them until necessary. The bug makes it impossible to add an unrelinearized ciphertext to a fresh ciphertext through the standard API without a workaround.

## Full Diagnostic Code

A self-contained diagnostic test file is attached below. It can be compiled against OpenFHE and run directly to reproduce all results described above.

> **diag\_test.cpp (click to expand)**
>
> ```auto
> // ============================================================
> // BFV Mixed-Source Ciphertext Addition Diagnostic Test
> // ============================================================
> 
> #include "openfhe.h"
> #include <iostream>
> #include <iomanip>
> using namespace lbcrypto;
> using namespace std;
> using CT = Ciphertext<DCRTPoly>;
> void printCtInfo(const string& name, const CT& ct) {
> cout << " " << name << ": polys=" << ct->GetElements().size()
> << " level=" << ct->GetLevel()
> << " noiseScaleDeg=" << ct->GetNoiseScaleDeg()
> << " towers=" << ct->GetElements()[0].GetNumOfElements()
> << " format=" << (ct->GetElements()[0].GetFormat() == Format::EVALUATION ? "EVAL" : "COEFF")
> << endl;
> }
> 
> CT ManualAdd(const CT& ct_big, const CT& ct_small) {
> auto result = ct_big->Clone();
> auto& rElems = result->GetElements();
> const auto& sElems = ct_small->GetElements();
> for (size_t i = 0; i < sElems.size(); i++) {
> rElems[i] += sElems[i];
> }
> return result;
> }
> 
> CT ManualAddWithFormatAlign(const CT& ct_big, const CT& ct_small) {
> auto result = ct_big->Clone();
> auto& rElems = result->GetElements();
> Format targetFormat = rElems[0].GetFormat();
> auto ct_small_copy = ct_small->Clone();
> auto& sElems = ct_small_copy->GetElements();
> for (auto& e : sElems) {
> e.SetFormat(targetFormat);
> }
> for (size_t i = 0; i < sElems.size(); i++) {
> rElems[i] += sElems[i];
> }
> return result;
> }
> 
> int main() {
> cout << "============================================" << endl;
> cout << " BFV Mixed-Source Addition Diagnostic Test" << endl;
> cout << "============================================" << endl;
> CCParams<CryptoContextBFVRNS> params;
> params.SetPlaintextModulus(65537);
> params.SetMultiplicativeDepth(4);
> CryptoContext<DCRTPoly> cc = GenCryptoContext(params);
> cc->Enable(PKE);
> cc->Enable(LEVELEDSHE);
> auto keys = cc->KeyGen();
> cc->EvalMultKeyGen(keys.secretKey);
> uint32_t N = cc->GetRingDimension();
> cout << "Ring dimension: " << N << endl;
> int64_t val_a = 100, val_b = 200, val_c = 333;
> int64_t expected_ab = val_a \* val_b;
> int64_t expected_sum = val_a \* val_b + val_c;
> auto ct_a = cc->Encrypt(keys.publicKey, cc->MakePackedPlaintext(vector<int64_t>(N, val_a)));
> auto ct_b = cc->Encrypt(keys.publicKey, cc->MakePackedPlaintext(vector<int64_t>(N, val_b)));
> auto ct_c = cc->Encrypt(keys.publicKey, cc->MakePackedPlaintext(vector<int64_t>(N, val_c)));
> auto ct_one = cc->Encrypt(keys.publicKey, cc->MakePackedPlaintext(vector<int64_t>(N, 1)));
> auto ct_ab = cc->EvalMultNoRelin(ct_a, ct_b);
> cout << "expected a\*b = " << expected_ab << ", expected a\*b+c = " << expected_sum << endl;
> printCtInfo("ct_ab (noRelin)", ct_ab);
> printCtInfo("ct_c (fresh)", ct_c);
> cout << endl;
> Plaintext ptResult;
> int numTests = 0, numPass = 0;
> auto check = [&](const string& name, int64_t expected) {
> numTests++;
> int64_t got = ptResult->GetPackedValue()[0];
> bool pass = (got == expected);
> if (pass) numPass++;
> cout << " Slot[0] = " << got << (pass ? " PASS" : " FAIL")
> << " (expected " << expected << ")" << endl << endl;
> };
> // Test 1: Baseline
> cout << "=== Test 1: Decrypt 3-poly ct_ab directly ===" << endl;
> cc->Decrypt(keys.secretKey, ct_ab, &ptResult);
> check("baseline", expected_ab);
> // Test 2: ManualAdd without format alignment -> Decrypt 3-poly
> cout << "=== Test 2: ManualAdd (NO format align) -> Decrypt 3-poly ===" << endl;
> { auto s = ManualAdd(ct_ab, ct_c);
> cc->Decrypt(keys.secretKey, s, &ptResult);
> check("ManualAdd raw", expected_sum); }
> 
> // Test 3: ManualAdd without format alignment -> Relin -> Decrypt
> cout << "=== Test 3: ManualAdd (NO format align) -> Relin -> Decrypt ===" << endl;
> { auto s = ManualAdd(ct_ab, ct_c);
> cc->Decrypt(keys.secretKey, cc->Relinearize(s), &ptResult);
> check("ManualAdd+Relin", expected_sum); }
> 
> // Test 4: ManualAdd WITH format alignment -> Decrypt 3-poly
> cout << "=== Test 4: ManualAdd (WITH format align) -> Decrypt 3-poly ===" << endl;
> { auto s = ManualAddWithFormatAlign(ct_ab, ct_c);
> cc->Decrypt(keys.secretKey, s, &ptResult);
> check("FormatAlign direct", expected_sum); }
> 
> // Test 5: ManualAdd WITH format alignment -> Relin -> Decrypt
> cout << "=== Test 5: ManualAdd (WITH format align) -> Relin -> Decrypt ===" << endl;
> { auto s = ManualAddWithFormatAlign(ct_ab, ct_c);
> cc->Decrypt(keys.secretKey, cc->Relinearize(s), &ptResult);
> check("FormatAlign+Relin", expected_sum); }
> 
> // Test 6: ctOne fix (control)
> cout << "=== Test 6: ctOne fix (control) ===" << endl;
> { auto cv = cc->EvalMultNoRelin(ct_c, ct_one);
> auto s = cc->EvalAdd(ct_ab, cv);
> cc->Decrypt(keys.secretKey, cc->Relinearize(s), &ptResult);
> check("ctOne fix", expected_sum); }
> 
> // Test 7: Relin first, EvalAdd 2+2
> cout << "=== Test 7: Relin first, EvalAdd 2+2 ===" << endl;
> { auto s = cc->EvalAdd(cc->Relinearize(ct_ab), ct_c);
> cc->Decrypt(keys.secretKey, s, &ptResult);
> check("Relin-first", expected_sum); }
> 
> // Format diagnostic
> cout << "=== Format Diagnostic ===" << endl;
> { const auto& abE = ct_ab->GetElements();
> const auto& cE = ct_c->GetElements();
> cout << " ct_ab[0] format: " << (abE[0].GetFormat()==Format::EVALUATION?"EVALUATION":"COEFFICIENT") << endl;
> cout << " ct_c[0] format: " << (cE[0].GetFormat()==Format::EVALUATION?"EVALUATION":"COEFFICIENT") << endl;
> auto abP = abE[0].GetParams(); auto cP = cE[0].GetParams();
> cout << " ct_ab primes: " << abP->GetParams().size()
> << ", ct_c primes: " << cP->GetParams().size() << endl;
> bool same = true;
> for (size_t i = 0; i < min(abP->GetParams().size(), cP->GetParams().size()); i++)
> if (abP->GetParams()[i]->GetModulus() != cP->GetParams()[i]->GetModulus()) same = false;
> cout << " CRT moduli: " << (same ? "ALL MATCH" : "MISMATCH") << endl; }
> 
> cout << "============================================" << endl;
> cout << " SUMMARY: " << numPass << "/" << numTests << " passed" << endl;
> cout << "============================================" << endl;
> return 0;
> }
> 
> ```

* * *

I am happy to submit a PR for the fix if the team agrees on the approach. I see at least two possible directions:

- **(A)** Add format unification in `EvalAddCoreInPlace` / `EvalSubCoreInPlace`, consistent with the existing plaintext addition path at line 93.

- **(B)** Ensure that `EvalMultCore` (and the BFV-specific multiplication paths) output ciphertexts in EVALUATION format, resolving the mismatch at the source.

I’d appreciate guidance on which approach is preferred.

Reported by Zhongqi Wang from University of Tsukuba

---

<div class="post-metadata">

**Author:** ![ypolyakov](https://yyz1.discourse-cdn.com/flex031/user_avatar/openfhe.discourse.group/ypolyakov/32/47_2.png) [@ypolyakov](https://openfhe.discourse.group/u/ypolyakov)\
**Post date:** [March 25, 2026, 4:56pm UTC](https://openfhe.discourse.group/t/bug-report-bfv-evaladd-produces-incorrect-results-when-ciphertexts-have-mismatched-dcrtpoly-formats-evaluation-vs-coefficient/2304/2 "2026-03-25T16:56:00Z")

</div>

@WayneOnEarth Thank you for reporting the bug. We’ve created an issue for it: [Some of BFV EvalAdd/Sub\* procedures produce incorrect results when ciphertexts have mismatched DCRTPoly formats (EVALUATION vs COEFFICIENT) · Issue #1147 · openfheorg/openfhe-development · GitHub](https://github.com/openfheorg/openfhe-development/issues/1147) The fix will be more involved than the fixes proposed here. We will incorporate the fix in v1.5.1.
