Concrete enables operating homomorphically on real-values by encoding them into fixed-precision representation called plaintexts. Encoders have:
an interval to work in, with size equal to .
a precision in bits, representing the granularity of the interval. The granularity is the smallest increment between two consecutive values, and is equal to .
Defining the right encoders is important to ensure your homomorphic program runs accurately and efficiently. More precision typically means more internal operations, and thus, a more computationally expensive homomorphic program, while less precision can lead to imprecise results.
Always chose the smallest possible precision that yields the desired output. This will ensure your homomorphic program runs faster!
Concrete simplifies managing precision by providing an Encoder
struct that encodes real messages into plaintexts. An encoder takes three parameters:
an interval representing the range of values your messages can take
the number of bits of precision needed to represent your data
the number of bits of padding to carry on leveled operations (more on that later)
Here is an example encoder that can represent messages in with 8 bits of precision and 0 bits of padding:
let min = -10.;let max = 10.;let precision = 8; // bitslet padding = 0; // bits// create an Encoder instancelet encoder = Encoder::new(min, max, precision, padding)?;
Instead of using the min and max of the interval, an encoder can also be defined using the center value of the interval and a radius:
let center = 0.;let radius = 10.;let precision = 8;let padding = 0;// this is equivalent to the previous encoderlet encoder = Encoder::new_centered(center, radius, precision, padding)?;
Using the above encoders, values can be represented, with being the smallest value, being the largest, and a granularity of between consecutive values. The granularity of an encoder can be computed with its get_granularity
method.
Concrete only requires that you specify the encoding for your input messages. Once you start operating on the ciphertexts, the encoding will evolve dynamically to represent the range of possible values. When it is not possible to infer the encoding, an output encoder will need to be specified.
For example, let and be the messages encoded into the interval and respectively . Then, the range of values of their addition is updated to .
The last parameter, the number of padding bits is required to ensure the correctness of future computations. In a nutshell, they allow to keep the precision and granularity defined in the encoder while taking in account the potential carries. The processes related to the padding are details in the Leveled Operations section.
Under the hood, a Plaintext instance stores a vector of encoded messages, each with their own encoder. This enables Concrete to better manage performances. Thus, a plaintext in Concrete can be either a single encoded message, or a vector of encoded messages.
Encoding a message into a plaintext is rather simple:
// create a message in the intervallet message = -6.276;// create a new Plaintext using the encoder's functionlet p1: Plaintext = encoder.encode_single(message)?;
The encode function is versatile, meaning you can pass it a vector of messages instead of a single message. Internally, both are represented in the same way, with single-message plaintexts simply representing the values as a vector of size 1.
// create a list of messages in our intervallet messages = vec![-6.276, 4.3, 0.12, -1.1, 7.78];// create a new Plaintext using the encoder's functionlet p2: Plaintext = encoder.encode(&messages)?;
You can decode a plaintext back into a raw message using the decode method:
// decode the plaintext into a vector of messageslet output: Vec<f64> = p2.decode()?;
The decode method always returns a vector of messages, since this is how the plaintext stores values internally. If you only encoded one value, it will be stored in the decoded vector's first element.
Since encoding reduces the precision of the original message, the decoded message will not always match exactly the original message, but rather the closest value that the encoder can represent.
Here is an example program that specifies and uses an encoder, for a single message and a message vector:
/// file: main.rsuse concrete::*;fn main() -> Result<(), CryptoAPIError> {// the encoder's parameterslet min = -10.;let max = 10.;let precision = 8;let padding = 0;// create an Encoder instancelet encoder = Encoder::new(min, max, precision, padding)?;// encode a single messagelet m1 = -6.276;let p1: Plaintext = encoder.encode_single(m1)?;// encode a vector of messageslet m2 = vec![-6.276, 4.3, 0.12, -1.1, 7.78];let p2: Plaintext = encoder.encode(&m2)?;// decode the plaintextlet o1: Vec<f64> = p1.decode()?;let o2: Vec<f64> = p2.decode()?;println!("{}", p1);println!("{}", p2);println!("m1 = {}, o1 = {:?}", m1, o1[0]);println!("m2 = {:?}, o2 = {:?}", m2, o2);Ok(())}