Repository: OpenMined/PySyft

Issue:
Implement Addition operation for FV HE Scheme
## Feature Description
Addition operations of FV Scheme need to be implemented.

1. It can **Add** 2 ciphertexts objects.
2. It can **Add** a ciphertext with plaintext objects.

In both cases, the result should be returned as a ciphertext object.



Discussion:

Resolving patch:
diff --git a/syft/frameworks/torch/he/fv/evaluator.py b/syft/frameworks/torch/he/fv/evaluator.py
new file mode 100644
--- /dev/null
+++ b/syft/frameworks/torch/he/fv/evaluator.py
@@ -0,0 +1,87 @@
+import copy
+
+from syft.frameworks.torch.he.fv.util.operations import poly_add_mod
+from syft.frameworks.torch.he.fv.util.operations import multiply_add_plain_with_delta
+from syft.frameworks.torch.he.fv.ciphertext import CipherText
+from syft.frameworks.torch.he.fv.plaintext import PlainText
+
+
+class Evaluator:
+    def __init__(self, context):
+        self.context = context
+        self.coeff_modulus = context.param.coeff_modulus
+        self.plain_modulus = context.param.plain_modulus
+
+    def add(self, op1, op2):
+        """Adds two operands using FV scheme.
+
+        Args:
+            op1 (Ciphertext/Plaintext): First argument.
+            op2 (Ciphertext/Plaintext): Second argument.
+
+        Returns:
+            If both arguments are Plaintext elements then the result will be a Plaintext object
+                otherwise a Ciphertext object with value equivalent to the result of addition
+                operation of two provided arguments.
+        """
+        if isinstance(op1, CipherText) and isinstance(op2, CipherText):
+            return self._add_cipher_cipher(op1, op2)
+
+        elif isinstance(op1, PlainText) and isinstance(op2, PlainText):
+            return self._add_plain_plain(op1, op2)
+
+        elif isinstance(op1, PlainText) and isinstance(op2, CipherText):
+            return self._add_plain_cipher(op1, op2)
+
+        elif isinstance(op1, CipherText) and isinstance(op2, PlainText):
+            return self._add_plain_cipher(op2, op1)
+
+        else:
+            raise TypeError(f"Addition Operation not supported between {type(op1)} and {type(op2)}")
+
+    def _add_cipher_cipher(self, ct1, ct2):
+        """Adds two ciphertexts.
+
+        Args:
+            ct1 (Ciphertext): First argument.
+            ct2 (Ciphertext): Second argument.
+
+        Returns:
+            A Ciphertext object with value equivalent to result of addition of two provided
+                arguments.
+        """
+        ct1, ct2 = copy.deepcopy(ct1.data), copy.deepcopy(ct2.data)
+        result = ct2 if len(ct2) > len(ct1) else ct1
+
+        for i in range(min(len(ct1), len(ct2))):
+            for j in range(len(self.coeff_modulus)):
+                result[i][j] = poly_add_mod(ct1[i][j], ct2[i][j], self.coeff_modulus[j])
+
+        return CipherText(result)
+
+    def _add_plain_cipher(self, pt, ct):
+        """Adds a ciphertext and a plaintext.
+
+        Args:
+            pt (Plaintext): First argument.
+            ct (Ciphertext): Second argument.
+        Returns:
+            A Ciphertext object with value equivalent to result of addition of two provided
+                arguments.
+        """
+        ct = copy.deepcopy(ct)
+        return multiply_add_plain_with_delta(ct, pt, self.context)
+
+    def _add_plain_plain(self, pt1, pt2):
+        """Adds two plaintexts object.
+
+        Args:
+            pt1 (Plaintext): First argument.
+            pt2 (Plaintext): Second argument.
+
+        Returns:
+            A Plaintext object with value equivalent to result of addition of two provided
+                arguments.
+        """
+        pt1, pt2 = copy.deepcopy(pt1), copy.deepcopy(pt2)
+        return PlainText(poly_add_mod(pt1.data, pt2.data, self.plain_modulus))
diff --git a/syft/frameworks/torch/he/fv/util/operations.py b/syft/frameworks/torch/he/fv/util/operations.py
--- a/syft/frameworks/torch/he/fv/util/operations.py
+++ b/syft/frameworks/torch/he/fv/util/operations.py
@@ -60,6 +60,15 @@ def invert_mod(value, modulus):
 def poly_add_mod(op1, op2, modulus):
     """return addition of two polynomials with all coefficients of
     polynomial %q(coefficient modulus)"""
+
+    # For non same size polynomails we have to shift the polynomials because numpy consider right
+    # side as lower order of polynomial and we consider right side as heigher order.
+    if len(op1) != len(op2):
+        if len(op1) > len(op2):
+            op2 = op2 + [0] * (len(op1) - len(op2))
+        else:
+            op1 = op1 + [0] * (len(op2) - len(op1))
+
     return np.mod(np.polyadd(op1, op2), modulus).tolist()
 
 
@@ -152,10 +161,10 @@ def xgcd(x, y):
 
 
 def multiply_add_plain_with_delta(phase, message, context):
-    """Add message (PlainText) into phase.
+    """Add message into phase.
 
     Args:
-        phase: phase is pre-computed carrier polynomial where we can add message data.
+        phase (Ciphertext): phase is pre-computed carrier polynomial where we can add message data.
         message (Plaintext): A plaintext representation of integer data to be encrypted.
         context (Context): Context for extracting encryption parameters.
 


Test patch:
diff --git a/test/torch/tensors/test_fv.py b/test/torch/tensors/test_fv.py
--- a/test/torch/tensors/test_fv.py
+++ b/test/torch/tensors/test_fv.py
@@ -19,6 +19,7 @@
 from syft.frameworks.torch.he.fv.util.operations import reverse_bit
 from syft.frameworks.torch.he.fv.encryptor import Encryptor
 from syft.frameworks.torch.he.fv.decryptor import Decryptor
+from syft.frameworks.torch.he.fv.evaluator import Evaluator
 
 
 @pytest.mark.parametrize(
@@ -373,3 +374,63 @@ def test_fv_encryption_decrption_without_changing_parameters():
         ct = encryptor.encrypt(encoder.encode(value))
         for _ in range(3):
             assert value == encoder.decode(decryptor.decrypt(ct))
+
+
+@pytest.mark.parametrize(
+    "int1, int2", [(0, 0), (-1, 1), (100, -10), (1000, 100), (-1000, 100), (-100, -100)]
+)
+def test_fv_add_cipher_cipher(int1, int2):
+    ctx = Context(EncryptionParams(1024, CoeffModulus().create(1024, [30, 30]), 1024))
+    keys = KeyGenerator(ctx).keygen()
+    encoder = IntegerEncoder(ctx)
+    encryptor = Encryptor(ctx, keys[1])  # keys[1] = public_key
+    decryptor = Decryptor(ctx, keys[0])  # keys[0] = secret_key
+    evaluator = Evaluator(ctx)
+
+    op1 = encryptor.encrypt(encoder.encode(int1))
+    op2 = encryptor.encrypt(encoder.encode(int2))
+    assert (
+        int1 + int2
+        == encoder.decode(decryptor.decrypt(evaluator._add_cipher_cipher(op1, op2)))
+        == encoder.decode(decryptor.decrypt(evaluator.add(op1, op2)))
+        == encoder.decode(decryptor.decrypt(evaluator.add(op2, op1)))
+    )
+
+
+@pytest.mark.parametrize(
+    "int1, int2", [(0, 0), (-1, 1), (100, -10), (1000, 100), (-1000, 100), (-100, -100)]
+)
+def test_fv_add_plain_cipher(int1, int2):
+    ctx = Context(EncryptionParams(1024, CoeffModulus().create(1024, [30, 30]), 1024))
+    keys = KeyGenerator(ctx).keygen()
+    encoder = IntegerEncoder(ctx)
+    encryptor = Encryptor(ctx, keys[1])  # keys[1] = public_key
+    decryptor = Decryptor(ctx, keys[0])  # keys[0] = secret_key
+    evaluator = Evaluator(ctx)
+
+    op1 = encoder.encode(int1)
+    op2 = encryptor.encrypt(encoder.encode(int2))
+
+    assert (
+        int1 + int2
+        == encoder.decode(decryptor.decrypt(evaluator._add_plain_cipher(op1, op2)))
+        == encoder.decode(decryptor.decrypt(evaluator.add(op1, op2)))
+        == encoder.decode(decryptor.decrypt(evaluator.add(op2, op1)))
+    )
+
+
+@pytest.mark.parametrize(
+    "int1, int2", [(0, 0), (-1, 1), (100, -10), (1000, 100), (-1000, 100), (-100, -100)]
+)
+def test_fv_add_plain_plain(int1, int2):
+    ctx = Context(EncryptionParams(1024, CoeffModulus().create(1024, [30, 30]), 1024))
+    encoder = IntegerEncoder(ctx)
+    evaluator = Evaluator(ctx)
+    op1 = encoder.encode(int1)
+    op2 = encoder.encode(int2)
+    assert (
+        int1 + int2
+        == encoder.decode(evaluator._add_plain_plain(op1, op2))
+        == encoder.decode(evaluator.add(op1, op2))
+        == encoder.decode(evaluator.add(op2, op1))
+    )