What i128 is exact about¶
Level: 301 · deep dive
One line: i128 makes +, − and × exact to the last digit inside a ceiling you are responsible for proving, does nothing whatever for ÷, and is not a substitute for Python's fractions.Fraction — which is exact under division and has no ceiling at all, and bills you for both on every operation.
i128 usually gets reached for about ten minutes after a float has destroyed a tie — two totals that are mathematically equal come out differing in the last bit, so the tiebreak that should have decided the top spot never runs and the win goes to whichever way the rounding fell. That instinct is right, and the type does deliver — but it is a wider integer, not an exact-arithmetic library, and the two get conflated constantly. "Exact" is really three separate properties, and i128 has one of them:
exact under + − ×? |
exact under ÷? |
unbounded? | |
|---|---|---|---|
f64 |
no — 53 significant bits | no | no |
i64 / i128 |
yes | no — truncates | no |
num_rational::Ratio<i128> |
yes | yes | no |
Python int |
yes | no — // truncates |
yes |
Python fractions.Fraction |
yes | yes | yes |
Every row after the first is exact about something. The rows differ in what they are exact about and what that costs, and picking between them is the whole subject of this page.
What the type actually is¶
Verified output of i128_exactness.rs — regenerated by tools/run_examples.py, never hand-typed.
1. The type, exactly
i64 8 bytes, align 8 max 9223372036854775807
i128 16 bytes, align 16 max 170141183460469231731687303715884105727
that is 39 digits against 19 — 20 decimal digits more room.
It is a primitive: no crate, no heap, no allocation. Just twice
the register and twice the cache line of an i64.
2. Exact under + - *, right up to the ceiling
i64::MAX x i64::MAX
i128 85070591730234615847396907784232501249
f64 85070591730234615865843651857942052864
error 18446744073709551615 <- larger than i64::MAX itself (9223372036854775807)
Every digit of the i128 answer is right. The f64 keeps about 17
significant digits and fills the rest with whatever the exponent
implies, which is how a tied count stops being tied.
The ceiling is real, though, and it is the price of the exactness:
i128::MAX x 2 = None — there is no wider primitive to escape to
3. And exact about nothing at all under /
100 raters, 3 products, integer division:
each gets 33, and 33 × 3 = 99 — 1 rater unaccounted for
The same statement in i64 loses the same rater. Widening the type
does not close the integers under division; nothing can. That is
what a rational is for — and what it charges for.
4. A rational built on i128: how far 1 + 1/2 + 1/3 + ... gets
never call gcd reaches 1/33
gcd after each add reaches 1/83
gcd before each multiply reaches 1/88
The furthest an i128 pair gets is the sum of the first 88 terms:
40866521918642154860585199122889549709
8076030954443701744994070304101969600
Numerator is 38 digits, denominator 37 — one more term and neither fits.
5. So what did the gcd buy?
no reduction 1/33 ← the denominators just multiply out
reduce the result 1/83 ← +50 terms, and what overflows is now
the INTERMEDIATE, not the answer
reduce first 1/88 ← +5 more; now the answer itself is
the thing that no longer fits
The gcd is not overhead sitting on top of the arithmetic. It IS
the range. Skip it and a 128-bit rational gets no further than 1/33.
Python's Fraction has no such term. It reduces with the same gcd
and then simply grows — so it never reports a limit, and never
needs to. That is the whole difference, and it is not free either:
the bill moves from a ceiling you must prove onto a running cost
that rises with the size of the number you are holding.
Four things in that run are worth pulling out.
It is an ordinary primitive. No crate, no allocation, no Box. i128 is in the language the way i32 is, it is Copy, and it lives in registers — two of them, on a 64-bit machine, which is where its costs come from. It is 16 bytes with 16-byte alignment, matching C's __int128.
i64::MAX squared fits with room to spare. That is the case the type exists for: two values that each fit in 64 bits and a product that does not. The f64 answer to the same multiplication is off by 18,446,744,073,709,551,615 — an error larger than the largest number an i64 can hold. Floating point is not being sloppy there; it is doing exactly what 53 bits of mantissa permit.
It truncates under division exactly as i64 does. 100 raters among 3 products is 33 each and one rater gone. Widening the type buys range, not closure — no integer type is closed under division, and no wider one would help. This is the part people are surprised by, because "I switched to i128 for exactness" and "my averages are wrong" feel like they should not both be true.
The ceiling is hard and there is nothing past it. i128::MAX.checked_mul(2) is None, and unlike every earlier widening there is no i256 to escape into. In Rust, 128 bits is the end of the primitives.
What exactness costs, measured¶
The usual claim is that i128 is "basically free — just a couple of extra instructions." That is true for two of the three operations and badly wrong for the third.
Here is the same 3,000,000-operation loop at both widths — one machine (Intel i5-10500), rustc 1.97.1 -O, best of five, and the loop overhead (~3.9 ms) is in every row:
| operation | i64 |
i128 |
cost of widening |
|---|---|---|---|
| add | 3.90 ms | 3.86 ms | none measurable |
| multiply | 5.67 ms | 5.87 ms | ~4% |
| divide | 22.21 ms | 97.52 ms | 4.4× (≈5× with the overhead subtracted) |
gcd (Euclid) |
— | 480 ms | — |
The reason is visible in the generated assembly. Put the four operations in a file as #[no_mangle] pub fns and compile it as a library:
Here are the bodies of the four, side by side with the stack prologue and epilogue trimmed away — an arrangement for reading, not verbatim ops.s:
add128: addq %rdx, %rax mul128: mulq %rdi
adcq %rcx, %rsi imulq %r8, %rsi
addq %rsi, %rdx
imulq %rdi, %rcx
addq %rcx, %rdx
div64: idivq %rsi div128: callq ___divti3
Addition is a carry chain — add then add-with-carry, and the hardware was always going to do that. Multiplication is one widening multiply plus two cross products. But there is no 128-bit divide instruction on x86-64 or on aarch64, so a / b on i128 is not an instruction at all: it is a function call into compiler-rt's __divti3, a software long-division routine. Same for %, which is why the gcd row is what it is — Euclid is a loop of remainders, so a gcd on i128 costs about five divisions, and a rational pays one on every operation.
Two smaller costs worth knowing before you put i128 in a hot struct:
- Twice the memory, and twice the cache pressure. A
Vec<i128>of a million raters is 15 MB against 7.6 MB. For a count that streams once this is nothing; for one that random-accesses a table repeatedly it can cost more than the arithmetic did. - There is no stable
AtomicI128. It is behind the unstableinteger_atomicsfeature, so a shared running total across threads needs aMutex(and its own poisoning question) rather than a lock-free add.
So: widen freely for adding and multiplying. Think before you divide, and think hard before you build a rational on it.
How this differs from fractions.Fraction¶
Take the obvious next step and build a rational on i128 — a numerator, a denominator, and a gcd to reduce after each operation. That is num_rational::Ratio<i128>, and it is the closest thing Rust has to a Fraction. It is exact under division, which i128 alone is not.
It also stops. Section 4 of the run above adds 1 + 1/2 + 1/3 + … and reports the last term that fits:
| how the rational reduces | reaches |
|---|---|
never calls gcd |
1/33 |
gcd after each addition |
1/83 |
gcd before each multiplication |
1/88 |
Read that table twice, because it says something that is easy to have backwards. The gcd is not overhead sitting on top of the arithmetic — it is the range. Skip it and a 128-bit rational manages thirty-three terms of the simplest series in mathematics. Reduce after each addition and you get to 83, at which point what overflows is no longer the answer but the intermediate num*n on the way to it. Reduce the cross terms before multiplying and you reach 88 — which is where the exact answer itself stops fitting, so no cleverer implementation exists.
Now the same sum in Python:
>>> from fractions import Fraction
>>> sum((Fraction(1, n) for n in range(1, 89)), Fraction(0))
Fraction(40866521918642154860585199122889549709, 8076030954443701744994070304101969600)
Identical to the Rust answer at term 88 — and then it simply keeps going. There is no term at which Fraction reports a limit, because Python's int grows to fit and Fraction is a pair of them. That is the difference in one sentence: Ratio<i128> has a ceiling and a constant cost per operation; Fraction has no ceiling and a cost per operation that grows with the number it is holding.
That growth is the part worth measuring, because it is not a constant factor you can budget for. Python 3.14.2, same machine:
| 200,000 additions | |
|---|---|
int, value stays small |
9.1 ms |
Fraction, value stays small |
137 ms — ~15× |
Fifteen times is the honest per-operation overhead: a heap object, a math.gcd (which is C, and good), and interpreter dispatch. But hold a value that grows and the factor stops being a factor at all:
| harmonic sum | cumulative | denominator |
|---|---|---|
| first 1,000 terms | 2.3 ms | 1,438 bits |
| first 10,000 terms | 85.8 ms | 14,434 bits |
| first 40,000 terms | 1,138 ms | 57,719 bits |
Forty times the terms for 495 times the time. The denominator at 40,000 terms is 57,719 bits wide — more than 450 times wider than an i128 — and every remaining operation pays gcd and multiplication on numbers that size. A Fraction never gives a wrong answer and never overflows; it just gradually stops finishing.
Which makes the choice a choice between two failure modes, and this is the sentence to carry away:
i128gives you a wrong answer quickly.Fractiongives you the right answer, eventually.
And i128's wrong answer is the more dangerous of the two, because of how it arrives. A debug build panics on overflow; a release build wraps. A Fraction that is too slow announces itself the first time you run it — you sit there waiting. An i128 that has overflowed produces a plausible negative number in production and nothing in the language mentions it.
So which one¶
- Neither, if the denominators are known in advance. Multiply them out once and count in plain integers — that is scaling the denominator away, it is exact, and it never divides at all, so none of this page's costs apply. Reach for it first; it is available more often than it looks.
i128when you are adding and multiplying bounded integers and can argue the range on a whiteboard. Prove the bound, and writechecked_mulwherever you could not.- A rational when division is genuinely in the middle of the computation and the denominators are not enumerable. In Rust that means
num_rationaland a real ceiling you should test against; in Python it meansFractionand a running cost you should profile. - Never
f64for a value whose equality you will test. That is not a precision argument, it is a categorical one: a tie is the case the rulebook has a whole ladder for, and equality is the one question floating point cannot answer.
Notice that two of these four are ways of not dividing. That is the actual lesson under the measurements: exactness problems are usually division problems, and the cheapest fix is almost never a wider type — it is restructuring so the division does not happen. The kata is that fix in miniature.
If you are coming from another language¶
- Python —
fractions.Fractionis the same design asRatio<i128>and a genuinely good one; what transfers is the numerator/denominator/gcdstructure exactly. What changes is that Python'sintis arbitrary-precision, soFractionhas no ceiling and therefore no overflow question — which cuts both ways. You never have to prove a bound, and you also can never be told you have exceeded one: the same program that would have raised in Rust just gets slower until someone notices. Coming the other way, the trap is assumingi128is "Python's int but faster." It is not; it isintwith a wall at 39 digits, and unlikeintit will go through the wall silently in a release build. - ABAP —
TYPE pis the closest thing you have, and the mapping is instructive because it is inexact in an interesting way. Packed decimal is exact under+ − ×and rounds under/at a declared number of decimals, so it sits betweeni128and a rational: closer to closed under division than an integer, but by rounding rather than by representing the quotient. Two things change. The scale is a power of ten fixed at declaration, where a rational carries whatever denominator the arithmetic produced. And the overflow reporting inverts — packed arithmetic raisesCX_SY_ARITHMETIC_OVERFLOWat runtime and youCATCHit, whereas Rust wants the range argued before the run and will otherwise wrap without a word. If you are used to trusting the runtime to tell you, that is the habit to unlearn:checked_mulreturningOptionis where the exception went.
Both bridges land in the same place. Rust gives you a 128-bit integer that costs nothing to add, very little to multiply, and a function call to divide — and then leaves the range argument entirely to you.
Practice¶
The average that came out as a three-way tie. Three products, three genuinely different average scores, and an i128 wide enough to hold every number in the dataset several times over. Compute each product's average with total / raters and rank them.
Make that mistake first and look hard at the three identical numbers before fixing anything, because the shape of the failure is the lesson. Work out why integer division can never reverse two products but can readily collapse them onto the same value — and then say what a manufactured tie actually does to an dataset, which is the reason this is a serious bug and not a rounding nit.
Then fix it without a wider type, because there isn't one: rank the field exactly while never performing a division. Finally, scale the dataset up until your fix overflows too — it will — and buy the headroom back the same way a rational library does.
Solution
i128_exactness_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution — the average that came out as a three-way tie.
//!
//! Three products, three different average scores, and an `i128` wide enough
//! to hold every number in the dataset with room to spare. The averages still
//! come out equal, because the one operation `i128` is not exact about is the
//! one the word "average" is made of.
//!
//! The fix is not a wider type. There is no wider primitive, and it would not
//! help if there were: the problem is division, not range. The fix is to stop
//! dividing — and then to notice what not-dividing costs.
/// `(name, total score awarded, raters that scored them)`.
const FIELD: [(&str, i128, i128); 3] = [
("Alma", 1_000_000, 3_000),
("Bruno", 1_000_500, 3_001),
("Cara", 999_000, 3_000),
];
fn gcd(mut a: i128, mut b: i128) -> i128 {
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a.abs()
}
/// Compare `a/b` against `c/d` without ever dividing, and refuse to wrap.
///
/// `a/b > c/d` exactly when `a*d > c*b`, for positive denominators. The
/// products are what overflow, so divide out the common factors of each
/// *pair* first — the comparison is unchanged and the operands get smaller.
fn compare_exact(a: i128, b: i128, c: i128, d: i128) -> Option<std::cmp::Ordering> {
let g1 = gcd(a, c);
let g2 = gcd(b, d);
let left = (a / g1).checked_mul(d / g2)?;
let right = (c / g1).checked_mul(b / g2)?;
Some(left.cmp(&right))
}
fn main() {
// ------------------------------------------------------------ 1
println!("1. The average, by division — the mistake worth making first");
for (name, total, raters) in FIELD {
println!(" {name:<6} {total:>9} / {raters:<5} = {}", total / raters);
}
println!(" Three identical averages, and a three-way tie for the top spot.");
// ------------------------------------------------------------ 2
println!("\n2. What the truncation actually did");
println!(" The real averages, to six places, computed only to show them:");
for (name, total, raters) in FIELD {
println!(
" {name:<6} {:.6}",
total as f64 / raters as f64
);
}
println!(" They were never equal. Integer division floors, and flooring is");
println!(" monotone — so it can never REVERSE two products, only collapse");
println!(" them onto the same number. That sounds like the harmless failure");
println!(" until you remember what a tie does: it hands the top spot to the");
println!(" tiebreak ladder, and at the bottom of that ladder is a lot.");
println!(" A manufactured tie is not a rounding error. It is a coin flip");
println!(" between products who were not actually tied.");
// ------------------------------------------------------------ 3
println!("\n3. The fix: rank them without dividing at all");
let mut order: Vec<(&str, i128, i128)> = FIELD.to_vec();
order.sort_by(|&(_, at, ab), &(_, bt, bb)| {
compare_exact(bt, bb, at, ab).expect("these products fit easily")
});
for (i, (name, total, raters)) in order.iter().enumerate() {
println!(" {}. {name:<6} ({total}/{raters})", i + 1);
}
let (a, at, ab) = FIELD[0];
let (b, bt, bb) = FIELD[1];
println!(" The comparison {a} vs {b} is one multiplication each side:");
println!(" {at} × {bb} = {}", at * bb);
println!(" {bt} × {ab} = {}", bt * ab);
println!(" No division, so nothing truncates, so no tie is invented.");
// ------------------------------------------------------------ 4
println!("\n4. The bill: cross-multiplying needs headroom dividing did not");
// A national count whose totals are already scaled by a large denominator —
// which is exactly what you are holding after the scaled-integer trick.
let (big_a, big_b): (i128, i128) = (90_000_000_000_000_000_000_000_000_000_000_000_000, 6_000_000_000_000);
let (big_c, big_d): (i128, i128) = (80_000_000_000_000_000_000_000_000_000_000_000_000, 5_000_000_000_000);
println!(" Alma {big_a} / {big_b}");
println!(" Bruno {big_c} / {big_d}");
match big_a.checked_mul(big_d) {
Some(v) => println!(" naive cross-multiply -> {v}"),
None => {
println!(
" naive cross-multiply -> None: that product needs about {} bits,",
big_a.ilog2() + big_d.ilog2()
);
println!(" and an i128 has 127 to spend");
}
}
println!(" Both operands fit i128 comfortably. Their product does not, and");
println!(" in a release build a bare `*` would have wrapped to a negative");
println!(" number and ranked the field by it.");
println!("\n Reducing each pair by its gcd first, the same comparison is tiny:");
let g1 = gcd(big_a, big_c);
let g2 = gcd(big_b, big_d);
println!(" gcd of the totals {g1}");
println!(" gcd of the counts {g2}");
println!(
" {} × {} = {} vs {} × {} = {}",
big_a / g1,
big_d / g2,
(big_a / g1) * (big_d / g2),
big_c / g1,
big_b / g2,
(big_c / g1) * (big_b / g2),
);
println!(
" -> {}",
match compare_exact(big_a, big_b, big_c, big_d) {
Some(std::cmp::Ordering::Greater) => "Alma leads",
Some(std::cmp::Ordering::Less) => "Bruno leads",
Some(std::cmp::Ordering::Equal) => "a genuine tie",
None => "still overflows",
}
);
println!(" Two 38-digit numbers compared by multiplying 9 by 5 and 8 by 6.");
println!(" That is the same move the lesson called reduce-before-multiply,");
println!(" and it is the reason a rational library calls gcd as often as it");
println!(" does: the reduction is not tidiness, it is the headroom.");
}
Verified output of i128_exactness_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
1. The average, by division — the mistake worth making first
Alma 1000000 / 3000 = 333
Bruno 1000500 / 3001 = 333
Cara 999000 / 3000 = 333
Three identical averages, and a three-way tie for the top spot.
2. What the truncation actually did
The real averages, to six places, computed only to show them:
Alma 333.333333
Bruno 333.388870
Cara 333.000000
They were never equal. Integer division floors, and flooring is
monotone — so it can never REVERSE two products, only collapse
them onto the same number. That sounds like the harmless failure
until you remember what a tie does: it hands the top spot to the
tiebreak ladder, and at the bottom of that ladder is a lot.
A manufactured tie is not a rounding error. It is a coin flip
between products who were not actually tied.
3. The fix: rank them without dividing at all
1. Bruno (1000500/3001)
2. Alma (1000000/3000)
3. Cara (999000/3000)
The comparison Alma vs Bruno is one multiplication each side:
1000000 × 3001 = 3001000000
1000500 × 3000 = 3001500000
No division, so nothing truncates, so no tie is invented.
4. The bill: cross-multiplying needs headroom dividing did not
Alma 90000000000000000000000000000000000000 / 6000000000000
Bruno 80000000000000000000000000000000000000 / 5000000000000
naive cross-multiply -> None: that product needs about 168 bits,
and an i128 has 127 to spend
Both operands fit i128 comfortably. Their product does not, and
in a release build a bare `*` would have wrapped to a negative
number and ranked the field by it.
Reducing each pair by its gcd first, the same comparison is tiny:
gcd of the totals 10000000000000000000000000000000000000
gcd of the counts 1000000000000
9 × 5 = 45 vs 8 × 6 = 48
-> Bruno leads
Two 38-digit numbers compared by multiplying 9 by 5 and 8 by 6.
That is the same move the lesson called reduce-before-multiply,
and it is the reason a rational library calls gcd as often as it
does: the reduction is not tidiness, it is the headroom.
Three things worth taking from that run.
Truncation could only ever have produced a tie. Integer division floors, flooring is monotone, so if Bruno's true average is higher than Alma's then his floored average cannot come out lower. The failure has exactly one shape: distinct products collapsing onto one number. That is worth knowing precisely, because it tells you the bug will never look like an upset — it will look like a tie, and a tie looks like something the rulebook already handles.
Which is why it is worse than a reversal, not better. A reversal is a wrong winner, and someone eventually notices a wrong winner. A manufactured tie sends the top spot down the tiebreak ladder to a coin toss between products who were not tied — and every part of that process behaves correctly, logs correctly, and is reproducible. There is nothing to notice.
The fix and its bill are the same move as the lesson's. Cross-multiplying removes the division, and then the products overflow at national scale — so you divide out each pair's gcd first and compare 9 × 5 against 8 × 6 instead of two 38-digit numbers. That is Reduce::BeforeTheMultiply from the lesson, arrived at from the other direction: not to make a rational reach further, but to make a comparison fit at all. Reduction is headroom, wherever you meet it.
Po polsku¶
Po i128 sięga się zwykle dziesięć minut po tym, jak f64 zepsuł remis: dwie sumy matematycznie równe wychodzą różne na ostatnim bicie, więc drabinka rozstrzygnięć w ogóle się nie uruchamia, a mandat bierze ten, w którą stronę spadło zaokrąglenie. Instynkt jest dobry, tylko słowo „dokładny” znaczy tutaj trzy różne rzeczy naraz: dokładność pod + − ×, dokładność pod ÷ i brak sufitu. i128 ma pierwszą z nich — i tylko ją. To szerszy typ całkowity, nie biblioteka arytmetyki dokładnej. Dzielenie obcina dokładnie tak samo jak w i64: 100 kart na trzech kandydatów to 33 na głowę i jedna karta znika w powietrzu. Poszerzanie typu kupuje zakres, a nie domknięcie na dzieleniu — i sufit jest twardy, bo i256 nie istnieje: 128 bitów to koniec typów prostych w Ruscie.
Cena tej dokładności rozkłada się bardzo nierówno i warto ją znać z liczbami, a nie z przeczucia. Dodawanie jest za darmo (add plus adc, zwykły łańcuch przeniesień), mnożenie kosztuje jakieś 4%, ale dzielenie jest 4,4 raza wolniejsze — bo ani x86-64, ani aarch64 nie mają instrukcji dzielenia 128-bitowego, więc a / b nie jest instrukcją, tylko wywołaniem funkcji __divti3 z compiler-rt. Stąd NWD (gcd) na i128 kosztuje mniej więcej pięć dzieleń, a typ ułamkowy płaci to przy każdej operacji. Do tego dwa razy więcej pamięci i brak stabilnego AtomicI128, czyli wspólny licznik między wątkami wymaga Mutexa zamiast bezblokadowego dodawania.
Najważniejsze zdanie tej strony jest jednak odwrotne do tego, co podpowiada intuicja: NWD to nie narzut doklejony do arytmetyki — NWD to jest ten zapas zakresu. Ułamek na i128 bez skracania dochodzi w szeregu 1 + 1/2 + 1/3 + … do wyrazu 1/33; skracany po każdym dodawaniu — do 1/83; skracany przed mnożeniem — do 1/88, czyli tam, gdzie sama odpowiedź przestaje się mieścić i lepszej implementacji już nie ma. Pythonowy Fraction liczy to samo i po prostu rośnie dalej, bo siedzi na nieograniczonym int. Nie ma sufitu — ma za to koszt rosnący z wielkością liczby: przy 40 000 wyrazów mianownik ma 57 719 bitów, ponad 450 razy więcej niż i128, i każda kolejna operacja płaci NWD na liczbach tej wielkości. Wybierasz więc między dwoma trybami awarii: i128 daje złą odpowiedź szybko, Fraction daje dobrą odpowiedź kiedyś. Groźniejsza jest ta pierwsza, bo w debug przepełnienie kończy się paniką, ale w trybie --release zawija się bez słowa — i dostajesz wiarygodnie wyglądającą liczbę ujemną, o której język nigdzie nie wspomni.
Praktycznie sprowadza się to do czterech odpowiedzi:
- Żadna z tych dwóch, jeśli mianowniki są znane z góry — przemnóż je raz i licz na zwykłych liczbach całkowitych, bez ani jednego dzielenia.
i128, gdy dodajesz i mnożysz liczby o dającym się uargumentować zakresie. Udowodnij ograniczenie, a tam gdzie nie umiesz — piszchecked_mul.- Ułamek, gdy dzielenie naprawdę siedzi w środku rachunku, a mianowników nie da się wypisać: w Ruscie
num_rationali sufit, który trzeba przetestować. - Nigdy
f64dla wartości, której równość będziesz sprawdzać. To nie jest argument o precyzji, tylko o rodzaju pytania: remis to pytanie o równość, a na nie liczby zmiennoprzecinkowe nie odpowiadają.
Dwie z tych czterech to sposoby na niedzielenie i to jest właściwa nauka pod pomiarami: problem z dokładnością prawie zawsze jest problemem z dzieleniem, a najtańsza naprawa rzadko polega na szerszym typie. Na koniec rzecz, którą warto powiedzieć wprost, bo w kontekście wyborów robi różnicę: obcięcie przy dzieleniu nigdy nie odwróci kolejności dwóch kandydatów (podłoga jest monotoniczna) — może ich tylko skleić w jedną liczbę, czyli wyprodukować remis, którego nie było. A remis wygląda jak sytuacja, którą regulamin już przewidział: schodzi spokojnie po drabince rozstrzygnięć aż do losowania, wszystko po drodze działa poprawnie, loguje się poprawnie i jest odtwarzalne. Nie ma czego zauważyć — i dlatego to poważny błąd, a nie drobiazg o zaokrąglaniu.
Szukaj po polsku: dzielenie całkowitoliczbowe obcina · przepełnienie w trybie release · NWD i skracanie ułamków · rust i128 division __divti3 · rust integer overflow release wrapping