Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions lib/interval.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,90 @@ defmodule Interval do
end
end

@doc """
Computes the difference between `a` and `b` by subtracting all points in `b` from `a`.

`b` must not be contained in `a` in such a way that the difference would not be a single interval.

## Examples:

Discrete:

# a: [-----)
# b: [-----)
# c: [---)
iex> difference(Interval.IntegerInterval.new(1, 4), Interval.IntegerInterval.new(3, 5))
Interval.IntegerInterval.new(1, 3)

# a: [-----)
# b: [-----)
# c: [---)
iex> difference(Interval.IntegerInterval.new(3, 5), Interval.IntegerInterval.new(1, 4))
Interval.IntegerInterval.new(4, 5)

Continuous:

# a: [------)
# b: [-----)
# c: [---)
iex> difference(Interval.FloatInterval.new(1.0, 4.0), Interval.FloatInterval.new(3.0, 5.0))
Interval.FloatInterval.new(1.0, 3.0)

# a: [-----)
# b: (-----)
# c: [---]
iex> difference(Interval.FloatInterval.new(1.0, 4.0), Interval.FloatInterval.new(3.0, 5.0, "()"))
Interval.FloatInterval.new(1.0, 3.0, "[]")
"""
@doc since: "2.0.0"
def difference(a, b)

def difference(%{} = a, %{} = a) do
new_empty(a.__struct__)
end

def difference(%module{} = a, %module{} = b) do
if empty?(a) or empty?(b) do
# if a or b are empty, then the a - b = a
a
else
cmp_al_bl = compare_bounds(module, :left, a.left, :left, b.left)
cmp_al_br = compare_bounds(module, :left, a.left, :right, b.right)
cmp_ar_bl = compare_bounds(module, :right, a.right, :left, b.left)
cmp_ar_br = compare_bounds(module, :right, a.right, :right, b.right)

cond do
# if a.left < b.left and a.right > b.right then a contains b which would result in multiple intervals
cmp_al_bl === :lt and cmp_ar_br === :gt ->
raise IntervalOperationError,
message: "subtracting B from A would result in multiple intervals"

# if a.left > b.right or a.right < b.left then a does not overlap b, so a - b = a
cmp_al_br === :gt or cmp_ar_bl === :lt ->
a

# if a.left >= b.left and a.right <= b.right then b covers a, so: a - b = empty
cmp_al_bl in [:gt, :eq] and cmp_ar_br in [:lt, :eq] ->
new_empty(module)

# a: [------)
# b: [------)
# if a.left <= b.left and a.right >= b.left and a.right <= b.right
cmp_al_bl in [:lt, :eq] and cmp_ar_bl in [:gt, :eq] and cmp_ar_br in [:lt, :eq] ->
from_endpoints(module, a.left, inverted_bound(b.left))

# a: [------)
# b: [------)
# if a.left >= b.left and a.right >= b.right and a.left <= b.right
cmp_al_bl in [:gt, :eq] and cmp_ar_br in [:gt, :eq] and cmp_al_br in [:lt, :eq] ->
from_endpoints(module, inverted_bound(b.right), a.right)
end
end
end

defp inverted_bound({:inclusive, point}), do: {:exclusive, point}
defp inverted_bound({:exclusive, point}), do: {:inclusive, point}

@doc """
Partition an interval `a` into 3 intervals using `x`:

Expand Down
23 changes: 23 additions & 0 deletions test/continuous_interval_property_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ defmodule ContinuousIntervalPropertyTest do
end
end

property "difference/2", %{impl: impl} do
check all(
a <- Helper.interval(impl),
b <- Helper.interval(impl)
) do
cond do
# if a does not overlap b, we always expect first argument back
not Interval.overlaps?(a, b) ->
assert Interval.difference(a, b) === a

# if a contains b, and a does not share an endpoint with b, difference/2 would raise
Interval.contains?(a, b) and not (a.left == b.left or a.right == b.right) ->
assert_raise Interval.IntervalOperationError, fn ->
Interval.difference(a, b)
end

# if a overlaps b, we are always going to to change a
Interval.overlaps?(a, b) ->
assert Interval.difference(a, b) !== a
end
end
end

property "contains?/2", %{impl: impl} do
check all(
a <- Helper.interval(impl),
Expand Down
23 changes: 23 additions & 0 deletions test/discrete_interval_property_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ defmodule DiscreteIntervalPropertyTest do
end
end

property "difference/2", %{impl: impl} do
check all(
a <- Helper.interval(impl),
b <- Helper.interval(impl)
) do
cond do
# if a does not overlap b, we always expect first argument back
not Interval.overlaps?(a, b) ->
assert Interval.difference(a, b) === a

# if a contains b, and a does not share an endpoint with b, difference/2 would raise
Interval.contains?(a, b) and not (a.left == b.left or a.right == b.right) ->
assert_raise Interval.IntervalOperationError, fn ->
Interval.difference(a, b)
end

# if a overlaps b, we are always going to to change a
Interval.overlaps?(a, b) ->
assert Interval.difference(a, b) !== a
end
end
end

property "contains?/2 & contains_point?/2", %{impl: impl} do
check all(
a <- Helper.interval(impl),
Expand Down
90 changes: 90 additions & 0 deletions test/interval_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -519,4 +519,94 @@ defmodule Interval.IntervalTest do

assert a === b
end

test "difference/2" do
empty = inti(1, 1, "()")
##
# Discrete interval
##
# a - a = empty
assert Interval.difference(inti(1, 4), inti(1, 4)) === empty
# a - empty = a
assert Interval.difference(inti(1, 4), empty) === inti(1, 4)
# empty - a = empty
assert Interval.difference(empty, inti(1, 4)) === empty
# a - b when b covers left side of a
assert Interval.difference(inti(1, 4), inti(0, 2)) === inti(2, 4)
# a - b when b covers right side of a
assert Interval.difference(inti(1, 4), inti(3, 5)) === inti(1, 3)
# a - b when b covers a = empty
assert Interval.difference(inti(1, 4), inti(0, 5)) === empty
# a - b when a covers b is Error
assert_raise Interval.IntervalOperationError, fn ->
Interval.difference(inti(1, 4), inti(2, 3))
end

# b's endpoint matches a's endpoint on one side exactly
assert Interval.difference(inti(1, 4), inti(3, 4)) === inti(1, 3)
assert Interval.difference(inti(1, 4), inti(1, 2)) === inti(2, 4)

# unbounded endpoints
# different mutations of unbounded b (where b covers a completely)
assert Interval.difference(inti(1, 4), inti(nil, nil)) === empty
assert Interval.difference(inti(1, 4), inti(0, nil)) === empty
assert Interval.difference(inti(1, 4), inti(nil, 5)) === empty
# b only partially covers a
assert Interval.difference(inti(1, 4), inti(2, nil)) === inti(1, 2)
assert Interval.difference(inti(1, 4), inti(nil, 3)) === inti(3, 4)
# a also unbounded and b is not
assert_raise Interval.IntervalOperationError, fn ->
Interval.difference(inti(nil, nil), inti(1, 4))
end

# a is unbounded and b is unbounded
assert Interval.difference(inti(1, nil), inti(2, nil)) === inti(1, 2)
assert Interval.difference(inti(nil, 4), inti(nil, 3)) === inti(3, 4)
assert Interval.difference(inti(nil, 4), inti(nil, nil)) === empty
assert Interval.difference(inti(1, nil), inti(nil, nil)) === empty
assert Interval.difference(inti(nil, nil), inti(nil, nil)) === empty
assert Interval.difference(inti(nil, nil), inti(nil, 3)) === inti(3, nil)
assert Interval.difference(inti(nil, nil), inti(3, nil)) === inti(nil, 3)

assert Interval.difference(inti(1, 2), inti(3, 4)) === inti(1, 2)
assert Interval.difference(inti(3, 4), inti(1, 2)) === inti(3, 4)

##
# Continuous interval
##
# (we don't need to cover the basics, because those are the asme as for discrete intervals
# but we want to make sure we've covered what happens at the endpoints with bounds)
# empty = floati(1.0, 1.0, "()")

assert Interval.difference(floati(3.0, 5.0), floati(3.0, 5.0)) === floati(:empty)

assert Interval.difference(floati(1.0, 4.0, "[]"), floati(3.0, 4.0, "[]")) ===
floati(1.0, 3.0, "[)")

assert Interval.difference(floati(1.0, 4.0, "[]"), floati(1.0, 2.0, "[]")) ===
floati(2.0, 4.0, "(]")

# a: [------)
# b: [------)
a = floati(1.0, 4.0, "[)")
b = floati(3.0, 5.0, "[)")
assert Interval.difference(a, b) === floati(1.0, 3.0, "[)")

# a: [------]
# b: (------]
a = floati(1.0, 4.0, "[]")
b = floati(3.0, 5.0, "(]")
assert Interval.difference(a, b) === floati(1.0, 3.0, "[]")

# a: [-----------]
# b: (------)
a = floati(1.0, 5.0, "[]")
b = floati(3.0, 4.0, "()")

assert_raise Interval.IntervalOperationError, fn ->
Interval.difference(a, b)
end

assert Interval.difference(b, a) === floati(:empty)
end
end
17 changes: 17 additions & 0 deletions test/regression_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,21 @@ defmodule Interval.RegressionTest do
FloatInterval.new(left: 2.0, right: 3.0, bounds: "[]")
) === FloatInterval.new(left: 2.0, right: 3.0, bounds: "[)")
end

test "difference/2 regression 2025-03-10" do
a = %Interval.DecimalInterval{
left: {:inclusive, Decimal.new("-1")},
right: :unbounded
}

b = %Interval.DecimalInterval{
left: {:inclusive, Decimal.new("1")},
right: :unbounded
}

assert Interval.difference(a, b) === %Interval.DecimalInterval{
left: {:inclusive, Decimal.new("-1")},
right: {:exclusive, Decimal.new("1")}
}
end
end