// Copyright (c) 2025, Lux Industries Inc // SPDX-License-Identifier: BSD-3-Clause package fhe import ( "fmt" "github.com/luxfi/lattice/v7/core/rgsw/blindrot" "github.com/luxfi/lattice/v7/core/rlwe" ) // ========== Comparison Operations ========== // Eq returns 1 if a == b, 0 otherwise func (eval *IntegerEvaluator) Eq(a, b *RadixCiphertext) (*RadixCiphertext, error) { if a.fheType != b.fheType { return nil, fmt.Errorf("type mismatch: %s vs %s", a.fheType, b.fheType) } // Compare each block, AND all results numBlocks := len(a.blocks) var result *Ciphertext for i := 0; i < numBlocks; i++ { // Check if blocks are equal using XOR and NOT // a[i] == b[i] iff (a[i] XOR b[i]) == 0 xored, err := eval.xorBlocks(a.blocks[i], b.blocks[i]) if err != nil { return nil, fmt.Errorf("block %d xor: %w", i, err) } // Check if xor result is zero isZero, err := eval.isZeroBlock(xored) if err != nil { return nil, fmt.Errorf("block %d isZero: %w", i, err) } if result == nil { result = isZero } else { // AND with previous result result, err = eval.boolEval.AND(result, isZero) if err != nil { return nil, err } } } // Convert boolean result to RadixCiphertext return eval.boolToRadix(result, FheBool) } // Ne returns 1 if a != b, 0 otherwise func (eval *IntegerEvaluator) Ne(a, b *RadixCiphertext) (*RadixCiphertext, error) { eq, err := eval.Eq(a, b) if err != nil { return nil, err } return eval.Not(eq) } // Lt returns 1 if a < b, 0 otherwise (unsigned comparison) func (eval *IntegerEvaluator) Lt(a, b *RadixCiphertext) (*RadixCiphertext, error) { if a.fheType != b.fheType { return nil, fmt.Errorf("type mismatch: %s vs %s", a.fheType, b.fheType) } // Compare from MSB to LSB // a < b iff there exists i such that a[i] < b[i] and for all j > i, a[j] == b[j] numBlocks := len(a.blocks) var isLess *Ciphertext // Accumulated "definitely less" flag var isEqual *Ciphertext // Accumulated "still equal" flag // Start from MSB for i := numBlocks - 1; i >= 0; i-- { blockLt, err := eval.blockLt(a.blocks[i], b.blocks[i]) if err != nil { return nil, fmt.Errorf("block %d lt: %w", i, err) } blockEq, err := eval.blockEq(a.blocks[i], b.blocks[i]) if err != nil { return nil, fmt.Errorf("block %d eq: %w", i, err) } if isLess == nil { isLess = blockLt isEqual = blockEq } else { // isLess = isLess OR (isEqual AND blockLt) eqAndLt, err := eval.boolEval.AND(isEqual, blockLt) if err != nil { return nil, err } isLess, err = eval.boolEval.OR(isLess, eqAndLt) if err != nil { return nil, err } // isEqual = isEqual AND blockEq isEqual, err = eval.boolEval.AND(isEqual, blockEq) if err != nil { return nil, err } } } return eval.boolToRadix(isLess, FheBool) } // Le returns 1 if a <= b, 0 otherwise func (eval *IntegerEvaluator) Le(a, b *RadixCiphertext) (*RadixCiphertext, error) { gt, err := eval.Gt(a, b) if err != nil { return nil, err } return eval.Not(gt) } // Gt returns 1 if a > b, 0 otherwise func (eval *IntegerEvaluator) Gt(a, b *RadixCiphertext) (*RadixCiphertext, error) { // a > b iff b < a return eval.Lt(b, a) } // Ge returns 1 if a >= b, 0 otherwise func (eval *IntegerEvaluator) Ge(a, b *RadixCiphertext) (*RadixCiphertext, error) { lt, err := eval.Lt(a, b) if err != nil { return nil, err } return eval.Not(lt) } // Min returns the minimum of a and b func (eval *IntegerEvaluator) Min(a, b *RadixCiphertext) (*RadixCiphertext, error) { // min(a, b) = a < b ? a : b isLt, err := eval.Lt(a, b) if err != nil { return nil, err } return eval.Select(isLt, a, b) } // Max returns the maximum of a and b func (eval *IntegerEvaluator) Max(a, b *RadixCiphertext) (*RadixCiphertext, error) { // max(a, b) = a > b ? a : b isGt, err := eval.Gt(a, b) if err != nil { return nil, err } return eval.Select(isGt, a, b) } // ========== Bitwise Operations ========== // And performs bitwise AND on two radix integers func (eval *IntegerEvaluator) And(a, b *RadixCiphertext) (*RadixCiphertext, error) { if a.fheType != b.fheType { return nil, fmt.Errorf("type mismatch: %s vs %s", a.fheType, b.fheType) } numBlocks := len(a.blocks) resultBlocks := make([]*ShortInt, numBlocks) for i := 0; i < numBlocks; i++ { anded, err := eval.andBlocks(a.blocks[i], b.blocks[i]) if err != nil { return nil, fmt.Errorf("block %d: %w", i, err) } resultBlocks[i] = anded } return &RadixCiphertext{ blocks: resultBlocks, blockBits: a.blockBits, numBlocks: numBlocks, fheType: a.fheType, }, nil } // Or performs bitwise OR on two radix integers func (eval *IntegerEvaluator) Or(a, b *RadixCiphertext) (*RadixCiphertext, error) { if a.fheType != b.fheType { return nil, fmt.Errorf("type mismatch: %s vs %s", a.fheType, b.fheType) } numBlocks := len(a.blocks) resultBlocks := make([]*ShortInt, numBlocks) for i := 0; i < numBlocks; i++ { ored, err := eval.orBlocks(a.blocks[i], b.blocks[i]) if err != nil { return nil, fmt.Errorf("block %d: %w", i, err) } resultBlocks[i] = ored } return &RadixCiphertext{ blocks: resultBlocks, blockBits: a.blockBits, numBlocks: numBlocks, fheType: a.fheType, }, nil } // Xor performs bitwise XOR on two radix integers func (eval *IntegerEvaluator) Xor(a, b *RadixCiphertext) (*RadixCiphertext, error) { if a.fheType != b.fheType { return nil, fmt.Errorf("type mismatch: %s vs %s", a.fheType, b.fheType) } numBlocks := len(a.blocks) resultBlocks := make([]*ShortInt, numBlocks) for i := 0; i < numBlocks; i++ { xored, err := eval.xorBlocks(a.blocks[i], b.blocks[i]) if err != nil { return nil, fmt.Errorf("block %d: %w", i, err) } resultBlocks[i] = xored } return &RadixCiphertext{ blocks: resultBlocks, blockBits: a.blockBits, numBlocks: numBlocks, fheType: a.fheType, }, nil } // Not performs bitwise NOT on a radix integer func (eval *IntegerEvaluator) Not(a *RadixCiphertext) (*RadixCiphertext, error) { numBlocks := len(a.blocks) resultBlocks := make([]*ShortInt, numBlocks) for i := 0; i < numBlocks; i++ { notted, err := eval.notBlock(a.blocks[i]) if err != nil { return nil, fmt.Errorf("block %d: %w", i, err) } resultBlocks[i] = notted } return &RadixCiphertext{ blocks: resultBlocks, blockBits: a.blockBits, numBlocks: numBlocks, fheType: a.fheType, }, nil } // ========== Shift Operations ========== // Shl performs left shift by a scalar amount func (eval *IntegerEvaluator) Shl(a *RadixCiphertext, shift int) (*RadixCiphertext, error) { if shift < 0 { return nil, fmt.Errorf("negative shift amount: %d", shift) } if shift == 0 { return eval.copy(a), nil } totalBits := a.NumBits() if shift >= totalBits { // Shift by more than width returns 0 return eval.zeroRadix(a.fheType) } // Calculate block-level and intra-block shifts blockShift := shift / a.blockBits bitShift := shift % a.blockBits numBlocks := len(a.blocks) resultBlocks := make([]*ShortInt, numBlocks) // Initialize lower blocks to zero for i := 0; i < blockShift && i < numBlocks; i++ { zero, err := eval.shortEval.ScalarAdd(a.blocks[0], 0) if err != nil { return nil, err } // Actually encrypt 0 resultBlocks[i] = zero } // Shift remaining blocks for i := blockShift; i < numBlocks; i++ { srcIdx := i - blockShift if bitShift == 0 { resultBlocks[i] = &ShortInt{ ct: a.blocks[srcIdx].ct.CopyNew(), msgBits: a.blocks[srcIdx].msgBits, msgSpace: a.blocks[srcIdx].msgSpace, } } else { // Need intra-block shift with carry from lower block shifted, err := eval.shortEval.ScalarMul(a.blocks[srcIdx], 1<= totalBits { return eval.zeroRadix(a.fheType) } blockShift := shift / a.blockBits numBlocks := len(a.blocks) resultBlocks := make([]*ShortInt, numBlocks) // Shift blocks down for i := 0; i < numBlocks-blockShift; i++ { srcIdx := i + blockShift resultBlocks[i] = &ShortInt{ ct: a.blocks[srcIdx].ct.CopyNew(), msgBits: a.blocks[srcIdx].msgBits, msgSpace: a.blocks[srcIdx].msgSpace, } } // Zero upper blocks for i := numBlocks - blockShift; i < numBlocks; i++ { zero, _ := eval.shortEval.ScalarMul(a.blocks[0], 0) resultBlocks[i] = zero } return &RadixCiphertext{ blocks: resultBlocks, blockBits: a.blockBits, numBlocks: numBlocks, fheType: a.fheType, }, nil } // ========== Conditional Selection ========== // Select returns a if condition is true, b otherwise // condition should be an encrypted boolean (RadixCiphertext with FheBool type) func (eval *IntegerEvaluator) Select(cond, a, b *RadixCiphertext) (*RadixCiphertext, error) { if a.fheType != b.fheType { return nil, fmt.Errorf("type mismatch: %s vs %s", a.fheType, b.fheType) } // Get condition as boolean ciphertext if len(cond.blocks) == 0 { return nil, fmt.Errorf("empty condition") } condBool := &Ciphertext{cond.blocks[0].ct} numBlocks := len(a.blocks) resultBlocks := make([]*ShortInt, numBlocks) for i := 0; i < numBlocks; i++ { selected, err := eval.selectBlock(condBool, a.blocks[i], b.blocks[i]) if err != nil { return nil, fmt.Errorf("block %d: %w", i, err) } resultBlocks[i] = selected } return &RadixCiphertext{ blocks: resultBlocks, blockBits: a.blockBits, numBlocks: numBlocks, fheType: a.fheType, }, nil } // ========== Helper Functions ========== // xorBlocks XORs two shortint blocks func (eval *IntegerEvaluator) xorBlocks(a, b *ShortInt) (*ShortInt, error) { // Use LUT for XOR on each possible pair msgSpace := a.msgSpace scale := rlwe.NewScale(float64(eval.params.fheParams.QBR()) / float64(2*msgSpace*msgSpace)) // Create XOR LUT (depends on both a and b encoded in single input) // This is a simplified approach - proper implementation would use tensor product sum := eval.shortEval.addCiphertexts(a.ct, b.ct) xorLUT := blindrot.InitTestPolynomial(func(x float64) float64 { // Decode a and b from sum combined := int((x + 1) * float64(msgSpace*msgSpace) / 2) aVal := combined / msgSpace bVal := combined % msgSpace result := aVal ^ bVal return float64(result)*2/float64(msgSpace) - 1 }, scale, eval.shortEval.ringQBR, -1, 1) resultCt, err := eval.shortEval.bootstrap(sum, &xorLUT) if err != nil { return nil, err } return &ShortInt{ ct: resultCt, msgBits: a.msgBits, msgSpace: a.msgSpace, }, nil } // andBlocks ANDs two shortint blocks func (eval *IntegerEvaluator) andBlocks(a, b *ShortInt) (*ShortInt, error) { msgSpace := a.msgSpace scale := rlwe.NewScale(float64(eval.params.fheParams.QBR()) / float64(2*msgSpace*msgSpace)) sum := eval.shortEval.addCiphertexts(a.ct, b.ct) andLUT := blindrot.InitTestPolynomial(func(x float64) float64 { combined := int((x + 1) * float64(msgSpace*msgSpace) / 2) aVal := combined / msgSpace bVal := combined % msgSpace result := aVal & bVal return float64(result)*2/float64(msgSpace) - 1 }, scale, eval.shortEval.ringQBR, -1, 1) resultCt, err := eval.shortEval.bootstrap(sum, &andLUT) if err != nil { return nil, err } return &ShortInt{ ct: resultCt, msgBits: a.msgBits, msgSpace: a.msgSpace, }, nil } // orBlocks ORs two shortint blocks func (eval *IntegerEvaluator) orBlocks(a, b *ShortInt) (*ShortInt, error) { msgSpace := a.msgSpace scale := rlwe.NewScale(float64(eval.params.fheParams.QBR()) / float64(2*msgSpace*msgSpace)) sum := eval.shortEval.addCiphertexts(a.ct, b.ct) orLUT := blindrot.InitTestPolynomial(func(x float64) float64 { combined := int((x + 1) * float64(msgSpace*msgSpace) / 2) aVal := combined / msgSpace bVal := combined % msgSpace result := aVal | bVal return float64(result)*2/float64(msgSpace) - 1 }, scale, eval.shortEval.ringQBR, -1, 1) resultCt, err := eval.shortEval.bootstrap(sum, &orLUT) if err != nil { return nil, err } return &ShortInt{ ct: resultCt, msgBits: a.msgBits, msgSpace: a.msgSpace, }, nil } // notBlock performs bitwise NOT on a shortint block func (eval *IntegerEvaluator) notBlock(a *ShortInt) (*ShortInt, error) { msgSpace := a.msgSpace mask := msgSpace - 1 // NOT via LUT scale := rlwe.NewScale(float64(eval.params.fheParams.QBR()) / float64(2*msgSpace)) notLUT := blindrot.InitTestPolynomial(func(x float64) float64 { val := int((x + 1) * float64(msgSpace) / 2) if val >= msgSpace { val = msgSpace - 1 } result := (^val) & mask return float64(result)*2/float64(msgSpace) - 1 }, scale, eval.shortEval.ringQBR, -1, 1) resultCt, err := eval.shortEval.bootstrap(a.ct, ¬LUT) if err != nil { return nil, err } return &ShortInt{ ct: resultCt, msgBits: a.msgBits, msgSpace: a.msgSpace, }, nil } // isZeroBlock returns 1 if block is 0, else 0 func (eval *IntegerEvaluator) isZeroBlock(a *ShortInt) (*Ciphertext, error) { msgSpace := a.msgSpace scale := rlwe.NewScale(float64(eval.params.fheParams.QBR()) / 8.0) isZeroLUT := blindrot.InitTestPolynomial(func(x float64) float64 { val := int((x + 1) * float64(msgSpace) / 2) if val == 0 { return 1.0 } return -1.0 }, scale, eval.shortEval.ringQBR, -1, 1) resultCt, err := eval.shortEval.bootstrap(a.ct, &isZeroLUT) if err != nil { return nil, err } return &Ciphertext{resultCt}, nil } // blockLt returns 1 if a < b, else 0 (for single blocks) func (eval *IntegerEvaluator) blockLt(a, b *ShortInt) (*Ciphertext, error) { msgSpace := a.msgSpace scale := rlwe.NewScale(float64(eval.params.fheParams.QBR()) / float64(2*msgSpace*msgSpace)) sum := eval.shortEval.addCiphertexts(a.ct, b.ct) ltLUT := blindrot.InitTestPolynomial(func(x float64) float64 { combined := int((x + 1) * float64(msgSpace*msgSpace) / 2) aVal := combined / msgSpace bVal := combined % msgSpace if aVal < bVal { return 1.0 } return -1.0 }, scale, eval.shortEval.ringQBR, -1, 1) resultCt, err := eval.shortEval.bootstrap(sum, <LUT) if err != nil { return nil, err } return &Ciphertext{resultCt}, nil } // blockEq returns 1 if a == b, else 0 (for single blocks) func (eval *IntegerEvaluator) blockEq(a, b *ShortInt) (*Ciphertext, error) { msgSpace := a.msgSpace scale := rlwe.NewScale(float64(eval.params.fheParams.QBR()) / float64(2*msgSpace*msgSpace)) sum := eval.shortEval.addCiphertexts(a.ct, b.ct) eqLUT := blindrot.InitTestPolynomial(func(x float64) float64 { combined := int((x + 1) * float64(msgSpace*msgSpace) / 2) aVal := combined / msgSpace bVal := combined % msgSpace if aVal == bVal { return 1.0 } return -1.0 }, scale, eval.shortEval.ringQBR, -1, 1) resultCt, err := eval.shortEval.bootstrap(sum, &eqLUT) if err != nil { return nil, err } return &Ciphertext{resultCt}, nil } // selectBlock selects between two blocks based on condition using bivariate LUT // Implements: result = cond ? a : b // Uses tensor product encoding: combines cond, a, and b into a trivariate LUT // The condition is boolean (-1/+1 encoding), a and b are ShortInts. func (eval *IntegerEvaluator) selectBlock(cond *Ciphertext, a, b *ShortInt) (*ShortInt, error) { msgSpace := a.msgSpace // Strategy: Use two bivariate LUTs and combine // 1. Compute cond * a using bivariate LUT (result is a if cond=1, 0 if cond=0) // 2. Compute (1-cond) * b using bivariate LUT (result is b if cond=0, 0 if cond=1) // 3. Add results: cond*a + (1-cond)*b = MUX(cond, a, b) // For efficiency, we use a trivariate approach by encoding cond and one operand // together, then evaluating with the other operand. // Approach: Create bivariate LUT where we combine cond with a // cond is boolean: -Q/8 (false) or +Q/8 (true) // a is shortint: [0, msgSpace) encoded as [0, Q/(2*msgSpace) * msgSpace) // First, compute condVal * a using bivariate LUT // Scale: cond uses Q/8 encoding, a uses Q/(2*msgSpace) encoding // Combined encoding: we add them and use a bivariate LUT scale := rlwe.NewScale(float64(eval.params.fheParams.QBR()) / float64(2*msgSpace)) // Combine cond and a: we scale cond to match a's space // cond is in [-1, 1] -> scale to [-msgSpace/2, msgSpace/2) to interleave with a // Combined input x encodes: (cond_scaled * msgSpace + a) in a range for bivariate evaluation // Create select-a LUT: outputs a if cond > 0, else 0 selectALUT := blindrot.InitTestPolynomial(func(x float64) float64 { // Decode the combined input // x is in [-1, 1], representing the sum of cond and a encodings // After adding cond_scaled ([-0.5, 0.5]) and a_normalized ([0, 1]), // the combined range is [-0.5, 1.5] // For cond=true (+0.5): x = 0.5 + a_norm, range [0.5, 1.5] // For cond=false (-0.5): x = -0.5 + a_norm, range [-0.5, 0.5] // Extract cond and a from the combined value // If x >= 0.5, cond was true (output a) // If x < 0.5, cond was false (output 0) if x >= 0.0 { // cond is true, extract a value from upper portion // a_norm was in [0, 1], so x - 0.5 is a_norm when cond=true aNorm := x if aNorm > 1.0 { aNorm = 1.0 } if aNorm < 0.0 { aNorm = 0.0 } aVal := int(aNorm * float64(msgSpace)) if aVal >= msgSpace { aVal = msgSpace - 1 } return float64(aVal)*2/float64(msgSpace) - 1 } // cond is false, output 0 return float64(0)*2/float64(msgSpace) - 1 }, scale, eval.shortEval.ringQBR, -1, 1) // Combine cond and a ciphertexts // Scale cond to be compatible with a's encoding // cond is in ±Q/8, a is in [0, Q/(2*msgSpace)*value] // We want cond to contribute ±Q/(4*msgSpace) to separate true/false cases condScaled := eval.shortEval.scaleCiphertext(cond.Ciphertext, 0.5/float64(msgSpace)) sumCondA := eval.shortEval.addCiphertexts(condScaled, a.ct) // Evaluate LUT for cond*a condTimesA, err := eval.shortEval.bootstrap(sumCondA, &selectALUT) if err != nil { return nil, fmt.Errorf("selectA LUT: %w", err) } // Create select-b LUT: outputs b if cond <= 0, else 0 selectBLUT := blindrot.InitTestPolynomial(func(x float64) float64 { // Similar logic but inverted: output b when cond is false if x < 0.0 { // cond is false, extract b value from lower portion bNorm := x + 1.0 // shift to [0, 1] range if bNorm > 1.0 { bNorm = 1.0 } if bNorm < 0.0 { bNorm = 0.0 } bVal := int(bNorm * float64(msgSpace)) if bVal >= msgSpace { bVal = msgSpace - 1 } return float64(bVal)*2/float64(msgSpace) - 1 } // cond is true, output 0 return float64(0)*2/float64(msgSpace) - 1 }, scale, eval.shortEval.ringQBR, -1, 1) // Combine cond and b ciphertexts sumCondB := eval.shortEval.addCiphertexts(condScaled, b.ct) // Evaluate LUT for (1-cond)*b notCondTimesB, err := eval.shortEval.bootstrap(sumCondB, &selectBLUT) if err != nil { return nil, fmt.Errorf("selectB LUT: %w", err) } // Final result: add the two partial results // result = cond*a + (1-cond)*b resultCt := eval.shortEval.addCiphertexts(condTimesA, notCondTimesB) // Bootstrap to clean up noise and normalize identityLUT := blindrot.InitTestPolynomial(func(x float64) float64 { val := int((x + 1) * float64(msgSpace) / 2) if val >= msgSpace { val = msgSpace - 1 } if val < 0 { val = 0 } return float64(val)*2/float64(msgSpace) - 1 }, scale, eval.shortEval.ringQBR, -1, 1) finalCt, err := eval.shortEval.bootstrap(resultCt, &identityLUT) if err != nil { return nil, fmt.Errorf("final bootstrap: %w", err) } return &ShortInt{ ct: finalCt, msgBits: a.msgBits, msgSpace: a.msgSpace, }, nil } // boolToRadix converts a boolean ciphertext to RadixCiphertext func (eval *IntegerEvaluator) boolToRadix(ct *Ciphertext, t FheUintType) (*RadixCiphertext, error) { return &RadixCiphertext{ blocks: []*ShortInt{{ ct: ct.Ciphertext, msgBits: eval.params.blockBits, msgSpace: 1 << eval.params.blockBits, }}, blockBits: eval.params.blockBits, numBlocks: 1, fheType: t, }, nil } // zeroRadix returns an encrypted zero func (eval *IntegerEvaluator) zeroRadix(t FheUintType) (*RadixCiphertext, error) { numBlocks := (t.NumBits() + eval.params.blockBits - 1) / eval.params.blockBits blocks := make([]*ShortInt, numBlocks) for i := 0; i < numBlocks; i++ { zero, err := eval.shortEval.ScalarMul( &ShortInt{ ct: rlwe.NewCiphertext(eval.params.fheParams.paramsLWE, 1, eval.params.fheParams.paramsLWE.MaxLevel()), msgBits: eval.params.blockBits, msgSpace: 1 << eval.params.blockBits, }, 0) if err != nil { return nil, err } blocks[i] = zero } return &RadixCiphertext{ blocks: blocks, blockBits: eval.params.blockBits, numBlocks: numBlocks, fheType: t, }, nil }