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=Trueremoves the first rounding altogether:vis taken as the rational number its bits denote, multiplied by10^ein 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:
decryptreads the scale from the blob itself;add_manyrefuses 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.