Skip to content

Encoding and scale

Fixed point

A floating-point number is encoded as an integer: round(v · 10^e), and decoded by dividing back. The scale 10^e is chosen at encryption time and defaults to 10^8.

blobs = paillier.encrypt_many(pub, values, scale_pow10=12)

Round to nearest, not truncate

The rule was not chosen by taste.

Truncation toward zero biases every term downward in magnitude, by 1/(2·10^e) on average. On symmetric input the biases of opposite signs cancel and the error grows as √k. On sign-constant input they add up, and the error grows LINEARLY.

Federated learning is full of sign-constant data: bucket counters, squared gradients, sums of absolute values.

Measured at the same scale of 10^8:

terms truncation rounding (what we do)
1 000 4.98e-06 1.95e-08
10 000 5.02e-05 1.42e-07
100 000 5.00e-04 1.17e-06

The first column lands exactly on the drift line k/(2·10^e); the second stays at random-walk level. Over a hundred thousand terms that is a factor of four hundred.

Which rule an encoder actually applies is established by a discriminating observation, not inferred from the error numbers: take a value whose scaled magnitude has fractional part 0.7 and look at what comes back. benches/acc_rounding.py does exactly that for ours.

Naming the rule

What the default does is, precisely, two roundings: the product v · 10^e is rounded to the nearest f64, and that f64 is rounded to an integer with ties away from zero. Since 0.8.0 each step can be named on encrypt_many and on both multiplication paths:

  • rounding="half_even" sends an exact tie of the second rounding to the even integer instead;
  • exact=True removes the first rounding altogether: v is taken as the rational number its bits denote, multiplied by 10^e in the integers, and rounded once.

The default is unchanged and stays the default. The difference between the modes is confined to two places: a product that is not exact in f64 and lands across a midpoint, and values past 2^53/10^e, where the f64 grid is coarser than the integers. 0.1 at 1e18 is the first kind — 100000000000000000 by default, 100000000000000006 exactly, because 0.1 in binary is 0.1000000000000000055511….

Exact mode encodes the binary value, not the decimal the caller may have typed; the decimal 0.1 was already rounded to a double before the library saw it. The full table is in the API reference.

Integers need no encoding

A value that is an integer to begin with — a count, a code already scaled on the caller's side — does not have to pass through f64 at all. encrypt_integers takes Python ints of any size up to the key's plaintext bound, multiply_integers and multiply_integers_public multiply by them, and decrypt_exact_many returns (numerator, pow10) without dividing. Past 2^53 this is the only way to keep every digit: an f64 there holds only every second integer, then every fourth.

With integers the scale byte is recorded, not applied: encrypt_integers(pub, [150], scale_pow10=2) holds 150 and decrypts to 1.5.

The scale travels in the ciphertext

The first byte of the blob holds the power-of-ten exponent. Therefore:

  • decrypt reads the scale from the blob itself;
  • add_many refuses a batch that mixes scales;
  • the two sides never negotiate a scale at all.

Why not a call parameter

A scale mismatch produces not a refusal but a plausible wrong number: same codes, same length, just a result smaller by 10^Δ.

And separately: the passive side builds a peer key from n alone, and n carries no scale. Configured "on the side", it would take the default and silently disagree with the key holder.

The cost is one byte out of 513 — 0.2 % of the length.

Rescaling ciphertexts to a common scale is impossible: rescaling means multiplying the plaintext, and the plaintext is encrypted. So the choices are to refuse or to return nonsense.

Choosing a scale

The encoding error is 1/(2·10^e) and it is absolute — independent of the magnitude — but only while |v|·10^e is exactly representable in f64, roughly up to |v| ≈ 2^53/10^e. Above that the product itself gets rounded and the error becomes relative.

e error upper bound on \|v\|
8 (default) 5e-09 ~9e7
12 5e-13 ~9e3
15 5e-16 ~9e0

From e = 12 onward the scheme's error on long sums drops below the resolution of f64 itself: the spacing between adjacent floats near a sum of order 5e8 is about 1.2e-07, while the encoding error over a million terms is about 3e-10. The result lands on the same float as the exact sum, and raising the scale further gains nothing — it only narrows the range.

The default stays at 8: the widest input range is a sensible default, and narrowing it for accuracy is a decision for whoever knows the data.

Two edges to keep in mind

Below. Anything smaller in magnitude than 1/(2·10^e) encodes to zero. That is a property of fixed point, not a loss: at the default, 4e-9 encodes to zero and decrypts to zero.

Above. See the table. Past the boundary the error becomes relative and the model "error does not depend on magnitude" stops holding.

NaN and infinities are refused rather than turned into a credible zero that would then travel into the sum. A gap in a feature column is ordinary input, not an exotic case.