Skip to content

Commit

Permalink
Add ability to specify min & max values for Data.Floats
Browse files Browse the repository at this point in the history
Closes #22
  • Loading branch information
Seelengrab committed Jul 12, 2024
1 parent 3b47f8b commit 63fd131
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 16 deletions.
109 changes: 93 additions & 16 deletions src/data.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1299,7 +1299,7 @@ produce!(tc::TestCase, ::Booleans) = weighted!(tc, 0.5)
## Possibility of floating point values

"""
Floats{T <: Union{Float16,Float32,Float64}}(;infs=true, nans=true) <: Possibility{T}
Floats{T <: Union{Float16,Float32,Float64}}(;infs=true, nans=true, minimum=-T(Inf), maximum=+T(Inf)) <: Possibility{T}
A `Possibility` for sampling floating point values.
Expand Down Expand Up @@ -1327,17 +1327,32 @@ julia> example(floats, 5)
struct Floats{T <: Base.IEEEFloat} <: Possibility{T}
nans::Bool
infs::Bool
function Floats{T}(; nans=true, infs=true) where T <: Base.IEEEFloat
new{T}(nans, infs)
minimum::T
maximum::T
function Floats{T}(; nans::Bool=true, infs::Bool=true, minimum=T(-Inf), maximum=T(Inf)) where T <: Base.IEEEFloat
_min = convert(T, minimum)
_max = convert(T, maximum)
isnan(_min) && throw(ArgumentError("`minimum` must be non-NaN!"))
isnan(_max) && throw(ArgumentError("`maximum` must be non-NaN!"))
_min > _max && throw(ArgumentError("`minimum` must be `<= maximum`!"))
infs = (isinf(minimum) | isinf(maximum)) & infs
new{T}(nans, infs, _min, _max)
end
end

Base.:(==)(f1::Floats{T}, f2::Floats{T}) where {T} = f1.nans == f2.nans && f1.infs == f2.infs
Base.:(==)(f1::Floats{T}, f2::Floats{T}) where {T} = f1.nans == f2.nans && f1.infs == f2.infs && f1.minimum == f2.minimum && f1.maximum == f2.maximum

function Base.show(io::IO, f::Floats)
print(io, typeof(f), "(; ")
print(io, "nans=", f.nans, ", ")
print(io, "infs=", f.infs, ")")
print(io, "infs=", f.infs)
if !isinf(f.minimum)
print(io, ", minimum=", f.minimum)
end
if !isinf(f.maximum)
print(io, ", maximum=", f.maximum)
end
print(io, ")")
nothing
end

Expand All @@ -1354,41 +1369,81 @@ function Base.show(io::IO, ::MIME"text/plain", f::Floats)
print(io, styled"""
{code,underline:$Floats}:
Produce a floating point value of type {code:$(postype(f))}, which is
Produce a floating point value {code:x} of type {code:$(postype(f))}, which is
* {code:isinf}: $inf_str
* {code:isnan}: $nan_str
* {code:$(f.minimum) <= x <= $(f.maximum)}
E.g. {code:$obj}; {code:isinf}: $inf, {code:isnan}: $nan
""")
E.g. {code:$obj}; {code:isinf}: $inf, {code:isnan}: $nan""")
end

function produce!(tc::TestCase, f::Floats{T}) where {T}
iT = Supposition.uint(T)
res = reinterpret(T, produce!(tc, Integers{iT}()))
# early rejections
!f.infs && isinf(res) && reject(tc)
!f.nans && isnan(res) && reject(tc)
return res

# early acception
f.nans && isnan(res) && return res
f.minimum <= res <= f.maximum && return res

return float_remap(res, f.minimum, f.maximum)
end

function float_remap(num::T, _min::T, _max::T) where T <: AbstractFloat
# We're outside of the desired bounds, so use the mantissa
# to resample the actual range
range_size = min(_max - _min, floatmax(T))
_, _, mantissa = Supposition.tear(num)
max_mantissa = oftype(mantissa, (2^Supposition.fracsize(T)) - 1)
num = _min + range_size * (mantissa / max_mantissa)

# ensure the value is still in the desired range
return clamp(convert(T, num), _min, _max)
end

struct AllFloats <: Data.Possibility{Union{Float16, Float32, Float64}}
nans::Bool
infs::Bool
minimum::Float64
maximum::Float64
function AllFloats(nans, infs, minimum, maximum)
_min = convert(Float64, minimum)
_max = convert(Float64, maximum)
isnan(_min) && throw(ArgumentError("`minimum` must be non-NaN!"))
isnan(_max) && throw(ArgumentError("`maximum` must be non-NaN!"))
_min > _max && throw(ArgumentError("`minimum` must be `<= maximum`!"))
infs = (isinf(minimum) | isinf(maximum)) & infs
new(convert(Bool, nans), convert(Bool, infs), _min, _max)
end
end
AllFloats(nans, infs) = AllFloats(nans, infs, -Inf, Inf)
function AllFloats(; nans=true, infs=true,
minimum=-Inf, maximum=Inf)
AllFloats(nans, infs, minimum, maximum)
end
AllFloats(;nans=true, infs=true) = AllFloats(nans, infs)

Base.:(==)(af1::AllFloats, af2::AllFloats) = af1.nans == af2.nans && af1.infs == af2.infs
Base.:(==)(af1::AllFloats, af2::AllFloats) = af1.nans == af2.nans && af1.infs == af2.infs && af1.minimum == af2.minimum && af1.maximum == af1.maximum

"""
Floats(;nans=true, infs=true) <: Possibility{Union{Float64,Float32,Float16}}
A catch-all for generating instances of all three IEEE floating point types.
"""
Floats(;nans=true, infs=true) = AllFloats(nans, infs)
Floats(;nans=true, infs=true, minimum=-Inf, maximum=Inf) = AllFloats(nans, infs, minimum, maximum)

function Base.show(io::IO, f::AllFloats)
print(io, Floats, "(; ")
print(io, "nans=", f.nans, ", ")
print(io, "infs=", f.infs, ")")
print(io, "infs=", f.infs)
if !isinf(f.minimum)
print(io, ", minimum=", f.minimum)
end
if !isinf(f.maximum)
print(io, ", maximum=", f.maximum)
end
print(io, ")")
nothing
end

Expand All @@ -1408,17 +1463,39 @@ function Base.show(io::IO, ::MIME"text/plain", f::AllFloats)
Produce a floating point value of type {code:Float16}, {code:Float32} or {code:Float64}, which may be
* {code:isinf}: $inf_str
* {code:isnan}: $nan_str
* {code:$(f.minimum) <= x <= $(f.maximum)}
E.g. {code:$obj}, a {code:$(typeof(ex))}; {code:isinf}: $inf, {code:isnan}: $nan
""")
E.g. {code:$obj}, a {code:$(typeof(ex))}; {code:isinf}: $inf, {code:isnan}: $nan""")
end

function produce!(tc::TestCase, af::AllFloats)
of = OneOf(
Floats{Float16}(;af.nans,af.infs),
Floats{Float32}(;af.nans,af.infs),
Floats{Float64}(;af.nans,af.infs))
produce!(tc, of)
num = produce!(tc, of)
af.nans && isnan(num) && return num
af.minimum <= num <= af.maximum && return num

# Not all numbers in the given range are representable
# by all types, so normalize the bounds to the closest
# that are still in the interval for that type.
# This ensures that we don't accidentally go outside of the
# allowed range.
_min = oftype(num, af.minimum)
_max = oftype(num, af.maximum)
while _min < af.minimum
_min = nextfloat(_min)
end
while _max > af.maximum
_max = prevfloat(_max)
end

# in this case, the interval doesn't contain values of the chosen type
_min > _max && reject(tc)

# and finally remap the value to a valid one
return float_remap(num, _min, _max)
end

####
Expand Down
45 changes: 45 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,35 @@ const verb = VERSION.major == 1 && VERSION.minor < 11
end
end

@testset "Floating point clamping" begin
nums = Data.Floats{Float64}(;nans=false)
@check function floatclamp(a=nums, b=nums)
lower, upper = minmax(a, b)
inner = Data.Floats(;nans=false, minimum=lower, maximum=upper)
num = produce!(inner)
lower <= num <= upper
end

@check function floatclamp_type(T=Data.SampledFrom((Float16, Float32, Float64)))
nums = Data.Floats{T}(;nans=false)
a = produce!(nums)
b = produce!(nums)
lower, upper = minmax(a, b)
inner = Data.Floats{T}(;nans=false, minimum=lower, maximum=upper)
num = produce!(inner)
lower <= num <= upper
end

# test conversion
@test Data.Floats(;minimum=4, maximum=5) isa Data.AllFloats
@test Data.Floats{Float64}(;minimum=4, maximum=5) isa Data.Floats{Float64}

# test invariant checks
@test_throws ArgumentError Data.Floats(;minimum=NaN)
@test_throws ArgumentError Data.Floats(;maximum=NaN)
@test_throws ArgumentError Data.Floats(;minimum=2.0, maximum=1.0)
end

@testset "@check API" begin
# These tests are for accepted syntax, not functionality, so only one example is fine
API_conf = Supposition.merge(DEFAULT_CONFIG[]; verbose=verb, max_examples=1)
Expand Down Expand Up @@ -1039,7 +1068,13 @@ const verb = VERSION.major == 1 && VERSION.minor < 11
Data.Integers{UInt8}(),
Data.Integers(0x1,0xfe),
Data.Floats{Float16}(),
Data.Floats{Float16}(;minimum=3.0, maximum=7.0),
Data.Floats{Float16}(;minimum=3.0),
Data.Floats{Float16}(;maximum=7.0),
Data.Floats(),
Data.Floats(;minimum=3.0, maximum=7.0),
Data.Floats(;minimum=3.0),
Data.Floats(;maximum=7.0),
Data.Booleans(),
Data.Pairs(Data.Booleans(), Data.Booleans()),
Data.Vectors(Data.Booleans();max_size=1),
Expand Down Expand Up @@ -1077,8 +1112,18 @@ const verb = VERSION.major == 1 && VERSION.minor < 11
@test occursin("of type Float16", repr("text/plain", Data.Floats{Float16}()))
@test occursin("isinf: never", repr("text/plain", Data.Floats{Float16}(;infs=false)))
@test occursin("isnan: never", repr("text/plain", Data.Floats{Float16}(;nans=false)))
minmax_float16 = Data.Floats{Float16}(;minimum=4.0, maximum=5.0)
@test occursin("4.0 <= x <= 5.0", repr("text/plain", minmax_float16))
@test occursin("isinf: never", repr("text/plain", minmax_float16))
@test occursin("isinf: maybe", repr("text/plain", Data.Floats{Float16}(;minimum=4.0)))
@test occursin("isinf: maybe", repr("text/plain", Data.Floats{Float16}(;maximum=5.0)))
@test occursin("AllFloats", repr("text/plain", Data.Floats()))
@test occursin("AllFloats", repr("text/plain", Data.AllFloats()))
minmax_allfloats = Data.Floats(;minimum=4.0, maximum=5.0)
@test occursin("4.0 <= x <= 5.0", repr("text/plain", minmax_allfloats))
@test occursin("isinf: never", repr("text/plain", minmax_allfloats))
@test occursin("isinf: maybe", repr("text/plain", Data.Floats(;minimum=4.0)))
@test occursin("isinf: maybe", repr("text/plain", Data.Floats(;maximum=5.0)))
@test occursin("true and false have a probability of 50%", repr("text/plain", Data.Booleans()))
end
@testset "Data.Pairs" begin
Expand Down

0 comments on commit 63fd131

Please sign in to comment.