Make solve_itp terminate, and build its schedule without an overflow - #600
Open
kooshi wants to merge 1 commit into
Open
Make solve_itp terminate, and build its schedule without an overflow#600kooshi wants to merge 1 commit into
solve_itp terminate, and build its schedule without an overflow#600kooshi wants to merge 1 commit into
Conversation
Contributor
This was referenced Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
AI disclosure: these bugs were diagnosed and resolved by Claude Opus 5, working under my guidance to be exceptionally rigorous. I ran into these issues in a real codebase, needed a fix, and have verified that this resolves the issue.
Everything beyond this point is written by Claude.
inv_arclenhangs on long curves and panics on longer ones. Two lines, kurbo 0.13.1, debug build,and this never comes back:
It sits there burning a full core and allocating nothing. The curve is about 1e8 long, and 0.171 is
nothing special: of 999 evenly spaced positions along it, 199 hang the same way. On a curve ten
times longer, 248 of 999. Short curves are fine.
Make the curve longer still and you get a panic instead. In release, where there is no overflow
check, that one hangs too:
I ran into this through
fit_to_bezpath. Its fitter callsinv_arclen(_, 1e-9)on candidatecurves, one candidate came out long enough, and the fit never returned.
Below is what I think is going on and what I did about it. You know this code better than I do, so
treat the diagnosis as a proposal too.
How the loop works, for anyone who has not read the paper
solve_itpfinds where a function crosses zero. You hand it a bracket[a, b]with the functionnegative at one end and positive at the other, and it repeatedly picks a point inside, evaluates
there, and throws away whichever half cannot contain the crossing. The bracket closes in. It stops
once the bracket is narrower than
2 * epsilon, soepsilonis how close to the crossing you areasking it to get.
Bisection would just take the midpoint every time. Safe, and exactly one bit per call. Regula falsi
draws a straight line between the two endpoints and takes where that line crosses zero, which is
usually much faster and occasionally much worse.
ITP is a way of getting the second without giving up the first. Each pass it works out the regula
falsi point
xf, nudges it a little toward the midpoint (xt), and then, and this is the part thatmatters here, refuses to move it further than
raway from the midpoint:ris the interesting quantity.scaled_epsilonis the widest the bracket is still allowed to beat this point in the run: it starts at
epsilon * 2^nmax, wherenmaxis roughly how many halvingsit would take to get from the starting bracket down to
epsilon, plus a few spare passes (n0),and it halves every iteration. Subtract the half-width you actually have and you get
r, which ishow far ahead of that schedule you are running.
So
ris a budget. Ahead of schedule and it is positive, which buys room to move away from themidpoint and trust the interpolated point. Behind schedule and it goes to zero,
xitpis pinned tothe midpoint, and the algorithm turns back into plain bisection. That is the trick: the guessing is
only allowed while there is slack to pay for it.
Bug one: the loop can have no way out
inv_arclenworks out theepsilonit needs like this:Which is the right conversion. You asked for the answer to within
accuracyunits of arc length,the answer comes back as a parameter
tin[0, 1], so the tolerance ontisaccuracydividedby how long the curve is. On a curve 1e8 long at
accuracy = 1e-9, that is about 1e-17.The trouble is that
tis anf64. Around 0.125 the gap between onef64and the next is about2.8e-17. So the bracket is being asked to get narrower than the gap between the numbers it is made
of, which it cannot do. It shrinks until
aandbare neighbours and then stops, because there isnothing left in between.
Meanwhile
while b - a > 2.0 * epsilonis still true, and stays true forever. And the point theloop picks now rounds onto
aorb, soa = xitpassignsato itself. Nothing changes, so thenext pass computes the same point, and the one after that. Here is a run of it, from a small program
that prints what the objective gets asked for:
Two and a half billion calls, all at the same point. It stopped making progress at call 56.
There is a second way in, with no curve involved at all.
b - ais never negative and2.0 * epsilonis always negative for a negativeepsilon, so the exit test cannot go false for anybracket at all. That takes four arguments and a minus sign.
Bug two:
1u64 << nmaxruns out of roomnmaxnever gets bounded at 63:A curve about 1e10 long gives
epsilon = 9.9e-20, andnmaxcomes out at 64. Debug builds panic.Release builds have no check, so the shift wraps to
nmax % 64andscaled_epsilonstarts out afactor of 2^64 too small. Then
ris negative on every pass,xitpis stuck on the midpoint, andthe call lands in bug one instead. So the release build is the worse of the two, not the milder one.
There is also an overflow one line up.
epsilon = 0.0makes(b - a) / epsiloninfinite,inf as usizesaturates tousize::MAX, andn0 + n1_2overflows the addition before the shift isreached.
solve_itpis public and takesepsilondirectly, so that one needs no curve either.The doc comment does ask callers to keep
epsilonabove2^-63timesb - a, and bug two is thatline being crossed. Two things about it. The caller crossing it is kurbo, since someone using
inv_arclenhands over a curve and an accuracy and never sees theepsilonthat gets derived. Andbug one is nowhere near that bound: the first curve above runs at
nmax = 57.The fix
Stop when the floats run out. Three lines in the loop:
Reading them in order.
ris a distance in the paper, so it is never negative there. Unclamped it goes negative as soon asthe bracket falls behind that schedule, and then
x1_2 - r.copysign(sigma)moves the point away fromthe midpoint instead of toward it, which can put it outside the bracket entirely. Clamping it at
zero is the paper's own behaviour: no slack left, so bisect.
The second line handles a point that rounded onto an endpoint. That can happen with plenty of room
still left in the bracket, since the interpolated point only has to land on
aorbby chance.cubicbez_inv_arclendoes exactly that on brackets millions of floats wide. Bisection is thisalgorithm's own fallback, so take it rather than give up on a bracket that can still be narrowed.
The third line is the actual exit. If even the midpoint rounds onto an endpoint, there is genuinely
nothing representable left between
aandb, every later pass would repeat this one, and thisbracket is the tightest that exists. So return it.
That gives termination without a counter. Each pass either returns or removes at least one
representable value from
[a, b], and[a, b]holds a finite number of them.Build the schedule in floating point. One expression:
nmaxcomes out oflog2and gets used as2^nmax. It only ever became an integer so it couldfeed a shift. Take the integer out and both overflow sites go with it, since there is no integer
left to overflow.
Nothing here is approximate.
f64holds2^kexactly for everykbelow 1024, soexp2(k)and(1u64 << k) as f64are the same number for all 64 values the shift could ever have reached.exp2needed one line in thedefine_float_funcs!shim forno_std; libm hasexp2andexp2f.When
nmaxnow comes out infinite, from a zero or denormalepsilon,scaled_epsilonisNaN,the clamp on
rturns that into a clean zero, and the search runs as plain bisection until thefloats run out. So this commit also drops the
2^-63lower bound from the doc comment. Nothingenforces it any more, and
epsilon = 0.0works.A doc change with no behaviour attached.
inv_arclennever said which unitsaccuracyis in.It is arc length. And a curve of arc length
Lcannot tell apart arc lengths closer together thanabout
L * 2^-53, which is roughly 1e-6 on a curve 1e10 long, so asking for 1e-9 there was nevergoing to work. Both now appear in the doc comment.
Why this is one commit and not two
The two fixes are for independent bugs, and I did write them separately. But the schedule change on
its own would leave things worse than they are today. Without the clamp on
r,epsilon = 0.0getsyou
NaNback for a root at 0.9, where today it panics, loudly, in a debug build. Splitting themputs a commit in the middle that trades a loud failure for a silent one, so I would rather not offer
that as a thing to cherry-pick.
What changes for callers
This does not return the same bits as 0.13.1 on every input, and I would rather say so up front.
I ran 3240 calls through both: 5 brackets, 3 root positions, 6 objective shapes, 9 values of
epsilon, 4 values ofn0. Published kurbo returns a root on 1872 of them. This branch gives abit-identical answer on 1865 and a different one on 7.
All 7 are the same objective shape, the one whose slope changes by a hundredfold at the root.
Interpolation does badly there, so the bracket falls behind the schedule,
rgoes negative, andthat is exactly where the clamp changes what happens: published kurbo steps away from the midpoint,
this branch bisects. All 7 answers still land within the
epsilonthe caller asked for, which is whatsolve_itppromises. Some are closer to the true root than before and some are further.On the 1008 inputs where published kurbo overflows the shift and has no answer at all, this branch
returns one.
That comparison is a program rather than something I am asking you to take on trust. It runs a
transcription of the published loop next to whichever kurbo is linked, and it checks itself against
the crates.io release first so the transcription cannot drift. Happy to share it if you want to run
it yourself.
Tests. 191 kurbo unit tests, 55 doctests and 10 polycool tests all pass. Three new ones cover
the loop ending, a schedule past the width of the shift, and
epsilon = 0.0.The smaller fix I passed on
nmax.min(63)changes one token and needs no shim, so it was the first thing I tried. But on the1e10 curve it works out
scaled_epsilonas 0.9158572095555719, where the right answer,1.8317144191111439, is a perfectly ordinary
f64. The clamp costs one factor of two, so it lands onexactly half, and the schedule is wrong from the first pass. It also leaves the addition overflow
untouched.
One thing this does not fix
epsilon = f64::NANmakes the loop test false on the very first evaluation, sosolve_itphandsback the midpoint of the bracket without iterating at all. That is 0.5 for a root at 0.9, both
before and after this branch. Same unchecked parameter as the zero case, but narrowing the type of a
public function's argument felt like a bigger call than a bug fix should make on its own.