Repository navigation
Document correctness of Fraction.limit_denominator #95723
Description
The correctness of the Fraction.limit_denominator method is not immediately obvious, and extracting a proof of correctness from freely available external sources is nontrivial: Wikipedia has statements without proofs at https://en.wikipedia.org/wiki/Simple_continued_fraction#Best_rational_approximations, and a typical elementary number theory book will have you wade through a whole chapter of theory on continued fractions to get to something useful. In spite of that, a targeted correctness proof for limit_denominator is straightforward, and doesn't require developing the whole theory of continued fractions (or even mentioning them).
For future maintainers of Fraction.limit_denominator, it would be useful to have the correctness proof recorded somewhere: when I had to update the limit_denominator code in #93730, I had to stare at the code for a good hour to figure out why it worked at all, despite the fact that I wrote that code in the first place (albeit a good number of years ago). Moreover, the new tie-break condition, while more efficient than the old one, is also less obvious.
I'm not sure what the best place to record the proof is: the fractions.py source? A text file alongside the source? For now, I'll record the proof in this issue, which is at least better than nothing.
Note on originality: the proof below is my own, and not a transcription from another source. I haven't seen any proof along these lines in the literature, but this is an extremely well-traveled road, so it's very likely that there's something closely resembling it somewhere.
Proof of correctness of the limit_denominator algorithm
The problem being solved
The limit_denominator algorithm takes a fraction
- All fractions are assumed to be in lowest terms, with positive denominator.
- A candidate approximation to
$m / n$ with denominator bounded by$\ell$ (or just candidate for short) is a fraction$r/s$ with$s \le \ell$ . - Given two candidates
$r/s$ and$t/u$ , say that$r/s$ is better than$t/u$ if either-
$r/s$ is closer to$m/n$ than$t/u$ is, or -
$r/s$ and$t/u$ are equidistant from$m/n$ , and$s \le u$ .
-
- A candidate
$r/s$ is a best approximation if it's better than all (other) candidates.
The goal of this exposition is to convince the reader that Python's implementation of limit_denominator always returns a best approximation for the given inputs.
Some notes:
- There's a tension with the English language: our "better" relation is reflexive (a candidate
$r/s$ is better than itself), while the word "better" suggests an irreflexive relation. But other more accurate wordings ("no worse than", "better or equally good", "at least as good") are more cumbersome, so we'll live with the tension. - We'll show below that outside one corner case, there's a unique best approximation for a given set of arguments. That corner case is the case where
$m/n$ is exactly halfway between two consecutive integers and$\ell = 1$ ; we'll call this the ambiguous case, and we'll return to it below. - For an example of the tie-break condition for "better", consider the case where
$m/n = 99/140 = 0.707142...$ and$\ell = 10$ . Then$7/10 = 0.7$ and$5/7 = 0.714285...$ are candidate approximations, they're equidistant from the target fraction$99/140$ , and they're each closer than any other candidate - any fraction that's closer must be strictly between$7/10$ and$5/7$ , and all fractions in the open interval$(7/10, 5/7)$ have denominator larger than$10$ . Since$5/7$ has smaller denominator than$7/10$ , our definition says that$5/7$ is better than$7/10$ (but not vice versa). So$5/7$ is the unique best approximation.
Simplifying the code
Here's the standard library implementation of Fraction.limit_denominator, shorn of
comments and docstring details, but otherwise unaltered.
class Fraction(numbers.Rational):
# other methods elided ...
def limit_denominator(self, max_denominator=1000000):
"""Closest Fraction to self with denominator at most max_denominator."""
if max_denominator < 1:
raise ValueError("max_denominator should be at least 1")
if self._denominator <= max_denominator:
return Fraction(self)
p0, q0, p1, q1 = 0, 1, 1, 0
n, d = self._numerator, self._denominator
while True:
a = n//d
q2 = q0+a*q1
if q2 > max_denominator:
break
p0, q0, p1, q1 = p1, q1, p0+a*p1, q2
n, d = d, n-a*d
k = (max_denominator-q0)//q1
if 2*d*(q0+k*q1) <= self._denominator:
return Fraction._from_coprime_ints(p1, q1)
else:
return Fraction._from_coprime_ints(p0+k*p1, q0+k*q1)Instead of proving correctness of the above code directly, we'll prove correctness
of an equivalent simplified version. Here's that version:
def limit_denominator(m: int, n: int, l: int) -> tuple[int, int]:
"""
Given a fraction m/n and a positive integer l, return integers r and s such
that r/s is the closest fraction to m/n with denominator bounded by l.
m/n need not be in lowest terms, but n must be positive.
On return, 0 < s <= l and gcd(r, s) = 1.
"""
if l < 1:
raise ValueError("max_denominator should be at least 1")
a, b, p, q, r, s, v = n, m % n, 1, 0, m // n, 1, 1
while 0 < b and q + a // b * s <= l:
a, b, p, q, r, s, v = b, a % b, r, s, p + a // b * r, q + a // b * s, -v
k = (l - q) // s
t, u = p + k * r, q + k * s
return (r, s) if 2 * b * u <= n else (t, u)The differences with the standard library version are mostly cosmetic: there are some de-optimizations (like computing a//b several times); we deal purely with integers to avoid the Fraction unpacking and repacking; some variables are renamed, and we've removed the fast path where a Fraction is returned unaltered if its denominator is already at most l. The most significant difference is that we've unrolled the first loop-and-a-half of the while loop, with the side-effect of shifting the break condition to the top of the loop, where it becomes the loop guard. There's also an extra variable v, which alternates between values 1 and -1. It's not needed for computing the final result, but we'll be making use of it in the statements and proofs below. Finally, the standard library code has no positivity check in the break condition to match the 0 < b check in the loop guard - that check proves to be unnecessary if the fast path is present.
Proof overview
The main loop of limit_denominator is a variant of the extended Euclidean algorithm applied to
The main loop, together with that partial Euclidean step, produces candidate approximations
In more detail, we'll show in the sections below that after exiting the while loop and defining
$(ts - ru)v = 1$ $0 < s \le \ell$ $0 < u \le \ell$ $\ell < s + u$ -
$r/s \le m/n < t/u$ if$v=1$ , and$t/u < m/n \le r/s$ if$v = -1$
Note that the first three properties make
Now
At this point
Details: loop invariants
Both the initial state and the state after every iteration of the while loop satisfy the following six invariants:
$(ps - rq)v = 1$ $(pn - mq)v = a$ $(ms - rn)v = b$ $0 \le b < a$ $0 \le q$ $0 < s \le \ell$
These are all easily proved by induction: they hold for the initial state by inspection, and it's easy to see that the way that the variables are updated within the while loop keeps all of these properties invariant. Note that at the end of an iteration of the while loop, the condition
That
Details: loop termination
For a complete proof of correctness we need to establish that our while loop does not run indefinitely - it must terminate after a finite number of iterations. But this follows immediately from the observation that the value of
Details: after the loop
Here we prove that the five properties mentioned earlier hold.
One of our two possible return values,
That
That
From construction of
We also define an integer
and
and finally,
We next prove that
Finally, note that
We've now established four of the five properties that were used in the proof overview above. The only property that we're missing is that
Choosing the best approximation
By the time we get to the final line of limit_denominator, we know that either
Adding
which is the check that we use in the code.
In the case that
Uniqueness, and the ambiguous case
From earlier, any best approximation must be either limit_denominator returns that best approximation. In this section we explore the corner case where both limit_denominator returns in that situation.
So suppose that both
Conversely, if
the floor and ceiling of
We call the case where
We now prove that in the ambiguous case, the limit_denominator algorithm returns the floor of limit_denominator returns
We first claim that
so we have
Subtracting
But
Optimizations: removing the $0 < b$ check and avoiding unnecessary gcds
The standard library version of the code has a fast path: if the denominator of the target fraction is already bounded by
With the fast path in place, we can simplify the loop guard. Suppose that when the loop guard is evaluated, the
So if we've already excluded the case that
It's worth noting that the simplified listing does require the extra
One other optimization in the standard library version: since we already know that Fraction through the default Fraction constructor, which would require a potentially expensive gcd computation. We can use the Fraction._from_coprime_ints constructor instead.
Activity
hauntsaninja commentedon Aug 6, 2022
(you could link to this issue from the fractions.py source)
mdickinson commentedon Aug 7, 2022
@hauntsaninja Yes, true. I can't help feeling that the proof really belongs with the source, though. (Either that, or hidden away in the docs somewhere.) CPython's been through enough version control and platform changes over the decades that I don't entirely trust something in a GitHub issue not to eventually become separated from the code.
skirpichev commentedon Jun 13, 2025
I would rather disagreed with this decision.
It's a good idea to put somewhere in the code proof (or link to some source, I'm not entirely sure it doesn't exists). We have a lengthy comment on rational arithmetic, which is much more trivial matter.
A link to this issue is better than nothing (as now): we have a lot of references to issues/prs in the code - so, this will not be lost even if the CPython migrates to some alien hosting in a galaxy far far away.
mdickinson commentedon Jun 13, 2025
@skirpichev I'm happy for the core team to do whatever they feel is appropriate here. I originally opened this issue with the intent of acting on it myself. I closed because obviously I'm not going to be acting on this, and I don't feel it's reasonable for me to ask anyone else to do so. (And I'm trying to slowly thin down the mess that's appears under "Your issues" in my GitHub account.)
terryjreedy commentedon Jun 13, 2025
@tim-one You put a lot of information about tim-sort somewhere, either the source or test file or ??. Any suggestion here?
AA-Turner commentedon Jun 15, 2025
Objects/listsort.txt. PerhapsLib/fractions-limit_denominator.rstorfractionsproof.rst?A
tim-one commentedon Jun 15, 2025
A text file in the source tree is fine! I've seen plenty of links to
listsort.txtin the wild, and putting it in the same directory aslistobject.cmakes it easy to find "even by accident" 😉.Whether to use some markup language may be contentious. I wouldn't bother. These aren't user-facing docs, and developers want the proof. "Heavy math" would benefit from LaTeX rendering, but "the math" here consists mostly of simple expressions, and it's easy to read even as plain Python or C comments.
I think there is value, though, in keeping large blocks of text out of code source files.
skirpichev commentedon Jun 15, 2025
Or just Lib/fractions.txt. I would prefer to move from the sources also comments about arithmetics.
Anyway, I'm reopening this.
Prove the simplified limit_denominator listing correct in Lean 4
Lean 4 proof of correctness for Fraction.limit_denominator (#19)
mdickinson commentedon Oct 11, 2026
A formal proof of correctness of the algorithm (after translation into Lean), roughly following the proof in this issue, is here: https://github.com/mdickinson/snippets/tree/1f47f8cc33185c646b679647e88cb24f29fd6a4e/proofs/limit_denominator
skirpichev commentedon Oct 12, 2026
Should we use this URL (like for isqrt) or a shorter one, https://github.com/mdickinson/snippets/tree/main/proofs/limit_denominator?
BTW, now we have InternalDocs/, I suspect it's a good place to put fractions.md with the proof from issue description. Maybe you can open a pr? Next we could also move to that file proofs for arithmetic algorithms.
Perhaps, Khinchin's book "Continued Fractions"?
mdickinson commentedon Oct 12, 2026
Totally up to you. The permalink that I gave stays valid even if things get moved around in that repository in the future. The shorter one might become invalid in time, but is also more likely to stay up to date (e.g., with respect to Lean updates).
I'll leave any PR-opening to the core devs.
Not really what I meant. 🙂 I meant references to something resembling this particular continued-fraction-free proof. (I'm not lacking in references for continued fractions in general - there are at least 20 sitting on my bookshelf.)
skirpichev commentedon Oct 13, 2026
Ok, lets wait a bit if some core devs from the thread will do. If not, I'll open a PR.
I doubt there are.
mdickinson commentedon Oct 23, 2026
I've updated the text of the main issue body to something I'm a bit happier with.
[-]Document correctness proof for Fraction.limit_denominator[/-][+]Document correctness of `Fraction.limit_denominator`[/+]skirpichev commentedon Oct 23, 2026
Ok, here is my attempt to include it in InternalDocs: https://github.com/skirpichev/cpython/blob/fractions-docs/95723/InternalDocs/fractions.md Let me know if it worth a pr in this shape.
I'm planning here also stuff for rational arithmetic algorithms (now in comments). But that should go to a separate commit.
I didn't mention this file in the fractions.py (just like asyncio.md) as the InternalDocs seems a "standard place" for this kind of stuff.
Edit: #160625
Sync PROOF.md with the rewritten text of python/cpython#95723 (#21)
pythongh-95723: setup InternalDocs/fractions.md