From b84181caadc15f3d2f052e480d18202cc609b6c1 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Mon, 13 Jul 2026 10:16:09 -0400 Subject: [PATCH 1/2] Make SearchProperties probes robust to non-primitive Real eltypes (AD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SearchProperties(v::AbstractVector{<:Real})` runs sampled linearity / uniformity / log-linearity probes that coerced intermediate values with `Float64(...)`. `ForwardDiff.Dual <: Real`, but `Float64(::Dual)` is deliberately undefined, so constructing `SearchProperties` on a Dual-valued knot vector — as happens when an interpolation is reconstructed inside `ForwardDiff` and the knots are promoted to Duals — threw a `MethodError`. The `Float64(...)` coercions are redundant for the primitive eltypes: for `Int`/`Float32`/`Float64` the probe expressions are already `Float64`. Drop them and compute in native arithmetic, so the probes work for any ordered `Real` (the classification is taken from the primal and partials are kept intact), while the primitive hot path is byte-for-byte unchanged. Adds a ForwardDiff regression testset differentiating through both values and Dual-valued knots, and wires ForwardDiff into the test targets. Co-Authored-By: Chris Rackauckas Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FvBXVKGujjeaCB3iLwsDeG --- Project.toml | 4 +++- src/search_properties.jl | 17 ++++++++++----- test/core_tests.jl | 47 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/Project.toml b/Project.toml index acc1fb4..1f59660 100644 --- a/Project.toml +++ b/Project.toml @@ -7,6 +7,7 @@ authors = ["Chris Elrod and contributors"] PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" [compat] +ForwardDiff = "0.10, 1" PrecompileTools = "1.0.1" SafeTestsets = "0.1" SciMLTesting = "2.2" @@ -15,10 +16,11 @@ Test = "1.10" julia = "1.10" [extras] +ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" SciMLTesting = "09d9d899-5365-40a9-917a-5f67fddea283" StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["SafeTestsets", "SciMLTesting", "StableRNGs", "Test"] +test = ["ForwardDiff", "SafeTestsets", "SciMLTesting", "StableRNGs", "Test"] diff --git a/src/search_properties.jl b/src/search_properties.jl index d554045..bc11b96 100644 --- a/src/search_properties.jl +++ b/src/search_properties.jl @@ -47,7 +47,12 @@ const _AUTO_LINEAR_REL_TOLERANCE = 1.0e-3 for k in 1:9 kk = 1 + (k * nm1) ÷ 10 expected = v1 + (kk - 1) / nm1 * span - rel_err = Float64(abs(v[kk] - expected) / abs_span) + # Native arithmetic (no `Float64` coercion) so non-primitive + # `Real` eltypes — e.g. `ForwardDiff.Dual` knots differentiated + # through — probe without an invalid scalar conversion. For + # `Int`/`Float32`/`Float64` this expression is already `Float64`, + # so the primitive hot path is unchanged. + rel_err = abs(v[kk] - expected) / abs_span rel_err > max_err && (max_err = rel_err) end return max_err @@ -83,12 +88,12 @@ function _validated_uniform(v::AbstractVector{<:Real}) n < 2 && return false @inbounds begin v1, vn = v[1], v[n] - span = Float64(vn - v1) + span = vn - v1 (iszero(span) || !isfinite(span)) && return false nm1 = n - 1 for i in 2:nm1 expected = v1 + (i - 1) / nm1 * span - abs(Float64(v[i] - expected)) > _UNIFORM_REL_TOLERANCE * abs(span) && + abs(v[i] - expected) > _UNIFORM_REL_TOLERANCE * abs(span) && return false end end @@ -110,8 +115,8 @@ end @inbounds begin v1, vn = v[1], v[n] (v1 <= 0 || vn <= 0 || !isfinite(v1) || !isfinite(vn)) && return false - log_v1 = log(Float64(v1)) - log_vn = log(Float64(vn)) + log_v1 = log(v1) + log_vn = log(vn) span = log_vn - log_v1 (iszero(span) || !isfinite(span)) && return false abs_span = abs(span) @@ -121,7 +126,7 @@ end vk = v[kk] (vk <= 0 || !isfinite(vk)) && return false expected = log_v1 + (kk - 1) / nm1 * span - rel_err = abs(log(Float64(vk)) - expected) / abs_span + rel_err = abs(log(vk) - expected) / abs_span rel_err > tol && return false end end diff --git a/test/core_tests.jl b/test/core_tests.jl index 3366964..ee149f8 100644 --- a/test/core_tests.jl +++ b/test/core_tests.jl @@ -645,6 +645,53 @@ end @test p_nonuniform.inv_step == 0.0 end +@safetestset "SearchProperties with ForwardDiff.Dual knots" begin + using FindFirstFunctions + using FindFirstFunctions: SearchProperties, Auto, searchsorted_last, searchsorted_first + using ForwardDiff + + # `ForwardDiff.Dual <: Real`, so a Dual-valued knot vector — as produced + # when an interpolation is reconstructed inside `ForwardDiff` and the + # knots are promoted to Duals — dispatches to the `<:Real` probe. The + # probes must not force a `Float64(::Dual)` conversion (which errors); + # they must classify from the primal values and keep partials intact. + ForwardDiff.derivative(2.0) do p + t = collect(range(0.0, 1.0; length = 15)) .* p # linear Dual knots + props = SearchProperties(t) + @test props.has_props + @test props.is_linear # linear in index + @test props.is_uniform # evenly spaced + # first_val / inv_step carry the derivative w.r.t. p. + @test ForwardDiff.value(props.first_val) == 0.0 + # sum(t) = 7.5p, so d/dp = 7.5; exercise a value that depends on the + # branch chosen from the probe result. + props.is_linear ? sum(t) : 2 * sum(t) + end == 7.5 + + # Non-uniform Dual knots must probe without error and report not-uniform. + ForwardDiff.derivative(3.0) do p + t = (Float64[0, 1, 2, 4, 8, 16, 20, 21, 22, 40, 41, 100]) .* p + props = SearchProperties(t) + @test props.has_props + @test !props.is_uniform + sum(t) + end + + # Full search path on uniform Dual knots exercises the closed-form + # UniformStep kernel (`Auto` resolves to `KIND_UNIFORM_STEP`). It must + # return the same index as `Base` on the primal values. + ForwardDiff.derivative(2.0) do p + t = collect(range(0.0, 10.0; length = 40)) .* p + tv = map(ForwardDiff.value, t) + auto = Auto(t) + for q in (t[1], t[7], t[end], 5.0 * p, -1.0 * p, 11.0 * p) + @test searchsorted_last(auto, t, q) == searchsortedlast(tv, ForwardDiff.value(q)) + @test searchsorted_first(auto, t, q) == searchsortedfirst(tv, ForwardDiff.value(q)) + end + sum(t) + end +end + @safetestset "Auto{T} parametric + props-aware UniformStep kernel" begin using FindFirstFunctions using FindFirstFunctions: From 75aeb7c2e071348387e550cf4341e0bd50fe132e Mon Sep 17 00:00:00 2001 From: Christopher Rackauckas Date: Mon, 13 Jul 2026 19:49:07 +0000 Subject: [PATCH 2/2] Apply suggestion from @ChrisRackauckas --- src/search_properties.jl | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/search_properties.jl b/src/search_properties.jl index bc11b96..c01d84b 100644 --- a/src/search_properties.jl +++ b/src/search_properties.jl @@ -47,11 +47,6 @@ const _AUTO_LINEAR_REL_TOLERANCE = 1.0e-3 for k in 1:9 kk = 1 + (k * nm1) ÷ 10 expected = v1 + (kk - 1) / nm1 * span - # Native arithmetic (no `Float64` coercion) so non-primitive - # `Real` eltypes — e.g. `ForwardDiff.Dual` knots differentiated - # through — probe without an invalid scalar conversion. For - # `Int`/`Float32`/`Float64` this expression is already `Float64`, - # so the primitive hot path is unchanged. rel_err = abs(v[kk] - expected) / abs_span rel_err > max_err && (max_err = rel_err) end