@@ -122,6 +122,7 @@ define_float_funcs! {
122122 fn ceil( self ) -> Self => ceil/ceilf;
123123 fn cos( self ) -> Self => cos/cosf;
124124 fn copysign( self , sign: Self ) -> Self => copysign/copysignf;
125+ fn exp2( self ) -> Self => exp2/exp2f;
125126 fn floor( self ) -> Self => floor/floorf;
126127 fn hypot( self , other: Self ) -> Self => hypot/hypotf;
127128 fn ln( self ) -> Self => log/logf;
@@ -642,10 +643,8 @@ fn depressed_cubic_dominant(g: f64, h: f64) -> f64 {
642643/// It is assumed that `ya < 0.0` and `yb > 0.0`, otherwise unexpected
643644/// results may occur.
644645///
645- /// The value of `epsilon` must be larger than 2^-63 times `b - a`,
646- /// otherwise integer overflow may occur. The `a` and `b` parameters
647- /// represent the lower and upper bounds of the bracket searched for a
648- /// solution.
646+ /// The `a` and `b` parameters represent the lower and upper bounds of
647+ /// the bracket searched for a solution.
649648///
650649/// The ITP method has tuning parameters. This implementation hardwires
651650/// k2 to 2, both because it avoids an expensive floating point
@@ -670,6 +669,12 @@ fn depressed_cubic_dominant(g: f64, h: f64) -> f64 {
670669/// be within `epsilon` of the zero crossing. For more detailed analysis,
671670/// again see the paper.
672671///
672+ /// The floats bound that guarantee. A bracket can never be narrower than
673+ /// the spacing between the `f64` values inside it, which near a crossing
674+ /// at `x` is about `f64::EPSILON * x.abs()`. A smaller `epsilon` than
675+ /// that is not an error: the search stops at the tightest bracket there
676+ /// is and returns its midpoint.
677+ ///
673678/// [ITP method]: https://en.wikipedia.org/wiki/ITP_Method
674679/// [An Enhancement of the Bisection Method Average Performance Preserving Minmax Optimality]: https://dl.acm.org/doi/10.1145/3423597
675680#[ allow( clippy:: too_many_arguments) ]
@@ -697,6 +702,9 @@ pub fn solve_itp(
697702///
698703/// Another difference: it returns the bracket that contains the root,
699704/// which may be important if the function has a discontinuity.
705+ ///
706+ /// The returned bracket is narrower than `2 * epsilon`, or, where the
707+ /// floats there cannot manage that, two adjacent values.
700708#[ allow( clippy:: too_many_arguments) ]
701709pub ( crate ) fn solve_itp_fallible < E > (
702710 mut f : impl FnMut ( f64 ) -> Result < f64 , E > ,
@@ -708,12 +716,19 @@ pub(crate) fn solve_itp_fallible<E>(
708716 mut ya : f64 ,
709717 mut yb : f64 ,
710718) -> Result < ( f64 , f64 ) , E > {
711- let n1_2 = ( ( ( b - a) / epsilon) . log2 ( ) . ceil ( ) - 1.0 ) . max ( 0.0 ) as usize ;
712- let nmax = n0 + n1_2;
713- let mut scaled_epsilon = epsilon * ( 1u64 << nmax) as f64 ;
719+ // `nmax` comes out of `log2` and is used as `2^nmax`, so it stays a float. As a `usize` it
720+ // overflowed twice: the shift for any `epsilon` below `(b - a) * 2^-63`, which
721+ // `ParamCurveArclen::inv_arclen` asks for on a long curve, and the addition for
722+ // `epsilon == 0.0`, where `inf as usize` saturates to `usize::MAX`.
723+ let n1_2 = ( ( ( b - a) / epsilon) . log2 ( ) . ceil ( ) - 1.0 ) . max ( 0.0 ) ;
724+ let nmax = n0 as f64 + n1_2;
725+ let mut scaled_epsilon = epsilon * nmax. exp2 ( ) ;
714726 while b - a > 2.0 * epsilon {
715727 let x1_2 = 0.5 * ( a + b) ;
716- let r = scaled_epsilon - 0.5 * ( b - a) ;
728+ // A distance in the paper, so never negative there. Unclamped it goes negative once the
729+ // bracket stops halving within the `nmax` budget, and the step below then moves away
730+ // from the root.
731+ let r = ( scaled_epsilon - 0.5 * ( b - a) ) . max ( 0.0 ) ;
717732 let xf = ( yb * a - ya * b) / ( yb - ya) ;
718733 let sigma = x1_2 - xf;
719734 // This has k2 = 2 hardwired for efficiency.
@@ -728,6 +743,15 @@ pub(crate) fn solve_itp_fallible<E>(
728743 } else {
729744 x1_2 - r. copysign ( sigma)
730745 } ;
746+ // `xitp` can round onto an endpoint while values still sit strictly inside the bracket.
747+ // Bisect rather than give up on a bracket that can still be narrowed.
748+ let xitp = if a < xitp && xitp < b { xitp } else { x1_2 } ;
749+ // Once nothing is representable strictly between `a` and `b`, even the midpoint rounds
750+ // onto an endpoint and every later pass repeats this one. This is the tightest bracket
751+ // there is.
752+ if !( a < xitp && xitp < b) {
753+ return Ok ( ( a, b) ) ;
754+ }
731755 let yitp = f ( xitp) ?;
732756 if yitp > 0.0 {
733757 b = xitp;
@@ -1097,6 +1121,122 @@ mod tests {
10971121 assert ! ( f( x) . abs( ) < 6e-12 ) ;
10981122 }
10991123
1124+ // The sign changes between two adjacent floats and `epsilon` is far below their spacing, so
1125+ // the bracket can never reach `2 * epsilon`. This ran forever before the fix, which
1126+ // `ParamCurveArclen::inv_arclen` reaches on a long curve. The call budget uses the error
1127+ // channel, so a regression fails instead of hanging.
1128+ #[ test]
1129+ fn solve_itp_terminates_when_epsilon_is_below_float_resolution ( ) {
1130+ // A quantity from quadrature never lands on exactly zero, which keeps the
1131+ // `yitp == 0.0` exit shut.
1132+ const Q : f64 = 9.313225746154785e-10 ;
1133+ const ROOT_BELOW : f64 = 0.1251866955333938 ;
1134+
1135+ #[ derive( Debug ) ]
1136+ struct BudgetExhausted ;
1137+
1138+ let root_above = f64:: from_bits ( ROOT_BELOW . to_bits ( ) + 1 ) ;
1139+ let calls = core:: cell:: Cell :: new ( 0_u32 ) ;
1140+ let f = |x : f64 | {
1141+ calls. set ( calls. get ( ) + 1 ) ;
1142+ match calls. get ( ) {
1143+ 0 ..=200 => Ok ( if x <= ROOT_BELOW { -Q } else { Q } ) ,
1144+ _ => Err ( BudgetExhausted ) ,
1145+ }
1146+ } ;
1147+
1148+ let bracket = solve_itp_fallible ( f, 0.0 , 1.0 , 1e-17 , 1 , 0.2 , -Q , Q )
1149+ . expect ( "the loop must exit on its own, not by exhausting the call budget" ) ;
1150+ assert_eq ! ( bracket, ( ROOT_BELOW , root_above) ) ;
1151+ }
1152+
1153+ // An `epsilon` below `(b - a) * 2^-63` pushes `nmax` past 63, which the old `1u64 << nmax`
1154+ // wrapped to `nmax % 64` in release. That leaves `scaled_epsilon` a factor of 2^64 too
1155+ // small, so `r` clamps to zero every pass and `xitp` never leaves the midpoint. The first
1156+ // queried point therefore separates the two with no tolerance needed.
1157+ #[ test]
1158+ fn solve_itp_uses_its_schedule_when_nmax_exceeds_the_shift_budget ( ) {
1159+ // In `[2^-64, 2^-63)`, so `nmax` is 64 for `n0 = 1` over a unit bracket, one past what
1160+ // the shift could express.
1161+ const EPSILON : f64 = 1e-19 ;
1162+ // Far enough from the midpoint to survive the `k1 (b - a)^2` truncation. A root near 0.5
1163+ // would put `xt` back on the midpoint for honest reasons.
1164+ const ROOT : f64 = 0.9 ;
1165+
1166+ #[ derive( Debug ) ]
1167+ struct BudgetExhausted ;
1168+
1169+ let first_query = core:: cell:: Cell :: new ( None ) ;
1170+ let calls = core:: cell:: Cell :: new ( 0_u32 ) ;
1171+ let f = |x : f64 | {
1172+ calls. set ( calls. get ( ) + 1 ) ;
1173+ if first_query. get ( ) . is_none ( ) {
1174+ first_query. set ( Some ( x) ) ;
1175+ }
1176+ // Generous on purpose. The assertions below carry the claim; this only stops a
1177+ // regression from running CI out of time.
1178+ match calls. get ( ) {
1179+ 0 ..=200 => Ok ( x * x - ROOT * ROOT ) ,
1180+ _ => Err ( BudgetExhausted ) ,
1181+ }
1182+ } ;
1183+
1184+ let ( lo, hi) = solve_itp_fallible (
1185+ f,
1186+ 0.0 ,
1187+ 1.0 ,
1188+ EPSILON ,
1189+ 1 ,
1190+ 0.2 ,
1191+ -ROOT * ROOT ,
1192+ 1.0 - ROOT * ROOT ,
1193+ )
1194+ . expect ( "the loop must exit on its own, not by exhausting the call budget" ) ;
1195+
1196+ assert_ne ! (
1197+ first_query. get( ) ,
1198+ Some ( 0.5 ) ,
1199+ "the first query was the midpoint, so `r` was zero: `scaled_epsilon` came from a \
1200+ shift that wrapped"
1201+ ) ;
1202+ assert ! (
1203+ lo <= ROOT && ROOT <= hi,
1204+ "the returned bracket {lo}..{hi} does not contain the root"
1205+ ) ;
1206+ assert ! ( hi - lo < 1e-15 , "the bracket {lo}..{hi} did not converge" ) ;
1207+ }
1208+
1209+ // `epsilon = 0.0` used to overflow the addition above the shift, since `(b - a) / 0.0` is
1210+ // infinite and `inf as usize` saturates to `usize::MAX`. In floating point `nmax` is
1211+ // infinite, `scaled_epsilon` is `NaN`, `r` clamps to zero, and the search bisects. Only a
1212+ // debug build tells the two apart; with checks off the addition wrapped to `n0 - 1`.
1213+ #[ test]
1214+ fn solve_itp_accepts_a_zero_epsilon ( ) {
1215+ const ROOT : f64 = 0.9 ;
1216+
1217+ #[ derive( Debug ) ]
1218+ struct BudgetExhausted ;
1219+
1220+ let calls = core:: cell:: Cell :: new ( 0_u32 ) ;
1221+ let f = |x : f64 | {
1222+ calls. set ( calls. get ( ) + 1 ) ;
1223+ match calls. get ( ) {
1224+ 0 ..=200 => Ok ( x * x - ROOT * ROOT ) ,
1225+ _ => Err ( BudgetExhausted ) ,
1226+ }
1227+ } ;
1228+
1229+ let ( lo, hi) =
1230+ solve_itp_fallible ( f, 0.0 , 1.0 , 0.0 , 1 , 0.2 , -ROOT * ROOT , 1.0 - ROOT * ROOT )
1231+ . expect ( "the loop must exit on its own, not by exhausting the call budget" ) ;
1232+
1233+ assert ! (
1234+ lo <= ROOT && ROOT <= hi,
1235+ "the returned bracket {lo}..{hi} does not contain the root"
1236+ ) ;
1237+ assert ! ( hi - lo < 1e-15 , "the bracket {lo}..{hi} did not converge" ) ;
1238+ }
1239+
11001240 #[ test]
11011241 fn test_inv_arclen ( ) {
11021242 use crate :: { ParamCurve , ParamCurveArclen } ;
0 commit comments