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..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,32 +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) +function Base.searchsortedlast( + s::Auto, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, + ) + 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( + s::Auto, v::AbstractVector, x; + order::Base.Order.Ordering = Base.Order.Forward, + ) + if _auto_is_uniform(v, s.props) + return searchsortedfirst(UniformStep(), v, x; order = order) + end + return searchsortedfirst(BinaryBracket(), v, x; order = order) +end diff --git a/src/batched.jl b/src/batched.jl index 9dbc82f..e455ad9 100644 --- a/src/batched.jl +++ b/src/batched.jl @@ -167,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 @@ -237,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/dispatch.jl b/src/dispatch.jl index 0851d63..0719ab8 100644 --- a/src/dispatch.jl +++ b/src/dispatch.jl @@ -634,6 +634,157 @@ Base.searchsortedfirst( order::Base.Order.Ordering = Base.Order.Forward, ) = searchsortedfirst(InterpolationSearch(), v, x; order = order) +# =========================================================================== +# Strategy: UniformStep — O(1) direct-arithmetic lookup for AbstractRange. +# +# 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. +# +# 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( + s::UniformStep, v::AbstractRange, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = _uniformstep_supported_order(order) ? + _uniformstep_searchsortedlast(v, x, order) : + searchsortedlast(BinaryBracket(), v, x; order = order) +Base.searchsortedfirst( + s::UniformStep, v::AbstractRange, x; + order::Base.Order.Ordering = Base.Order.Forward, +) = _uniformstep_supported_order(order) ? + _uniformstep_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/search_properties.jl b/src/search_properties.jl index f0507bf..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 + +@inline _sampled_looks_linear( + v::AbstractVector, tol::Float64 = _AUTO_LINEAR_REL_TOLERANCE, +) = _sampled_linear_err(v) <= tol -# AbstractRange is definitionally uniform — accept without sampling. -@inline _sampled_looks_linear(::AbstractRange, ::Float64 = _AUTO_LINEAR_REL_TOLERANCE) = true +@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 @@ -92,7 +110,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,16 +123,59 @@ 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::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 === nothing ? detected_uniform : is_uniform, + ) +end + +""" + SearchProperties(v::AbstractRange; linear_tolerance = 1.0e-3) + +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{<:Real}; + linear_tolerance::Real = 1.0e-3, ) + return SearchProperties(true, true, false, false, true) end diff --git a/src/strategies.jl b/src/strategies.jl index e70263c..d6e6b27 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 @@ -217,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 11e4b9d..40d1c80 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, @@ -443,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)) @@ -471,6 +576,27 @@ end v_signed = collect(-100.0:0.001:65.0) p_signed = SearchProperties(v_signed) @test !p_signed.is_log_linear + + # 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