From 3c1289afa69c015d4620e050e2a9cc7ca1d5b280 Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Mon, 10 Mar 2025 15:05:37 +0100 Subject: [PATCH] feat(interval): add difference/2 function - Implemented `difference/2` function to compute the difference between two intervals. - Added unit tests for `difference/2` - Added property-based tests for `difference/2` --- lib/interval.ex | 84 ++++++++++++++++++++ test/continuous_interval_property_test.exs | 23 ++++++ test/discrete_interval_property_test.exs | 23 ++++++ test/interval_test.exs | 90 ++++++++++++++++++++++ test/regression_test.exs | 17 ++++ 5 files changed, 237 insertions(+) diff --git a/lib/interval.ex b/lib/interval.ex index 1b25687..16a2750 100644 --- a/lib/interval.ex +++ b/lib/interval.ex @@ -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`: diff --git a/test/continuous_interval_property_test.exs b/test/continuous_interval_property_test.exs index de15379..fc8ce93 100644 --- a/test/continuous_interval_property_test.exs +++ b/test/continuous_interval_property_test.exs @@ -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), diff --git a/test/discrete_interval_property_test.exs b/test/discrete_interval_property_test.exs index 8345381..a7f5401 100644 --- a/test/discrete_interval_property_test.exs +++ b/test/discrete_interval_property_test.exs @@ -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), diff --git a/test/interval_test.exs b/test/interval_test.exs index 4f9aedd..4413049 100644 --- a/test/interval_test.exs +++ b/test/interval_test.exs @@ -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 diff --git a/test/regression_test.exs b/test/regression_test.exs index e6473b4..313ae85 100644 --- a/test/regression_test.exs +++ b/test/regression_test.exs @@ -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