From cfe810c3f1b237380ece8c17e5fe8c359061235e Mon Sep 17 00:00:00 2001 From: Henrik Tudborg Date: Tue, 11 Mar 2025 13:09:40 +0100 Subject: [PATCH] feat(interval): support partitioning by interval - `partition/2` now accepts an interval as its second argument. - Added property-based tests for `partition/2`. - Added regression tests for `partition/2`. --- CHANGELOG.md | 4 ++ lib/interval.ex | 71 ++++++++++++++++------ test/continuous_interval_property_test.exs | 29 +++++++++ test/discrete_interval_property_test.exs | 29 +++++++++ test/interval_test.exs | 43 +++++++++++-- test/regression_test.exs | 19 ++++++ 6 files changed, 170 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f25960..84239f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 2.0.0-alpha.2 +### Added + +- `partition/2` now also accepts an interval as it's second argument. + ### Fixed - `adjacent?/2` is now exposed on the interval module via `defdelegate` in the using macro. diff --git a/lib/interval.ex b/lib/interval.ex index 1f8cb35..205b92c 100644 --- a/lib/interval.ex +++ b/lib/interval.ex @@ -1125,40 +1125,73 @@ defmodule Interval do @doc """ Partition an interval `a` into 3 intervals using `x`: - - The interval with all points from `a` < `x` - - The interval with just `x` - - The interval with all points from `a` > `x` + - The interval with all points from `a` where `a` < `x` + - The interval with `x` + - The interval with all points from `a` where `a` > `x` If `x` is not in `a` this function returns an empty list. + Note: Since 2.0.0, `x` can be a point _or_ an interval. + When `x` is a point, the middle interval will be an interval such that `[x,x]`. + + If there are no points in a to the left of `x`, an empty interval is returned for the left side. + The same of course applies to the right side of `x`. + ## Examples - iex> partition(new(module: Interval.IntegerInterval, left: 1, right: 5, bounds: "[]"), 3) + iex> partition(Interval.IntegerInterval.new(1, 5, "[]"), 3) [ - new(module: Interval.IntegerInterval, left: 1, right: 3, bounds: "[)"), - new(module: Interval.IntegerInterval, left: 3, right: 3, bounds: "[]"), - new(module: Interval.IntegerInterval, left: 3, right: 5, bounds: "(]") + Interval.IntegerInterval.new(1, 3, "[)"), + Interval.IntegerInterval.new(3, 3, "[]"), + Interval.IntegerInterval.new(3, 5, "(]") ] - iex> partition(new(module: Interval.IntegerInterval, left: 1, right: 5), -10) + iex> partition(Interval.IntegerInterval.new(1, 5), -10) [] + + iex> partition(Interval.IntegerInterval.new(1, 6), Interval.IntegerInterval.new(3, 4)) + [ + Interval.IntegerInterval.new(1, 3, "[)"), + Interval.IntegerInterval.new(3, 4, "[)"), + Interval.IntegerInterval.new(4, 6, "[)") + ] + + iex> partition(Interval.FloatInterval.new(1.0, 6.0), Interval.FloatInterval.new(1.0, 3.0, "[]")) + [ + Interval.FloatInterval.new(1.0, 1.0, "[)"), + Interval.FloatInterval.new(1.0, 3.0, "[]"), + Interval.FloatInterval.new(3.0, 6.0, "()") + ] """ @doc since: "0.1.4" - @spec partition(t(), point()) :: [t()] | [] - def partition(%module{} = a, x) do - case contains_point?(a, x) do - false -> - [] + @spec partition(t(), point() | t()) :: [t()] | [] + def partition(%module{} = a, %module{} = x) do + if contains?(a, x) and not empty?(x) do + # x might be unbounded, in which case the left/right side of x will be the empty interval. + left_of = + if unbounded_left?(x) do + new_empty(module) + else + from_endpoints(module, a.left, inverted_bound(x.left)) + end - true -> - [ - from_endpoints(module, a.left, {:exclusive, x}), - from_endpoints(module, {:inclusive, x}, {:inclusive, x}), - from_endpoints(module, {:exclusive, x}, a.right) - ] + right_of = + if unbounded_right?(x) do + new_empty(module) + else + from_endpoints(module, inverted_bound(x.right), a.right) + end + + [left_of, x, right_of] + else + [] end end + def partition(%module{} = a, x) do + partition(a, new(module: module, left: x, right: x, bounds: "[]")) + end + @doc """ Compare the left/right side of `a` with the left/right side of `b` diff --git a/test/continuous_interval_property_test.exs b/test/continuous_interval_property_test.exs index fc8ce93..cb0cb21 100644 --- a/test/continuous_interval_property_test.exs +++ b/test/continuous_interval_property_test.exs @@ -136,6 +136,35 @@ defmodule ContinuousIntervalPropertyTest do end end + property "partition/2", %{impl: impl} do + check all( + a <- Helper.interval(impl), + b <- Helper.interval(impl) + ) do + if Interval.contains?(a, b) and not Interval.empty?(b) do + [p1, p2, p3] = Interval.partition(a, b) + # the middle partition is b (when b is an interval) + assert p2 == b + # a contains p1,p2,p3 (since we sliced up a in 3 parts) + assert Interval.contains?(a, p1) + assert Interval.contains?(a, p3) + # the union of p1,p2,3 is a + a_prime = p1 |> Interval.union(p2) |> Interval.union(p3) + assert a_prime == a + + if Interval.unbounded_left?(b) do + assert Interval.empty?(p1) + end + + if Interval.unbounded_right?(b) do + assert Interval.empty?(p3) + end + else + assert [] == Interval.partition(a, b) + end + end + end + property "strictly_left_of?/2 and strictly_right_of?/2 relationship", %{impl: impl} do check all( a <- Helper.interval(impl, empty: false), diff --git a/test/discrete_interval_property_test.exs b/test/discrete_interval_property_test.exs index a7f5401..bc59b50 100644 --- a/test/discrete_interval_property_test.exs +++ b/test/discrete_interval_property_test.exs @@ -208,4 +208,33 @@ defmodule DiscreteIntervalPropertyTest do end end end + + property "partition/2", %{impl: impl} do + check all( + a <- Helper.interval(impl), + b <- Helper.interval(impl) + ) do + if Interval.contains?(a, b) and not Interval.empty?(b) do + [p1, p2, p3] = Interval.partition(a, b) + # the middle partition is b (when b is an interval) + assert p2 == b + # a contains p1,p2,p3 (since we sliced up a in 3 parts) + assert Interval.contains?(a, p1) + assert Interval.contains?(a, p3) + # the union of p1,p2,3 is a + a_prime = p1 |> Interval.union(p2) |> Interval.union(p3) + assert a_prime == a + + if Interval.unbounded_left?(b) do + assert Interval.empty?(p1) + end + + if Interval.unbounded_right?(b) do + assert Interval.empty?(p3) + end + else + assert [] == Interval.partition(a, b) + end + end + end end diff --git a/test/interval_test.exs b/test/interval_test.exs index 4413049..8e0717f 100644 --- a/test/interval_test.exs +++ b/test/interval_test.exs @@ -489,7 +489,7 @@ defmodule Interval.IntervalTest do assert Interval.contains?(floati(1.0, 2.0, "[]"), floati(1.0, 2.0, "()")) end - test "partition/2" do + test "partition/2 around a point" do assert Interval.partition(inti(1, 4), 2) === [inti(1, 2), inti(2), inti(3)] assert Interval.partition(inti(1, 4), 1) === [inti(0, 0), inti(1, 2), inti(2, 4)] assert Interval.partition(inti(1, 4), 3) === [inti(1, 3), inti(3), inti(0, 0)] @@ -507,16 +507,47 @@ defmodule Interval.IntervalTest do floati(2.0, 2.0, "[]"), floati(2.0, nil, "()") ] + + assert Interval.partition(inti(1, 4), 4) === [] end - test "partition/2's result unioned together is it's input interval" do + test "partition/2 around an interval" do + a = inti(1, 4) + b = inti(2, 2, "[]") + assert Interval.partition(a, b) === [inti(1, 2), inti(2, 2, "[]"), inti(3)] + a = inti(1, 4) + b = inti(nil, nil) + assert Interval.partition(a, b) === [] - b = - a - |> Interval.partition(2) - |> Enum.reduce(&Interval.union/2) + a = inti(nil, 5) + b = inti(2, 3, "[]") + assert Interval.partition(a, b) === [inti(nil, 2), inti(2, 3, "[]"), inti(3, 5, "()")] + a = inti(1, nil) + b = inti(2, 3, "[]") + assert Interval.partition(a, b) === [inti(1, 2, "[)"), inti(2, 3, "[]"), inti(3, nil, "()")] + + a = inti(nil, nil) + b = inti(1, 2) + assert Interval.partition(a, b) === [inti(nil, 1), inti(1, 2), inti(2, nil)] + + a = inti(nil, nil) + b = inti(nil, nil) + assert Interval.partition(a, b) === [inti(:empty), inti(nil, nil), inti(:empty)] + + a = inti(nil, nil) + b = inti(nil, 2) + assert Interval.partition(a, b) === [inti(:empty), inti(nil, 2), inti(2, nil)] + + a = inti(nil, nil) + b = inti(2, nil) + assert Interval.partition(a, b) === [inti(nil, 2), inti(2, nil), inti(:empty)] + end + + test "partition/2's result unioned together is it's input interval" do + a = inti(1, 4) + b = a |> Interval.partition(2) |> Enum.reduce(&Interval.union/2) assert a === b end diff --git a/test/regression_test.exs b/test/regression_test.exs index 313ae85..246da8e 100644 --- a/test/regression_test.exs +++ b/test/regression_test.exs @@ -50,4 +50,23 @@ defmodule Interval.RegressionTest do right: {:exclusive, Decimal.new("1")} } end + + test "partition/2 regression 2025-03-11" do + a = %IntegerInterval{left: {:inclusive, 2}, right: :unbounded} + b = %IntegerInterval{left: {:inclusive, -1}, right: :unbounded} + assert [] = Interval.partition(a, b) + + a = %IntegerInterval{left: :unbounded, right: :unbounded} + b = %IntegerInterval{left: :unbounded, right: :unbounded} + + assert [ + %IntegerInterval{left: :empty, right: :empty}, + %IntegerInterval{left: :unbounded, right: :unbounded}, + %IntegerInterval{left: :empty, right: :empty} + ] = Interval.partition(a, b) + + a = %Interval.IntegerInterval{left: {:inclusive, 1}, right: :unbounded} + b = %Interval.IntegerInterval{left: {:inclusive, -2}, right: :unbounded} + assert Interval.partition(a, b) == [] + end end