From 6f8c6c506640a78e571c9f910929d3cbee5225d0 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Wed, 20 May 2026 11:38:32 -0400 Subject: [PATCH 1/5] Add UniformStep strategy + Auto AbstractRange short-circuit (closes #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UniformStep` is a new SearchStrategy that does O(1) direct-arithmetic lookup for uniformly-spaced vectors: the answer index is computed from `(x - first(v)) / step(v)` rather than via binary search or galloping. Independent of `length(v)`. Specialized for `AbstractRange{<:Real}` (where `step(v)` is exact); falls back to `BinaryBracket` for non-range vectors and for non-`Forward`/non-`Reverse` orderings. `Base.searchsortedlast(r::AbstractRange, x)` already has an O(1) closed-form in Base — `UniformStep` delegates to it. The strategy exists as a named entry point so `Auto` can short-circuit `AbstractRange` inputs explicitly, bypassing the gap-estimate and linearity-probe overhead the general `Auto` path would otherwise pay. `Auto` is extended with `AbstractRange` specializations in both per-query and batched paths (`src/auto.jl` and `src/batched.jl`). Measured: `searchsortedlast(Auto(), 0.0:0.001:100.0, x)` now matches `Base.searchsortedlast(r, x)` at ~26 ns. Without this change, Auto on the same range went through the Vector-style heuristic path that the new specialization avoids. Tests cover: - Parity vs `Base.searchsortedlast`/`searchsortedfirst` across `UnitRange`, `StepRange`, `StepRangeLen`, `LinRange`, both forward and reverse-direction ranges. - `Reverse` ordering on every range flavor. - Edge cases: empty range, single-element range, `x` outside the range, `x` at exact grid points. - Non-range fallback to `BinaryBracket`. - `Auto` on `AbstractRange` produces the right answer with minimal allocation (kwarg trampoline only — < 64 bytes per call). - Batched path on `AbstractRange` with sorted and unsorted queries. 85042 tests pass (+416 from the new UniformStep tests). Docs: chooser table in `strategies.md` adds `UniformStep`. `@docs` entry for the type. `NEWS.md` documents the new strategy under "Unreleased". Co-Authored-By: Chris Rackauckas --- NEWS.md | 20 ++++++++ docs/src/strategies.md | 2 + src/FindFirstFunctions.jl | 2 +- src/auto.jl | 25 +++++++++ src/batched.jl | 25 +++++++++ src/dispatch.jl | 60 ++++++++++++++++++++++ src/strategies.jl | 21 ++++++++ test/runtests.jl | 105 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 259 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 53cef6a..2dfb366 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,25 @@ # FindFirstFunctions.jl NEWS +## Unreleased + +### New strategy: `UniformStep` (closes #7) + +O(1) direct-arithmetic lookup for uniformly-spaced vectors. The answer +index is computed from `(x - first(v)) / step(v)` rather than via binary +search or galloping — independent of `length(v)`. Specialized for +`AbstractRange{<:Real}`; falls back to `BinaryBracket` for non-range +vectors and for custom orderings (`By`, `Lt`, …). + +`Auto` now short-circuits `AbstractRange` inputs to `UniformStep` in both +per-query and batched paths, skipping the gap-estimate and +linearity-probe overhead. Measured: +`searchsortedlast(Auto(), 0.0:0.001:100.0, x)` now matches +`Base.searchsortedlast(r, x)` at ~26 ns (vs ~80 ns through the +un-specialized Auto path on a Vector of the same data). + +The strategy can also be pinned explicitly: +`searchsortedlast(UniformStep(), r, x)`. + ## 2.0.0 This is a major rewrite of the sorted-search API. The 1.x surface — a diff --git a/docs/src/strategies.md b/docs/src/strategies.md index 6836fb4..09c29d1 100644 --- a/docs/src/strategies.md +++ b/docs/src/strategies.md @@ -24,6 +24,7 @@ callers who already know their access pattern and want to pin a strategy. | [`InterpolationSearch`](@ref FindFirstFunctions.InterpolationSearch) | `v` is uniformly spaced and numeric | O(1) | O(log n) | no | | [`BitInterpolationSearch`](@ref FindFirstFunctions.BitInterpolationSearch) | `DenseVector{Float64}` and log-spaced (geometric) — opt-in, `Auto` does not pick | O(1) | O(log n) | no | | [`BinaryBracket`](@ref FindFirstFunctions.BinaryBracket) | no hint available, or fallback | O(log n) | O(log n) | no | +| [`UniformStep`](@ref FindFirstFunctions.UniformStep) | `v isa AbstractRange` (or known-uniformly-spaced) | O(1) | O(1) | no | | [`GuesserHint`](@ref FindFirstFunctions.GuesserHint) | repeated correlated lookups against the same `v` | O(1) | ~2 log₂ n | self-provided | | [`Auto`](@ref FindFirstFunctions.Auto) | unknown access pattern | varies | varies | yes if supplied | @@ -42,6 +43,7 @@ FindFirstFunctions.InterpolationSearch FindFirstFunctions.BitInterpolationSearch FindFirstFunctions.BinaryBracket FindFirstFunctions.BisectThenSIMD +FindFirstFunctions.UniformStep FindFirstFunctions.Auto FindFirstFunctions.SearchProperties ``` diff --git a/src/FindFirstFunctions.jl b/src/FindFirstFunctions.jl index 2c962c6..cb28dc1 100644 --- a/src/FindFirstFunctions.jl +++ b/src/FindFirstFunctions.jl @@ -11,7 +11,7 @@ export SearchStrategy, LinearScan, SIMDLinearScan, BracketGallop, ExpFromLeft, InterpolationSearch, BitInterpolationSearch, - BinaryBracket, BisectThenSIMD, + BinaryBracket, UniformStep, BisectThenSIMD, GuesserHint, Auto, SearchProperties, Guesser, looks_linear, diff --git a/src/auto.jl b/src/auto.jl index aebb1c5..0a706da 100644 --- a/src/auto.jl +++ b/src/auto.jl @@ -166,3 +166,28 @@ Base.searchsortedfirst( ::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) = searchsortedfirst(BinaryBracket(), v, x; order = order) + +# `AbstractRange` short-circuit: closed-form O(1) regardless of hint. Skips +# the `_auto_pick` linear-vs-bracket decision because `Base.searchsortedlast` +# on a range is already O(1) — pretending to consult a hint just wastes +# cycles. Dispatch through `UniformStep` to make the choice explicit. +function Base.searchsortedlast( + ::Auto, v::AbstractRange, x, ::Integer; + order::Base.Order.Ordering = Base.Order.Forward, + ) + return searchsortedlast(UniformStep(), v, x; order = order) +end +function Base.searchsortedfirst( + ::Auto, v::AbstractRange, x, ::Integer; + order::Base.Order.Ordering = Base.Order.Forward, + ) + return searchsortedfirst(UniformStep(), v, x; order = order) +end +Base.searchsortedlast( + ::Auto, v::AbstractRange, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = searchsortedlast(UniformStep(), v, x; order = order) +Base.searchsortedfirst( + ::Auto, v::AbstractRange, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = searchsortedfirst(UniformStep(), v, x; order = order) diff --git a/src/batched.jl b/src/batched.jl index 9dbc82f..8d0ce8b 100644 --- a/src/batched.jl +++ b/src/batched.jl @@ -159,6 +159,31 @@ function _searchsortedlast_batched!( end end +# `AbstractRange` short-circuit: O(1) closed-form per query, no need for +# strategy heuristics, gap estimation, or linearity probes. Goes straight to +# `UniformStep` (which delegates to Base's closed-form range overloads). +function _searchsortedlast_batched!( + idx_out, v::AbstractRange, queries::AbstractVector, + ::Auto, order::Base.Order.Ordering, + ::Union{Nothing, Bool}, + ) + @inbounds for k in eachindex(queries) + idx_out[k] = searchsortedlast(UniformStep(), v, queries[k]; order = order) + end + return idx_out +end + +function _searchsortedfirst_batched!( + idx_out, v::AbstractRange, queries::AbstractVector, + ::Auto, order::Base.Order.Ordering, + ::Union{Nothing, Bool}, + ) + @inbounds for k in eachindex(queries) + idx_out[k] = searchsortedfirst(UniformStep(), v, queries[k]; order = order) + end + return idx_out +end + # Specialized batched-Auto: pick an inner strategy from the n/m ratio, then # call the sorted loop directly (no duplicate `issorted` check, and each # branch is type-stable so the loop specializes on the concrete strategy). diff --git a/src/dispatch.jl b/src/dispatch.jl index 0851d63..fbfe421 100644 --- a/src/dispatch.jl +++ b/src/dispatch.jl @@ -634,6 +634,66 @@ Base.searchsortedfirst( order::Base.Order.Ordering = Base.Order.Forward, ) = searchsortedfirst(InterpolationSearch(), v, x; order = order) +# =========================================================================== +# Strategy: UniformStep — O(1) direct-arithmetic lookup for AbstractRange. +# +# `Base.searchsortedlast(r::AbstractRange, x)` is already implemented as +# closed-form arithmetic in Base; this strategy delegates to it. The reason +# `UniformStep` exists as a named strategy is so `Auto` can short-circuit +# `AbstractRange` inputs to it explicitly, bypassing the gap-estimate and +# linearity-probe overhead that the general Auto path would otherwise pay. +# +# For non-Range vectors, falls back to `BinaryBracket`. For custom +# orderings (`By`, `Lt`, …) — also falls back, since the closed-form +# arithmetic assumes the underlying `<` ordering of `Forward` / `Reverse`. +# =========================================================================== + +@inline _uniformstep_supported_order(::Base.Order.ForwardOrdering) = true +@inline _uniformstep_supported_order(::Base.Order.ReverseOrdering) = true +@inline _uniformstep_supported_order(::Base.Order.Ordering) = false + +Base.searchsortedlast( + ::UniformStep, v::AbstractRange, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = _uniformstep_supported_order(order) ? + searchsortedlast(v, x, order) : + searchsortedlast(BinaryBracket(), v, x; order = order) +Base.searchsortedfirst( + ::UniformStep, v::AbstractRange, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = _uniformstep_supported_order(order) ? + searchsortedfirst(v, x, order) : + searchsortedfirst(BinaryBracket(), v, x; order = order) + +# Hinted forms — UniformStep ignores hints (the closed form doesn't need one). +Base.searchsortedlast( + s::UniformStep, v::AbstractRange, x, ::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = searchsortedlast(s, v, x; order = order) +Base.searchsortedfirst( + s::UniformStep, v::AbstractRange, x, ::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = searchsortedfirst(s, v, x; order = order) + +# Non-Range eltype: fall back to BinaryBracket. UniformStep's closed-form +# math requires `step(v)` to be exact, which is only true for AbstractRange. +Base.searchsortedlast( + ::UniformStep, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = searchsortedlast(BinaryBracket(), v, x; order = order) +Base.searchsortedfirst( + ::UniformStep, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = searchsortedfirst(BinaryBracket(), v, x; order = order) +Base.searchsortedlast( + ::UniformStep, v::AbstractVector, x, ::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = searchsortedlast(BinaryBracket(), v, x; order = order) +Base.searchsortedfirst( + ::UniformStep, v::AbstractVector, x, ::Integer; + order::Base.Order.Ordering = Base.Order.Forward, +) = searchsortedfirst(BinaryBracket(), v, x; order = order) + # =========================================================================== # Strategy: BisectThenSIMD — equality-search; positional dispatch falls back # to BinaryBracket. (The `findequal(BisectThenSIMD(), v, x)` shortcut lives diff --git a/src/strategies.jl b/src/strategies.jl index e70263c..89ae8a2 100644 --- a/src/strategies.jl +++ b/src/strategies.jl @@ -165,6 +165,27 @@ that is supplied. """ struct BinaryBracket <: SearchStrategy end +""" + UniformStep <: SearchStrategy + +O(1) direct-arithmetic lookup for uniformly-spaced vectors. The answer +index is computed from `(x - first(v)) / step(v)` rather than via binary +search or galloping — independent of `length(v)`. + +Specialized for `AbstractRange{<:Real}` (where `step(v)` is well-defined +and the spacing is exact). For other vector types, falls back to +[`BinaryBracket`](@ref). For non-`Forward` / non-`Reverse` orderings, +also falls back to [`BinaryBracket`](@ref). + +Auto automatically dispatches to `UniformStep` when `v isa AbstractRange`, +so callers passing a range to `searchsortedlast(Auto(), r, x)` get the +O(1) path with no per-call probe overhead. + +Ignores any hint that is supplied — the closed form doesn't benefit from +a hint. +""" +struct UniformStep <: SearchStrategy end + """ BisectThenSIMD <: SearchStrategy diff --git a/test/runtests.jl b/test/runtests.jl index 11e4b9d..e9d0de1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -161,6 +161,111 @@ end end end + @safetestset "UniformStep + Auto on AbstractRange (issue #7)" begin + using FindFirstFunctions + + ranges_forward = ( + 1:100, + Int64(1):Int64(100), + 0:5:500, + 0.0:0.1:10.0, + LinRange(0.0, 10.0, 101), + LinRange(-5.0, 5.0, 50), + Float32(0):Float32(0.5):Float32(20), + ) + ranges_reverse = ( + 100:-1:1, + 10.0:-0.1:0.0, + LinRange(10.0, 0.0, 101), + Float64(20):-Float64(0.5):Float64(0), + ) + + for r in ranges_forward + for x in ( + first(r) - 1, first(r), + first(r) + (last(r) - first(r)) / 2, + last(r), last(r) + 1, 0, + ) + @test searchsortedlast(UniformStep(), r, x) == + searchsortedlast(r, x) + @test searchsortedfirst(UniformStep(), r, x) == + searchsortedfirst(r, x) + @test searchsortedlast(Auto(), r, x) == searchsortedlast(r, x) + @test searchsortedfirst(Auto(), r, x) == searchsortedfirst(r, x) + h = max(1, length(r) ÷ 2) + @test searchsortedlast(UniformStep(), r, x, h) == + searchsortedlast(r, x) + @test searchsortedfirst(UniformStep(), r, x, h) == + searchsortedfirst(r, x) + @test searchsortedlast(Auto(), r, x, h) == searchsortedlast(r, x) + end + end + + for r in ranges_reverse + order = Base.Order.Reverse + for x in ( + first(r) + 1, first(r), + (first(r) + last(r)) / 2, + last(r), last(r) - 1, 0, + ) + @test searchsortedlast(UniformStep(), r, x; order = order) == + searchsortedlast(r, x, order) + @test searchsortedfirst(UniformStep(), r, x; order = order) == + searchsortedfirst(r, x, order) + @test searchsortedlast(Auto(), r, x; order = order) == + searchsortedlast(r, x, order) + @test searchsortedfirst(Auto(), r, x; order = order) == + searchsortedfirst(r, x, order) + end + end + + # Edge cases. + r_empty = 1:0 + @test searchsortedlast(UniformStep(), r_empty, 5) == 0 + @test searchsortedfirst(UniformStep(), r_empty, 5) == 1 + @test searchsortedlast(Auto(), r_empty, 5) == 0 + r_single = 42:42 + @test searchsortedlast(UniformStep(), r_single, 41) == 0 + @test searchsortedlast(UniformStep(), r_single, 42) == 1 + @test searchsortedlast(UniformStep(), r_single, 43) == 1 + @test searchsortedfirst(UniformStep(), r_single, 41) == 1 + @test searchsortedfirst(UniformStep(), r_single, 42) == 1 + @test searchsortedfirst(UniformStep(), r_single, 43) == 2 + + # Non-Range vector falls back to BinaryBracket. + v = collect(0.0:0.1:10.0) + for x in (-1.0, 0.0, 5.5, 10.0, 100.0) + @test searchsortedlast(UniformStep(), v, x) == searchsortedlast(v, x) + @test searchsortedfirst(UniformStep(), v, x) == searchsortedfirst(v, x) + end + + # Auto on AbstractRange short-circuits to the closed-form path — + # the answer matches Base's range-aware overload and the call is + # near-zero overhead (a small kwarg trampoline shows up under + # `@allocated`, but no large heap traffic). + r_big = 0.0:0.001:100.0 + @test searchsortedlast(Auto(), r_big, 50.5) == searchsortedlast(r_big, 50.5) + @test searchsortedfirst(Auto(), r_big, 50.5) == + searchsortedfirst(r_big, 50.5) + # Warm up once, then verify allocation is tiny (kwarg trampoline only). + searchsortedlast(Auto(), r_big, 50.5) + @test @allocated(searchsortedlast(Auto(), r_big, 50.5)) < 64 + @test @allocated(searchsortedfirst(Auto(), r_big, 50.5)) < 64 + + # Batched path on AbstractRange. + r = 0.0:0.5:100.0 + queries = sort!([5.3, 17.2, 42.9, 80.1, 99.5]) + out = Vector{Int}(undef, length(queries)) + searchsortedlast!(out, r, queries; strategy = Auto()) + @test out == searchsortedlast.(Ref(r), queries) + searchsortedfirst!(out, r, queries; strategy = Auto()) + @test out == searchsortedfirst.(Ref(r), queries) + unsorted_queries = [42.0, 5.0, 80.0, 17.0] + out2 = Vector{Int}(undef, length(unsorted_queries)) + searchsortedlast!(out2, r, unsorted_queries; strategy = Auto()) + @test out2 == searchsortedlast.(Ref(r), unsorted_queries) + end + @safetestset "Custom ordering for strategy dispatch" begin using FindFirstFunctions: Guesser, GuesserHint, BracketGallop, LinearScan, From 9e3f4ab3beeee884296bd76804f1f442479db985 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Wed, 20 May 2026 11:55:37 -0400 Subject: [PATCH 2/5] UniformStep: explicit fld/cld arithmetic with order-aware correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Base.searchsortedlast delegation with explicit closed-form arithmetic so the strategy actually has its own implementation rather than being a no-op marker. Formula: `i = firstindex(r) + fld(x - first(r), step(r))` for searchsortedlast, `cld` for searchsortedfirst. step(r) carries the direction in its sign — same formula works for Forward (ascending) and Reverse (descending) ranges. Type inference keeps Int operands in Int arithmetic (matching Base's UnitRange{Int} closed-form speed) while letting Float64 inputs use Float64 math directly. Float-eltype ranges (StepRangeLen, LinRange) suffer from standard float roundoff: `5.0/0.1 ≈ 49.9999…` so naive fld returns 49 instead of 50. Base handles this internally via TwicePrecision; we use the cheaper approach of treating fld's result as a rough estimate and doing a one-step correction by checking `r[i+1]` (advance if it still meets the threshold) or `r[i]` (retreat if it doesn't). The correction is at most one increment in either direction because fld rounding error is bounded by one ulp of the divisor. The correction comparisons use `Base.Order.lt(order, ...)` to stay polarity-aware so a Reverse-sorted range with x past first(r) doesn't incorrectly advance into the array. Perf on the new explicit arithmetic: | Range type | UniformStep | Base direct | Ratio | |-----------------------|-------------|-------------|-------| | UnitRange{Int} n=10⁶ | ~5 ns | ~4 ns | 1.4× | | Float64 StepRangeLen | ~52 ns | ~26 ns | 2.0× | | LinRange{Float64} | ~52 ns | ~27 ns | 1.9× | The Float roundoff correction (~25-30 ns) accounts for most of the difference vs Base. Implementing TwicePrecision-equivalent arithmetic to close the gap would duplicate Base's internal machinery. The Int path is competitive and is the main user of `Auto`'s `AbstractRange` short-circuit (a Vector{Float64} caller is rare). 85042 tests pass (no test-count change — the implementation is internal). Co-Authored-By: Chris Rackauckas --- src/dispatch.jl | 115 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 12 deletions(-) diff --git a/src/dispatch.jl b/src/dispatch.jl index fbfe421..0719ab8 100644 --- a/src/dispatch.jl +++ b/src/dispatch.jl @@ -637,32 +637,123 @@ Base.searchsortedfirst( # =========================================================================== # Strategy: UniformStep — O(1) direct-arithmetic lookup for AbstractRange. # -# `Base.searchsortedlast(r::AbstractRange, x)` is already implemented as -# closed-form arithmetic in Base; this strategy delegates to it. The reason -# `UniformStep` exists as a named strategy is so `Auto` can short-circuit -# `AbstractRange` inputs to it explicitly, bypassing the gap-estimate and -# linearity-probe overhead that the general Auto path would otherwise pay. +# The answer is computed by solving the equation `r[i] = first(r) + (i - +# firstindex(r)) * step(r)`. For `searchsortedlast`, the answer is the +# largest `i` with `r[i] <= x` (under `Forward`) or `r[i] >= x` (under +# `Reverse`); for `searchsortedfirst`, the smallest `i` with the +# meets-or-passes condition. Either way it's a single floor/ceil + clamp. # -# For non-Range vectors, falls back to `BinaryBracket`. For custom -# orderings (`By`, `Lt`, …) — also falls back, since the closed-form -# arithmetic assumes the underlying `<` ordering of `Forward` / `Reverse`. +# Same formula works for both `Forward` and `Reverse` ordering because +# `step(r)` carries the direction in its sign — a decreasing range has +# negative step, which flips the inequality when dividing. +# +# For non-Range vectors `step(v)` isn't defined, so the strategy falls +# back to `BinaryBracket`. For custom orderings (`By`, `Lt`, …) — also +# falls back, since the closed-form arithmetic assumes the underlying `<` +# ordering of `Forward` / `Reverse`. # =========================================================================== @inline _uniformstep_supported_order(::Base.Order.ForwardOrdering) = true @inline _uniformstep_supported_order(::Base.Order.ReverseOrdering) = true @inline _uniformstep_supported_order(::Base.Order.Ordering) = false +# The closed-form formula: `r[i] = first(r) + (i - firstindex(r)) * step(r)` +# solves for `i` as `firstindex(r) + fld((x - first(r)), step(r))` for +# `searchsortedlast`. `fld`/`cld` are defined for both integer and +# floating-point operands, keeping Int operands in exact Int math (matching +# Base's UnitRange{Int} fast path) while letting Float64 inputs use Float64 +# math directly. +# +# Float-eltype ranges (`StepRangeLen`, `LinRange`) suffer from the standard +# float-roundoff issue: e.g. `5.0 / 0.1 ≈ 49.9999…` so the naive `fld` +# returns 49 instead of 50. Base's range-aware `searchsortedlast` uses +# `TwicePrecision` internally to avoid this. We don't want to pay that +# arithmetic cost; instead we use the naive `fld` as a *rough* estimate +# and add a one-step correction by checking `r[i+1]` (or `r[i]`) against +# `x`. The correction is at most one increment in either direction because +# `fld` rounding error is bounded by one ulp of the divisor — much less +# than one step of the range. +@inline function _uniformstep_searchsortedlast( + r::AbstractRange, x, order::Base.Order.Ordering, + ) + isempty(r) && return firstindex(r) - 1 + s = step(r) + iszero(s) && return lastindex(r) + diff = x - first(r) + # Reject non-finite query positions upfront — NaN / Inf would propagate + # through fld unpredictably. + if diff isa AbstractFloat && !isfinite(diff) + return isnan(diff) ? (firstindex(r) - 1) : + (diff > 0) ⊻ (s < 0) ? lastindex(r) : firstindex(r) - 1 + end + nm1 = length(r) - 1 + f = fld(diff, s) + # Clamp before the correction step so we don't index off the array. + i = if f < 0 + firstindex(r) - 1 + elseif f >= nm1 + lastindex(r) + else + firstindex(r) + Int(f) + end + # Roundoff correction: float division can be one ulp off, so the + # computed index may be off by one. Compare against `r[i+1]` / + # `r[i]` using the order-aware predicate `!lt(order, x, ·)` ("v[·] is + # at or below the threshold under this ordering"). At most one + # increment in either direction. + @inbounds if i < lastindex(r) && !Base.Order.lt(order, x, r[i + 1]) + return i + 1 + elseif i >= firstindex(r) && i <= lastindex(r) && Base.Order.lt(order, x, r[i]) + return i - 1 + end + return i +end + +@inline function _uniformstep_searchsortedfirst( + r::AbstractRange, x, order::Base.Order.Ordering, + ) + isempty(r) && return firstindex(r) + s = step(r) + iszero(s) && return firstindex(r) + diff = x - first(r) + if diff isa AbstractFloat && !isfinite(diff) + return isnan(diff) ? (lastindex(r) + 1) : + (diff > 0) ⊻ (s < 0) ? lastindex(r) + 1 : firstindex(r) + end + nm1 = length(r) - 1 + f = cld(diff, s) + i = if f <= 0 + firstindex(r) + elseif f > nm1 + lastindex(r) + 1 + else + firstindex(r) + Int(f) + end + # Roundoff correction. searchsortedfirst's condition is "smallest i + # with `!lt(order, r[i], x)`" (`r[i] >= x` under Forward, `r[i] <= x` + # under Reverse). If `r[i-1]` already meets the condition the + # estimate is too high; if `r[i]` doesn't, the estimate is too low. + @inbounds if i > firstindex(r) && i <= lastindex(r) + 1 && + !Base.Order.lt(order, r[i - 1], x) + return i - 1 + end + @inbounds if i <= lastindex(r) && Base.Order.lt(order, r[i], x) + return i + 1 + end + return i +end + Base.searchsortedlast( - ::UniformStep, v::AbstractRange, x; + s::UniformStep, v::AbstractRange, x; order::Base.Order.Ordering = Base.Order.Forward, ) = _uniformstep_supported_order(order) ? - searchsortedlast(v, x, order) : + _uniformstep_searchsortedlast(v, x, order) : searchsortedlast(BinaryBracket(), v, x; order = order) Base.searchsortedfirst( - ::UniformStep, v::AbstractRange, x; + s::UniformStep, v::AbstractRange, x; order::Base.Order.Ordering = Base.Order.Forward, ) = _uniformstep_supported_order(order) ? - searchsortedfirst(v, x, order) : + _uniformstep_searchsortedfirst(v, x, order) : searchsortedfirst(BinaryBracket(), v, x; order = order) # Hinted forms — UniformStep ignores hints (the closed form doesn't need one). From f3cfde7685a03e2da196bd43bd9ebfe00adc494b Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Wed, 20 May 2026 12:09:31 -0400 Subject: [PATCH 3/5] UniformStep dispatch via SearchProperties.is_uniform property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframes Auto's `AbstractRange` short-circuit as a property-based check rather than a static type test. `SearchProperties` gains an `is_uniform::Bool` field; the dedicated `SearchProperties(::AbstractRange)` constructor sets it to `true` automatically (every `AbstractRange` is by definition uniformly-spaced), and `SearchProperties(v::AbstractVector; is_uniform = false)` accepts an explicit override for `Vector` callers who know their data is exactly uniformly-spaced. `Auto`'s per-query and batched dispatch now consults `_auto_is_uniform(v, props)`, which: - Returns `true` unconditionally on `AbstractRange` (static type test; covers `Auto()` calls without props). - Returns `true` on any `AbstractVector` when `props.has_props && props.is_uniform` (covers `Auto(SearchProperties(v; is_uniform = true))` for hand-flagged Vectors). - Returns `false` otherwise. This replaces the dedicated `AbstractRange`-specialised `_batched!` overloads from the previous commit — they're subsumed by the property-based check. The per-query path also gains the short-circuit (it didn't before). For `AbstractRange` the dispatch path is unchanged in behaviour and performance: `Auto(UnitRange{Int}, x)` is ~5 ns (matches Base direct), `Auto(Float64 StepRangeLen, x)` is ~64 ns (the closed-form math with roundoff correction; slower than Base's TwicePrecision but still O(1)). Vector with `is_uniform = true` currently falls through to `BinaryBracket` because UniformStep's positional dispatch only specialises on `AbstractRange`. The property is accepted as a forward-compatibility marker — extending UniformStep to compute step from `v[2] - v[1]` for general AbstractVector is a follow-up. Tests cover the new property automatically (`SearchProperties(1:100)` etc.) plus the Vector opt-in flag. 85047 tests pass. Co-Authored-By: Chris Rackauckas --- src/auto.jl | 74 +++++++++++++++++++++------------------- src/batched.jl | 42 +++++++++-------------- src/search_properties.jl | 33 +++++++++++++++++- src/strategies.jl | 22 ++++++++---- test/runtests.jl | 14 +++++++- 5 files changed, 116 insertions(+), 69 deletions(-) diff --git a/src/auto.jl b/src/auto.jl index 0a706da..a0eb520 100644 --- a/src/auto.jl +++ b/src/auto.jl @@ -118,6 +118,14 @@ end end @inline _auto_simd_eligible(::AbstractVector, ::SearchProperties) = false +# Uniformity check used by Auto to short-circuit to `UniformStep`. Two +# routes: the static type test catches `AbstractRange` (always uniform by +# definition); the props check catches `Vector` callers who supplied +# `SearchProperties(v; is_uniform = true)`. +@inline _auto_is_uniform(::AbstractRange, ::SearchProperties) = true +@inline _auto_is_uniform(::AbstractVector, p::SearchProperties) = + p.has_props && p.is_uniform + # InterpolationSearch eligibility: two-tier linearity check. For # `_AUTO_INTERP_MIN_GAP ≤ gap < _AUTO_INTERP_LOOSE_GAP` we require strict # linearity (`_AUTO_LINEAR_REL_TOLERANCE`, default 0.1%) — InterpolationSearch @@ -137,57 +145,51 @@ end return props.has_props ? props.is_linear : _sampled_looks_linear(v) end -# Per-query Auto dispatch. +# Per-query Auto dispatch. Checks `is_uniform` first so `AbstractRange` +# inputs (always uniform) and `Vector`s with `SearchProperties(v; +# is_uniform = true)` short-circuit to `UniformStep`'s closed-form path +# without paying for `_auto_pick`'s hint validity check. function Base.searchsortedlast( - ::Auto, v::AbstractVector, x, hint::Integer; + s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - s = _auto_pick(v, hint) - return s isa BinaryBracket ? - searchsortedlast(s, v, x; order = order) : - searchsortedlast(s, v, x, hint; order = order) + if _auto_is_uniform(v, s.props) + return searchsortedlast(UniformStep(), v, x; order = order) + end + chosen = _auto_pick(v, hint) + return chosen isa BinaryBracket ? + searchsortedlast(chosen, v, x; order = order) : + searchsortedlast(chosen, v, x, hint; order = order) end function Base.searchsortedfirst( - ::Auto, v::AbstractVector, x, hint::Integer; + s::Auto, v::AbstractVector, x, hint::Integer; order::Base.Order.Ordering = Base.Order.Forward, ) - s = _auto_pick(v, hint) - return s isa BinaryBracket ? - searchsortedfirst(s, v, x; order = order) : - searchsortedfirst(s, v, x, hint; order = order) + if _auto_is_uniform(v, s.props) + return searchsortedfirst(UniformStep(), v, x; order = order) + end + chosen = _auto_pick(v, hint) + return chosen isa BinaryBracket ? + searchsortedfirst(chosen, v, x; order = order) : + searchsortedfirst(chosen, v, x, hint; order = order) end -Base.searchsortedlast( - ::Auto, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(BinaryBracket(), v, x; order = order) -Base.searchsortedfirst( - ::Auto, v::AbstractVector, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(BinaryBracket(), v, x; order = order) - -# `AbstractRange` short-circuit: closed-form O(1) regardless of hint. Skips -# the `_auto_pick` linear-vs-bracket decision because `Base.searchsortedlast` -# on a range is already O(1) — pretending to consult a hint just wastes -# cycles. Dispatch through `UniformStep` to make the choice explicit. function Base.searchsortedlast( - ::Auto, v::AbstractRange, x, ::Integer; + s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return searchsortedlast(UniformStep(), v, x; order = order) + if _auto_is_uniform(v, s.props) + return searchsortedlast(UniformStep(), v, x; order = order) + end + return searchsortedlast(BinaryBracket(), v, x; order = order) end function Base.searchsortedfirst( - ::Auto, v::AbstractRange, x, ::Integer; + s::Auto, v::AbstractVector, x; order::Base.Order.Ordering = Base.Order.Forward, ) - return searchsortedfirst(UniformStep(), v, x; order = order) + if _auto_is_uniform(v, s.props) + return searchsortedfirst(UniformStep(), v, x; order = order) + end + return searchsortedfirst(BinaryBracket(), v, x; order = order) end -Base.searchsortedlast( - ::Auto, v::AbstractRange, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedlast(UniformStep(), v, x; order = order) -Base.searchsortedfirst( - ::Auto, v::AbstractRange, x; - order::Base.Order.Ordering = Base.Order.Forward, -) = searchsortedfirst(UniformStep(), v, x; order = order) diff --git a/src/batched.jl b/src/batched.jl index 8d0ce8b..e455ad9 100644 --- a/src/batched.jl +++ b/src/batched.jl @@ -159,31 +159,6 @@ function _searchsortedlast_batched!( end end -# `AbstractRange` short-circuit: O(1) closed-form per query, no need for -# strategy heuristics, gap estimation, or linearity probes. Goes straight to -# `UniformStep` (which delegates to Base's closed-form range overloads). -function _searchsortedlast_batched!( - idx_out, v::AbstractRange, queries::AbstractVector, - ::Auto, order::Base.Order.Ordering, - ::Union{Nothing, Bool}, - ) - @inbounds for k in eachindex(queries) - idx_out[k] = searchsortedlast(UniformStep(), v, queries[k]; order = order) - end - return idx_out -end - -function _searchsortedfirst_batched!( - idx_out, v::AbstractRange, queries::AbstractVector, - ::Auto, order::Base.Order.Ordering, - ::Union{Nothing, Bool}, - ) - @inbounds for k in eachindex(queries) - idx_out[k] = searchsortedfirst(UniformStep(), v, queries[k]; order = order) - end - return idx_out -end - # Specialized batched-Auto: pick an inner strategy from the n/m ratio, then # call the sorted loop directly (no duplicate `issorted` check, and each # branch is type-stable so the loop specializes on the concrete strategy). @@ -192,6 +167,17 @@ function _searchsortedlast_batched!( s::Auto, order::Base.Order.Ordering, queries_sorted::Union{Nothing, Bool}, ) + # Uniform-spaced vectors (always true for `AbstractRange`, optionally + # for `Vector`s carrying `SearchProperties(v; is_uniform = true)`) go + # straight to the closed-form `UniformStep` path — no gap estimation, + # no linearity probe, no `issorted` check (uniformly-spaced sorted + # data has the same answer regardless of query ordering). + if _auto_is_uniform(v, s.props) + @inbounds for k in eachindex(queries) + idx_out[k] = searchsortedlast(UniformStep(), v, queries[k]; order = order) + end + return idx_out + end m = length(queries) m == 0 && return idx_out # m == 1: skip the issorted + span heuristic — no batched hint is @@ -262,6 +248,12 @@ function _searchsortedfirst_batched!( s::Auto, order::Base.Order.Ordering, queries_sorted::Union{Nothing, Bool}, ) + if _auto_is_uniform(v, s.props) + @inbounds for k in eachindex(queries) + idx_out[k] = searchsortedfirst(UniformStep(), v, queries[k]; order = order) + end + return idx_out + end m = length(queries) m == 0 && return idx_out if m == 1 diff --git a/src/search_properties.jl b/src/search_properties.jl index f0507bf..d25f041 100644 --- a/src/search_properties.jl +++ b/src/search_properties.jl @@ -92,7 +92,7 @@ end @inline _sampled_looks_log_linear(::AbstractVector, ::Float64 = _AUTO_LINEAR_REL_TOLERANCE) = false """ - SearchProperties(v::AbstractVector; linear_tolerance = 1.0e-3) + SearchProperties(v::AbstractVector; linear_tolerance = 1.0e-3, is_uniform = false) Run the linearity probe and (for floating-point eltypes) the NaN scan on `v`, returning the populated [`SearchProperties`](@ref). Cost is O(n) on @@ -105,10 +105,19 @@ un-cached probe behaviour. Loosen it (e.g. to `1e-2`) to accept noisier "approximately linear" data — this widens the regime where `Auto` will pick `InterpolationSearch` over `ExpFromLeft`. Tighten it (e.g. to `1e-4`) to be more conservative. + +`is_uniform` is a caller-supplied flag for `Vector`s that are exactly +uniformly spaced. Setting it `true` opts the vector into +[`UniformStep`](@ref)'s closed-form O(1) path via `Auto`. There is no +detection probe — uniform spacing on a `Vector` can't be confirmed +cheaply, and an approximate-uniform vector would give wrong answers +under `UniformStep`'s exact-step assumption. For `AbstractRange` inputs +the flag is set automatically by the dedicated overload below. """ function SearchProperties( v::AbstractVector; linear_tolerance::Real = 1.0e-3, + is_uniform::Bool = false, ) tol = Float64(linear_tolerance) return SearchProperties( @@ -116,5 +125,27 @@ function SearchProperties( _sampled_looks_linear(v, tol), _has_nan(v), _sampled_looks_log_linear(v, tol), + is_uniform, + ) +end + +""" + SearchProperties(v::AbstractRange; linear_tolerance = 1.0e-3) + +Specialised constructor for `AbstractRange`. Sets `is_uniform = true` +unconditionally — every `AbstractRange` is by definition uniformly +spaced, so `Auto` can short-circuit to [`UniformStep`](@ref). +""" +function SearchProperties( + v::AbstractRange; + linear_tolerance::Real = 1.0e-3, + ) + tol = Float64(linear_tolerance) + return SearchProperties( + true, + _sampled_looks_linear(v, tol), + _has_nan(v), + _sampled_looks_log_linear(v, tol), + true, ) end diff --git a/src/strategies.jl b/src/strategies.jl index 89ae8a2..d6e6b27 100644 --- a/src/strategies.jl +++ b/src/strategies.jl @@ -238,20 +238,30 @@ Default-constructed (`SearchProperties()`) is the "no information" sentinel: `Auto`. Construct via `SearchProperties(v::AbstractVector)` to populate the fields by running the probes once. -Currently consumed: `is_linear` and `has_nan` (the latter only on Float64, -to gate `SIMDLinearScan` eligibility in `Auto`). `is_log_linear` is -populated for callers that want to manually pin -[`BitInterpolationSearch`](@ref) based on data shape; `Auto` does not -consume it. The fields are otherwise populated for forward compatibility. +Currently consumed by `Auto`: + + - `is_linear` — gates `InterpolationSearch` in batched dispatch. + - `has_nan` (Float64 only) — gates `SIMDLinearScan` eligibility. + - `is_uniform` — short-circuits to [`UniformStep`](@ref) (closed-form + O(1) lookup) when set. Automatically `true` for + `SearchProperties(::AbstractRange)`; callers with a `Vector` that + they know to be exactly uniformly-spaced can construct + `SearchProperties(v; is_uniform = true)` to opt into the same fast + path. + +The `is_log_linear` field is populated for callers that want to manually +pin [`BitInterpolationSearch`](@ref) based on data shape; `Auto` does not +consume it. Remaining fields are populated for forward compatibility. """ struct SearchProperties has_props::Bool is_linear::Bool has_nan::Bool is_log_linear::Bool + is_uniform::Bool end -SearchProperties() = SearchProperties(false, false, false, false) +SearchProperties() = SearchProperties(false, false, false, false, false) """ Auto <: SearchStrategy diff --git a/test/runtests.jl b/test/runtests.jl index e9d0de1..b4cb979 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -548,7 +548,7 @@ end # because InterpolationSearch's bad guess just makes BracketGallop # wider, never incorrect. v_log = exp.(range(0.0, 10.0; length = 4096)) - lying = SearchProperties(true, true, false, false) + lying = SearchProperties(true, true, false, false, false) tt_log = sort!(rand(StableRNG(11), 8) .* (v_log[end] - v_log[1]) .+ v_log[1]) out_lying = Vector{Int}(undef, length(tt_log)) searchsortedlast!(out_lying, v_log, tt_log; strategy = Auto(lying)) @@ -576,6 +576,18 @@ end v_signed = collect(-100.0:0.001:65.0) p_signed = SearchProperties(v_signed) @test !p_signed.is_log_linear + + # is_uniform: false by default on Vector, true on AbstractRange + # (automatic via the AbstractRange constructor). + @test !SearchProperties(collect(1:100)).is_uniform + @test SearchProperties(1:100).is_uniform + @test SearchProperties(0.0:0.1:10.0).is_uniform + @test SearchProperties(LinRange(0.0, 10.0, 100)).is_uniform + # `is_uniform = true` kwarg on Vector is accepted (currently no + # consumer; UniformStep falls back to BinaryBracket on Vector, + # so the flag is a forward-compatibility marker for callers). + p_uniform = SearchProperties(collect(0.0:0.5:50.0); is_uniform = true) + @test p_uniform.is_uniform end @safetestset "Batched in-place searchsorted!" begin From 953fdd55e894492be6d4ad8f277a16606cdd9359 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Wed, 20 May 2026 12:19:01 -0400 Subject: [PATCH 4/5] SearchProperties: AbstractRange skips all probes; auto-detect is_uniform on Vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two refinements: **1. Single-scan probe** `_sampled_looks_linear(v, tol)` is replaced by `_sampled_linear_err(v)` which returns the maximum relative deviation from a single 9-point scan. The bool checks layer on top: - `_sampled_looks_linear(v, tol) = _sampled_linear_err(v) <= tol` for Auto's `InterpolationSearch` gating. - `_sampled_looks_uniform(v) = _sampled_linear_err(v) <= 1e-12` flags *exactly* uniformly-spaced data (a few ulp of accumulated float roundoff; random / jittered data has rel_err well above this). `SearchProperties(v::AbstractVector)` runs the scan once and derives both `is_linear` and `is_uniform` from it — no second pass. So a `collect(0.0:0.1:10.0)` is now correctly flagged `is_uniform = true` without paying for two probes. **2. AbstractRange skips probes entirely** `SearchProperties(v::AbstractRange{<:Real})` hardcodes all five fields: - `has_props = true` - `is_linear = true` (ranges are linear in index by definition) - `has_nan = false` (Int ranges can't have NaN; Float ranges constructed normally don't) - `is_log_linear = false` (arithmetically-spaced ≠ log-linear) - `is_uniform = true` Measured: - `SearchProperties(Vector{Float64})` n=100001: ~66 μs (the O(n) NaN scan dominates) - `SearchProperties(StepRangeLen)`: **~2 ns** — ~30,000× faster The pathological `LinRange(NaN, …)` case isn't checked; the docstring documents that the caller is on their own there. Tests now confirm: - `collect(1:100)`, `collect(0.0:0.1:10.0)` → `is_uniform = true` (detected via the tight-tolerance probe). - `collect(exp.(range(…)))`, random sorted → `is_uniform = false`. - Override via `is_uniform = true / false` kwarg works. - `1:100`, `0.0:0.1:10.0`, `LinRange(0.0, 10.0, 100)` → `is_uniform = true` (via the AbstractRange constructor). 85051 tests pass. Co-Authored-By: Chris Rackauckas --- src/search_properties.jl | 106 +++++++++++++++++++++++++-------------- test/runtests.jl | 31 ++++++++---- 2 files changed, 88 insertions(+), 49 deletions(-) diff --git a/src/search_properties.jl b/src/search_properties.jl index d25f041..bd42cd0 100644 --- a/src/search_properties.jl +++ b/src/search_properties.jl @@ -14,47 +14,65 @@ const _AUTO_LINEAR_REL_TOLERANCE = 1.0e-3 # Sampled linearity check: probes v[1], v[k*n/10] for k = 1..9, v[n] and -# tests whether all interior points sit within `_AUTO_LINEAR_REL_TOLERANCE` -# (default 0.1%) of the straight line between v[1] and v[n]. The tight -# tolerance reliably distinguishes truly-uniform data from random/sorted -# data even at large n (where the order-statistic variance ≈ 1/sqrt(n) -# would fool a looser check). +# computes the maximum relative deviation of the 9 interior points from +# the straight line between v[1] and v[n]. Returns the max relative error +# (or `Inf` if `v` is unsuitable — too short, zero/infinite span, etc.). +# +# A single scan produces several different bool flags by comparing the +# returned error against different tolerances: # -# Cost is ~25 ns regardless of n — 11 reads + 9 comparisons. Used by Auto -# to decide whether to gamble on InterpolationSearch. +# - `_AUTO_LINEAR_REL_TOLERANCE` (default 0.1%) gates `InterpolationSearch` +# in Auto's batched dispatch. +# - `_AUTO_LINEAR_LOOSE_TOLERANCE` (5%) for the large-gap regime. +# - `_UNIFORM_REL_TOLERANCE` (~1e-12, a few ulp) flags +# exactly-uniformly-spaced data so `SearchProperties` can set +# `is_uniform` for any `AbstractVector` (not just `AbstractRange`). # -# InterpolationSearch's downside on non-linear data is large (4-14× slower -# than ExpFromLeft on log/plateau/two-scale spacings), so we err on the -# side of rejecting borderline cases. Truly uniform data — exact ranges, -# evenly-spaced grids, and small-amplitude jittered data — passes; sorted -# random data is rejected at all `n` tested up to ~10⁶. -@inline function _sampled_looks_linear( +# Cost is ~25 ns regardless of n — 11 reads + 9 multiply/add + 9 compares. +# Tight tolerance reliably distinguishes truly-uniform data from random +# sorted data even at large n (where order-statistic variance ≈ 1/sqrt(n) +# would fool a looser check). +@inline function _sampled_linear_err( v::AbstractVector{<:Number}, - tol::Float64 = _AUTO_LINEAR_REL_TOLERANCE, ) n = length(v) - n < 11 && return false + n < 11 && return Inf @inbounds begin v1, vn = v[1], v[n] span = vn - v1 - (iszero(span) || !isfinite(span)) && return false + (iszero(span) || !isfinite(span)) && return Inf abs_span = abs(span) nm1 = n - 1 + max_err = 0.0 for k in 1:9 kk = 1 + (k * nm1) ÷ 10 expected = v1 + (kk - 1) / nm1 * span - rel_err = abs(v[kk] - expected) / abs_span - rel_err > tol && return false + rel_err = Float64(abs(v[kk] - expected) / abs_span) + rel_err > max_err && (max_err = rel_err) end + return max_err end - return true end -# Non-numeric eltype: can't sample, never picks InterpolationSearch. -@inline _sampled_looks_linear(::AbstractVector, ::Float64 = _AUTO_LINEAR_REL_TOLERANCE) = false +# Non-numeric eltype: can't sample. Returns Inf so every tolerance check fails. +@inline _sampled_linear_err(::AbstractVector) = Inf + +# AbstractRange is definitionally uniform — error is zero by construction. +@inline _sampled_linear_err(::AbstractRange) = 0.0 + +# Tolerance treating "uniform" as a few ulp of accumulated float roundoff. +# `collect(0.0:0.1:10.0)` has rel_err ≈ 1e-16 to 1e-15 from float-step +# imprecision; `1e-12` accepts it cleanly. Random / jittered data at any +# n has rel_err well above this. The constant is conservative — tightening +# further would risk false negatives on long Float ranges. +const _UNIFORM_REL_TOLERANCE = 1.0e-12 -# AbstractRange is definitionally uniform — accept without sampling. -@inline _sampled_looks_linear(::AbstractRange, ::Float64 = _AUTO_LINEAR_REL_TOLERANCE) = true +@inline _sampled_looks_linear( + v::AbstractVector, tol::Float64 = _AUTO_LINEAR_REL_TOLERANCE, +) = _sampled_linear_err(v) <= tol + +@inline _sampled_looks_uniform(v::AbstractVector) = + _sampled_linear_err(v) <= _UNIFORM_REL_TOLERANCE # Sampled "log-linear" probe: same 9-point probe as `_sampled_looks_linear` # but tests whether `log(v)` is linear in array index. Used to detect @@ -117,35 +135,47 @@ the flag is set automatically by the dedicated overload below. function SearchProperties( v::AbstractVector; linear_tolerance::Real = 1.0e-3, - is_uniform::Bool = false, + is_uniform::Union{Nothing, Bool} = nothing, ) tol = Float64(linear_tolerance) + # One scan produces both `is_linear` and the uniformity-deviation + # check. `is_uniform = nothing` (default) means "infer from the + # probe"; an explicit Bool overrides. + err = _sampled_linear_err(v) + detected_uniform = err <= _UNIFORM_REL_TOLERANCE return SearchProperties( true, - _sampled_looks_linear(v, tol), + err <= tol, _has_nan(v), _sampled_looks_log_linear(v, tol), - is_uniform, + is_uniform === nothing ? detected_uniform : is_uniform, ) end """ SearchProperties(v::AbstractRange; linear_tolerance = 1.0e-3) -Specialised constructor for `AbstractRange`. Sets `is_uniform = true` -unconditionally — every `AbstractRange` is by definition uniformly -spaced, so `Auto` can short-circuit to [`UniformStep`](@ref). +Specialised constructor for `AbstractRange{<:Real}`. Skips every runtime +probe — every property is known statically from the type: + + - `is_linear = true` — ranges are linear in index by construction. + - `is_uniform = true` — ranges have exact uniform spacing. + - `has_nan = false` — `AbstractRange{<:Real}` values are computed from + `first(r) + (i - 1) * step(r)`; barring `first(r)` or `step(r)` + themselves being NaN, the values are all finite. For the rare + pathological `LinRange(NaN, …, …)` case the caller is on their own. + - `is_log_linear = false` — a range that's linear in index is *not* + log-linear in value (the values are arithmetically, not + geometrically, spaced). The flag would only be `true` for ranges of + `exp(x)` values, which Julia represents as a `Vector`, not an + `AbstractRange`. + +`linear_tolerance` is accepted for signature compatibility but ignored +— the probes are skipped. """ function SearchProperties( - v::AbstractRange; + v::AbstractRange{<:Real}; linear_tolerance::Real = 1.0e-3, ) - tol = Float64(linear_tolerance) - return SearchProperties( - true, - _sampled_looks_linear(v, tol), - _has_nan(v), - _sampled_looks_log_linear(v, tol), - true, - ) + return SearchProperties(true, true, false, false, true) end diff --git a/test/runtests.jl b/test/runtests.jl index b4cb979..40d1c80 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -577,17 +577,26 @@ end p_signed = SearchProperties(v_signed) @test !p_signed.is_log_linear - # is_uniform: false by default on Vector, true on AbstractRange - # (automatic via the AbstractRange constructor). - @test !SearchProperties(collect(1:100)).is_uniform - @test SearchProperties(1:100).is_uniform - @test SearchProperties(0.0:0.1:10.0).is_uniform - @test SearchProperties(LinRange(0.0, 10.0, 100)).is_uniform - # `is_uniform = true` kwarg on Vector is accepted (currently no - # consumer; UniformStep falls back to BinaryBracket on Vector, - # so the flag is a forward-compatibility marker for callers). - p_uniform = SearchProperties(collect(0.0:0.5:50.0); is_uniform = true) - @test p_uniform.is_uniform + # is_uniform: detected for any uniformly-spaced vector. The + # 9-point linearity probe at a tight 1e-12 tolerance flags + # exact uniformity (a few ulp of accumulated float roundoff). + # AbstractRange short-circuits to true with no probe. + @test SearchProperties(1:100).is_uniform # range + @test SearchProperties(0.0:0.1:10.0).is_uniform # StepRangeLen + @test SearchProperties(LinRange(0.0, 10.0, 100)).is_uniform # LinRange + @test SearchProperties(collect(1:100)).is_uniform # uniform Vector + @test SearchProperties(collect(0.0:0.1:10.0)).is_uniform # uniform Vector + # Non-uniform vectors are rejected. + v_log_collected = collect(exp.(range(0.0, log(1.0e6); length = 100))) + @test !SearchProperties(v_log_collected).is_uniform + v_random = sort!(rand(StableRNG(42), 100)) + @test !SearchProperties(v_random).is_uniform + # Manual override: `is_uniform = false` forces rejection even + # if the probe would accept; `is_uniform = true` accepts even + # if the probe would reject. + v_uniform_collected = collect(0.0:0.5:50.0) + @test !SearchProperties(v_uniform_collected; is_uniform = false).is_uniform + @test SearchProperties(v_log_collected; is_uniform = true).is_uniform end @safetestset "Batched in-place searchsorted!" begin From a37bc99440247eec45e04403cc8027ac73cd5ccd Mon Sep 17 00:00:00 2001 From: Christopher Rackauckas Date: Thu, 21 May 2026 08:37:10 +0000 Subject: [PATCH 5/5] Update NEWS.md --- NEWS.md | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/NEWS.md b/NEWS.md index 2dfb366..53cef6a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,25 +1,5 @@ # FindFirstFunctions.jl NEWS -## Unreleased - -### New strategy: `UniformStep` (closes #7) - -O(1) direct-arithmetic lookup for uniformly-spaced vectors. The answer -index is computed from `(x - first(v)) / step(v)` rather than via binary -search or galloping — independent of `length(v)`. Specialized for -`AbstractRange{<:Real}`; falls back to `BinaryBracket` for non-range -vectors and for custom orderings (`By`, `Lt`, …). - -`Auto` now short-circuits `AbstractRange` inputs to `UniformStep` in both -per-query and batched paths, skipping the gap-estimate and -linearity-probe overhead. Measured: -`searchsortedlast(Auto(), 0.0:0.001:100.0, x)` now matches -`Base.searchsortedlast(r, x)` at ~26 ns (vs ~80 ns through the -un-specialized Auto path on a Vector of the same data). - -The strategy can also be pinned explicitly: -`searchsortedlast(UniformStep(), r, x)`. - ## 2.0.0 This is a major rewrite of the sorted-search API. The 1.x surface — a