Bug: Sparsely packed EvalBootstrap quietly destroys the message for certain levelBudget[1] values without any exception or warning

This bugreport is already up as issue 1264 on github, but this website seems like the more appropriate place for it, so ill duplicate it here. If I am wrong, apologies for the double report :wink:
Version v1.5.1 release and dev @ acdaf5e; CPU backend,
macOS/clang, NATIVE_SIZE=64, FLEXIBLEAUTO, HYBRID, UNIFORM_TERNARY, ring 4096.

what

With sparse packing (64 out of 2048 slots), a correct (to 1.2e-14 immediately before) ciphertext comes back with O(1) error (~0.5 absolute on a 0.3 amplitude message) after EvalBootstrap for level budgets {4,4} and {5,5}, while {3,1}, {2,2}, {3,3}, {4,2}, {4,3}, {4,6} and {6,6} all return the expected ~5e-07. Fully packed ciphertexts are unaffected at every budget I tested.

which budgets fail

The trigger is the decoding budget levelbudget[1] alone, as levelbudget[0] does not demonstrate this behavior. It requires SelectLayers(logSlots, levelBudget[1]) (ckksrns-utils.cpp:55-77) to return rem != 0 — necessary but not sufficient, see below. For 64 slots (=> logSlots = 6), every rem != 0 budget does fail:

levelBudget {enc,dec} SelectLayers(6,dec) = {layers,rows,rem} max abs err after bootstrap
{3,1} {6,1,0} 5.887723e-07
{2,2} {3,2,0} 5.336707e-07
{3,3} {2,3,0} 3.912903e-07
{4,2} {3,2,0} 4.342297e-07
{4,3} {2,3,0} 4.820358e-07
{4,4} {1,3,3} 4.390237e-01
{2,4} {1,3,3} 4.556276e-01
{6,4} {1,3,3} 5.332023e-01
{4,6} {1,6,0} 4.689344e-07
{5,5} {1,4,2} 3.515473e-01
{6,6} {1,6,0} 4.954322e-07

The 6 decoding budgets allowed at logSlots = 6 are covered. The rule predicted {2,4} and {6,4} to fail and {4,6}, {3,1} to pass before they were run. EvalBootstrapSetup warns/clamps only when the budget is over logSlots (ckksrns-fhe.cpp:130-148).

At 256 slots (logSlots = 8, all 8 decoding budgets, enc fixed at 2) rem != 0 is not sufficient: dec 1, 2, 4, 8 (rem = 0) all clean at 1.19e-06 to 1.61e-06 and dec 5, 6, 7 (rem = 4, 3, 2) fail at 6.919277e-01, 5.906955e-01 and 4.644120e-01, but dec 3 (rem = 2) is clean at 1.385e-06. rem != 0 is required for the faulty block to run, but the message is destroyed only when the wrong stride also changes the rotation modulo slots. At 256/dec=3 it doesn’t — g=8, gRem=4, shiftScaleRem=64, so the correct 64 x 4 = 256 and the buggy 64 x 8 = 512 are both 0 mod 256.

reproduction (c11a_repro.cpp) encrypt 64 out of 2048 slots, apply 29 levels EvalMult by the all-ones plaintext encoded at the ciphertext’s own slot count (so no slot mismatch is involved and the expected result is the input), then EvalBootstrap and compare against the plaintext reference. ./c11a_repro 4 4 vs ./c11a_repro 3 3.
Build

c++ -std=c++17 -O2 c11a_repro.cpp -o c11a_repro \
    -I$P/include/openfhe -I$P/include/openfhe/core -I$P/include/openfhe/pke \
    -I$P/include/openfhe/binfhe -L$P/lib \
    -lOPENFHEpke -lOPENFHEcore -lOPENFHEbinfhe        # $P = install prefix

Run (with results on stock v1.5.1)

$ ./c11a_repro 4 4
sparse   slots=64    levelBudget={4,4}  err before boot = 1.225e-14   err AFTER boot = 6.150e-01   (towers 26 -> 33)
full     slots=2048  levelBudget={4,4}  err before boot = 1.053e-13   err AFTER boot = 3.480e-05   (towers 26 -> 33)
$ ./c11a_repro 3 3
sparse   slots=64    levelBudget={3,3}  err before boot = 1.094e-14   err AFTER boot = 6.102e-07   (towers 24 -> 33)
full     slots=2048  levelBudget={3,3}  err before boot = 1.185e-13   err AFTER boot = 2.767e-05   (towers 24 -> 33)
$ ./c11a_repro 2 4        # encoding budget changed, decoding budget still 4
sparse   slots=64    levelBudget={2,4}  err before boot = 1.521e-14   err AFTER boot = 7.057e-01   (towers 24 -> 33)
full     slots=2048  levelBudget={2,4}  err before boot = 1.311e-13   err AFTER boot = 3.325e-05   (towers 24 -> 33)

root cause

Issue #1112 - this is its’ unfixed half. PR #1113 corrected the divisor ij / p.gij / p.gRem in both branches of FHECKKSRNS::EvalSlotsToCoeffsPrecompute but left the multiplier. In the sparse branch’s flagRem == 1 block (v1.5.1 :1803, dev commit acdaf5e :2115) rotScale is still p.g even though the rotation divides by p.gRem (dev :2131, v1.5.1 :1817), so it comes out as -shiftScaleRem * p.g * (ij / p.gRem) instead of -shiftScaleRem * p.gRem * (ij / p.gRem). The full-packing branch (dev :2055, v1.5.1 :1749) uses p.gRem already, and the same sparse block’s limit is p.bRem * p.gRem. flagRem == 1 explains why at 64 slots this occurs only for decoding budgets 4 and 5, as 6 can’t be divided by them.

one-line fix for dev acdaf5e, tested

diff --git a/src/pke/lib/scheme/ckksrns/ckksrns-fhe.cpp b/src/pke/lib/scheme/ckksrns/ckksrns-fhe.cpp
--- a/src/pke/lib/scheme/ckksrns/ckksrns-fhe.cpp
+++ b/src/pke/lib/scheme/ckksrns/ckksrns-fhe.cpp
@@ -2112,7 +2112,7 @@ std::vector<std::vector<ReadOnlyPlaintext>> FHECKKSRNS::EvalSlotsToCoeffsPrecomp

         if (flagRem == 1) {
             const int32_t shiftScaleRem = 1 << (smax * p.layersCollapse);
-            const int32_t rotScale      = shiftScaleRem * static_cast<int32_t>(p.g);
+            const int32_t rotScale      = shiftScaleRem * static_cast<int32_t>(p.gRem);
             const uint32_t limit        = p.bRem * p.gRem;

After applying:

config before after
{4,4} 6.210330e-01 3.887558e-07
{5,5} 3.515473e-01 (v1.5.1) 5.317378e-07
{2,4} 4.556276e-01 (v1.5.1) 6.175081e-07
{3,3} (control, was passing) 4.916959e-07 5.794481e-07
full packing (control) 3.577487e-05 3.786088e-05
fresh sparse (control) 1.169898e-14 1.450923e-14

Reverting the patch and rebuilding brings the failure back (5.742682e-01).

I’ll happily open the PR if needed.