From d85ff50642969a9f2cc9b5ce35b7d780269cb316 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Fri, 23 Jan 2026 09:23:02 -0800 Subject: [PATCH 1/2] init change --- lib/eq.ex | 76 +++++++++++++------- lib/ord.ex | 26 +++++-- test/eq_test.exs | 174 ++++++++++++++++++++++++++++++++++++++++++++++ test/ord_test.exs | 123 ++++++++++++++++++++++++++++++++ 4 files changed, 366 insertions(+), 33 deletions(-) diff --git a/lib/eq.ex b/lib/eq.ex index 8b91363a..f00be59a 100644 --- a/lib/eq.ex +++ b/lib/eq.ex @@ -9,8 +9,8 @@ defmodule Funx.Eq do 1. **Utility functions** for working with equality comparisons: - `contramap/2` - Transform equality checks via projections - `eq?/3`, `not_eq?/3` - Direct equality checks - - `append_all/2`, `append_any/2` - Combine comparators - - `concat_all/1`, `concat_any/1` - Combine lists of comparators + - `compose_all/2`, `compose_any/2` - Combine comparators + - `compose_all/1`, `compose_any/1` - Combine lists of comparators - `to_predicate/2` - Convert to single-argument predicates 2. **Declarative DSL** for building complex equality comparators: @@ -375,7 +375,7 @@ defmodule Funx.Eq do end @doc """ - Combines two equality comparators using the `Eq.All` monoid. + Composes two equality comparators using the `Eq.All` monoid. This function merges two equality comparisons, requiring **both** to return `true` for the final result to be considered equal. This enforces a **strict** equality rule, @@ -385,61 +385,61 @@ defmodule Funx.Eq do iex> eq1 = Funx.Eq.contramap(& &1.name) iex> eq2 = Funx.Eq.contramap(& &1.age) - iex> combined = Funx.Eq.append_all(eq1, eq2) + iex> combined = Funx.Eq.compose_all(eq1, eq2) iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 30}, combined) true iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 25}, combined) false """ - @spec append_all(eq_t(), eq_t()) :: eq_t() - def append_all(a, b) do + @spec compose_all(eq_t(), eq_t()) :: eq_t() + def compose_all(a, b) do m_append(%Monoid.Eq.All{}, a, b) end @doc """ - Combines two equality comparators using the `Eq.Any` monoid. + Composes a list of equality comparators using the `Eq.All` monoid. - This function merges two equality comparisons, where **at least one** - must return `true` for the final result to be considered equal. + The resulting comparator requires **all** comparators in the list to agree + that two values are equal. ## Examples iex> eq1 = Funx.Eq.contramap(& &1.name) iex> eq2 = Funx.Eq.contramap(& &1.age) - iex> combined = Funx.Eq.append_any(eq1, eq2) - iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 25}, combined) + iex> combined = Funx.Eq.compose_all([eq1, eq2]) + iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 30}, combined) true - iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Bob", age: 25}, combined) + iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 25}, combined) false """ - @spec append_any(eq_t(), eq_t()) :: eq_t() - def append_any(a, b) do - m_append(%Monoid.Eq.Any{}, a, b) + @spec compose_all([eq_t()]) :: eq_t() + def compose_all(eq_list) when is_list(eq_list) do + m_concat(%Monoid.Eq.All{}, eq_list) end @doc """ - Concatenates a list of equality comparators using the `Eq.All` monoid. + Composes two equality comparators using the `Eq.Any` monoid. - The resulting comparator requires **all** comparators in the list to agree - that two values are equal. + This function merges two equality comparisons, where **at least one** + must return `true` for the final result to be considered equal. ## Examples iex> eq1 = Funx.Eq.contramap(& &1.name) iex> eq2 = Funx.Eq.contramap(& &1.age) - iex> combined = Funx.Eq.concat_all([eq1, eq2]) - iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 30}, combined) - true + iex> combined = Funx.Eq.compose_any(eq1, eq2) iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 25}, combined) + true + iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Bob", age: 25}, combined) false """ - @spec concat_all([eq_t()]) :: eq_t() - def concat_all(eq_list) when is_list(eq_list) do - m_concat(%Monoid.Eq.All{}, eq_list) + @spec compose_any(eq_t(), eq_t()) :: eq_t() + def compose_any(a, b) do + m_append(%Monoid.Eq.Any{}, a, b) end @doc """ - Concatenates a list of equality comparators using the `Eq.Any` monoid. + Composes a list of equality comparators using the `Eq.Any` monoid. The resulting comparator allows **any** comparator in the list to determine equality, making it more permissive. @@ -448,12 +448,36 @@ defmodule Funx.Eq do iex> eq1 = Funx.Eq.contramap(& &1.name) iex> eq2 = Funx.Eq.contramap(& &1.age) - iex> combined = Funx.Eq.concat_any([eq1, eq2]) + iex> combined = Funx.Eq.compose_any([eq1, eq2]) iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 25}, combined) true iex> Funx.Eq.eq?(%{name: "Alice", age: 30}, %{name: "Bob", age: 25}, combined) false """ + @spec compose_any([eq_t()]) :: eq_t() + def compose_any(eq_list) when is_list(eq_list) do + m_concat(%Monoid.Eq.Any{}, eq_list) + end + + @deprecated "Use compose_all/2 instead" + @spec append_all(eq_t(), eq_t()) :: eq_t() + def append_all(a, b) do + m_append(%Monoid.Eq.All{}, a, b) + end + + @deprecated "Use compose_any/2 instead" + @spec append_any(eq_t(), eq_t()) :: eq_t() + def append_any(a, b) do + m_append(%Monoid.Eq.Any{}, a, b) + end + + @deprecated "Use compose_all/1 instead" + @spec concat_all([eq_t()]) :: eq_t() + def concat_all(eq_list) when is_list(eq_list) do + m_concat(%Monoid.Eq.All{}, eq_list) + end + + @deprecated "Use compose_any/1 instead" @spec concat_any([eq_t()]) :: eq_t() def concat_any(eq_list) when is_list(eq_list) do m_concat(%Monoid.Eq.Any{}, eq_list) diff --git a/lib/ord.ex b/lib/ord.ex index 9f00fe96..6b87409c 100644 --- a/lib/ord.ex +++ b/lib/ord.ex @@ -20,7 +20,7 @@ defmodule Funx.Ord do - `reverse/1` - Reverse ordering logic - `comparator/1` - Convert to Elixir comparator for `Enum.sort/2` - `to_eq/1` - Convert to equality comparator - - `append/2`, `concat/1` - Combine multiple orderings + - `compose/2`, `compose/1` - Combine multiple orderings ## DSL @@ -392,7 +392,7 @@ defmodule Funx.Ord do end @doc """ - Appends two `Ord` instances, combining their comparison logic. + Composes two `Ord` instances, combining their comparison logic. If the first `Ord` comparator determines an order, that result is used. If not, the second comparator is used as a fallback. @@ -401,17 +401,17 @@ defmodule Funx.Ord do iex> ord1 = Funx.Ord.contramap(& &1.age, Funx.Ord.Protocol.Any) iex> ord2 = Funx.Ord.contramap(& &1.name, Funx.Ord.Protocol.Any) - iex> combined = Funx.Ord.append(ord1, ord2) + iex> combined = Funx.Ord.compose(ord1, ord2) iex> combined.lt?.(%{age: 30, name: "Alice"}, %{age: 30, name: "Bob"}) true """ - @spec append(ord_t(), ord_t()) :: ord_t() - def append(a, b) do + @spec compose(ord_t(), ord_t()) :: ord_t() + def compose(a, b) do m_append(%Funx.Monoid.Ord{}, a, b) end @doc """ - Concatenates a list of `Ord` instances into a single composite comparator. + Composes a list of `Ord` instances into a single composite comparator. This function reduces a list of `Ord` comparators into a single `Ord`, applying them in sequence until an order is determined. @@ -422,10 +422,22 @@ defmodule Funx.Ord do ...> Funx.Ord.contramap(& &1.age, Funx.Ord.Protocol.Any), ...> Funx.Ord.contramap(& &1.name, Funx.Ord.Protocol.Any) ...> ] - iex> combined = Funx.Ord.concat(ord_list) + iex> combined = Funx.Ord.compose(ord_list) iex> combined.gt?.(%{age: 25, name: "Charlie"}, %{age: 25, name: "Bob"}) true """ + @spec compose([ord_t()]) :: ord_t() + def compose(ord_list) when is_list(ord_list) do + m_concat(%Funx.Monoid.Ord{}, ord_list) + end + + @deprecated "Use compose/2 instead" + @spec append(ord_t(), ord_t()) :: ord_t() + def append(a, b) do + m_append(%Funx.Monoid.Ord{}, a, b) + end + + @deprecated "Use compose/1 instead" @spec concat([ord_t()]) :: ord_t() def concat(ord_list) when is_list(ord_list) do m_concat(%Funx.Monoid.Ord{}, ord_list) diff --git a/test/eq_test.exs b/test/eq_test.exs index 7dddaee3..9a7b776a 100644 --- a/test/eq_test.exs +++ b/test/eq_test.exs @@ -407,6 +407,16 @@ defmodule Funx.EqTest do defp eq_concat_all_default, do: Funx.Eq.concat_all([Funx.Eq.Protocol]) defp eq_concat_any_default, do: Funx.Eq.concat_any([Funx.Eq.Protocol]) + # Compose fixtures (new API) + defp eq_compose_all_2, do: Funx.Eq.compose_all(eq_name(), eq_age()) + defp eq_compose_any_2, do: Funx.Eq.compose_any(eq_name(), eq_age()) + + defp eq_compose_all_list, do: Funx.Eq.compose_all([eq_name(), eq_age()]) + defp eq_compose_any_list, do: Funx.Eq.compose_any([eq_name(), eq_age()]) + + defp eq_compose_all_default, do: Funx.Eq.compose_all([Funx.Eq.Protocol]) + defp eq_compose_any_default, do: Funx.Eq.compose_any([Funx.Eq.Protocol]) + describe "Eq Monoid - append" do test "append with equal persons" do alice1 = %Person{name: "Alice", age: 30} @@ -495,6 +505,102 @@ defmodule Funx.EqTest do end end + describe "Eq Monoid - compose_all" do + test "compose_all/2 combines with AND logic" do + alice1 = %Person{name: "Alice", age: 30} + alice2 = %Person{name: "Alice", age: 30} + alice3 = %Person{name: "Alice", age: 29} + + assert Funx.Eq.eq?(alice1, alice2, eq_compose_all_2()) + refute Funx.Eq.eq?(alice1, alice3, eq_compose_all_2()) + + refute Funx.Eq.not_eq?(alice1, alice2, eq_compose_all_2()) + assert Funx.Eq.not_eq?(alice1, alice3, eq_compose_all_2()) + end + + test "compose_all/1 with list combines with AND logic" do + alice1 = %Person{name: "Alice", age: 30} + alice2 = %Person{name: "Alice", age: 30} + alice3 = %Person{name: "Alice", age: 29} + + assert Funx.Eq.eq?(alice1, alice2, eq_compose_all_list()) + refute Funx.Eq.eq?(alice1, alice3, eq_compose_all_list()) + + refute Funx.Eq.not_eq?(alice1, alice2, eq_compose_all_list()) + assert Funx.Eq.not_eq?(alice1, alice3, eq_compose_all_list()) + end + + test "compose_all/1 with default (name)" do + alice1 = %Person{name: "Alice", age: 30} + alice2 = %Person{name: "Alice", age: 29} + bob = %Person{name: "Bob", age: 30} + + assert Funx.Eq.eq?(alice1, alice2, eq_compose_all_default()) + refute Funx.Eq.eq?(alice1, bob, eq_compose_all_default()) + + refute Funx.Eq.not_eq?(alice1, alice2, eq_compose_all_default()) + assert Funx.Eq.not_eq?(alice1, bob, eq_compose_all_default()) + end + + test "compose_all/1 with empty list behaves as identity (vacuous truth)" do + alice = %Person{name: "Alice", age: 30} + bob = %Person{name: "Bob", age: 25} + + empty_eq = Funx.Eq.compose_all([]) + + assert empty_eq.eq?.(alice, alice) + assert empty_eq.eq?.(alice, bob) + end + end + + describe "Eq Monoid - compose_any" do + test "compose_any/2 combines with OR logic" do + alice1 = %Person{name: "Alice", age: 30} + alice2 = %Person{name: "Alice", age: 29} + bob = %Person{name: "Bob", age: 25} + + assert Funx.Eq.eq?(alice1, alice2, eq_compose_any_2()) + refute Funx.Eq.eq?(alice1, bob, eq_compose_any_2()) + + refute Funx.Eq.not_eq?(alice1, alice2, eq_compose_any_2()) + assert Funx.Eq.not_eq?(alice1, bob, eq_compose_any_2()) + end + + test "compose_any/1 with list combines with OR logic" do + alice1 = %Person{name: "Alice", age: 30} + alice2 = %Person{name: "Alice", age: 29} + bob = %Person{name: "Bob", age: 25} + + assert Funx.Eq.eq?(alice1, alice2, eq_compose_any_list()) + refute Funx.Eq.eq?(alice1, bob, eq_compose_any_list()) + + refute Funx.Eq.not_eq?(alice1, alice2, eq_compose_any_list()) + assert Funx.Eq.not_eq?(alice1, bob, eq_compose_any_list()) + end + + test "compose_any/1 with default (name)" do + alice1 = %Person{name: "Alice", age: 30} + alice2 = %Person{name: "Alice", age: 29} + bob = %Person{name: "Bob", age: 30} + + assert Funx.Eq.eq?(alice1, alice2, eq_compose_any_default()) + refute Funx.Eq.eq?(alice1, bob, eq_compose_any_default()) + + refute Funx.Eq.not_eq?(alice1, alice2, eq_compose_any_default()) + assert Funx.Eq.not_eq?(alice1, bob, eq_compose_any_default()) + end + + test "compose_any/1 with empty list makes nothing equal" do + alice = %Person{name: "Alice", age: 30} + bob = %Person{name: "Bob", age: 25} + + empty_eq = Funx.Eq.compose_any([]) + + refute empty_eq.eq?.(alice, alice) + refute empty_eq.eq?.(alice, bob) + end + end + # ============================================================================ # Utility Functions Tests # ============================================================================ @@ -816,6 +922,74 @@ defmodule Funx.EqTest do end end end + + property "compose_all/1: empty list behaves as identity" do + check all(value <- integer()) do + # Empty compose_all should compare everything as equal (identity) + empty_eq = Funx.Eq.compose_all([]) + + # With empty list, everything equals everything (vacuous truth) + assert empty_eq.eq?.(value, value) + assert empty_eq.eq?.(value, value + 1) + end + end + + property "compose_any/1: empty list behaves as identity" do + check all(value <- integer()) do + # Empty compose_any should compare nothing as equal + empty_eq = Funx.Eq.compose_any([]) + + # With empty list, nothing equals anything (even itself) + refute empty_eq.eq?.(value, value) + refute empty_eq.eq?.(value, value + 1) + end + end + + property "compose_all/2 combines equality checks with AND logic" do + check all( + name1 <- string(:alphanumeric, min_length: 1), + name2 <- string(:alphanumeric, min_length: 1), + age1 <- integer(1..100), + age2 <- integer(1..100) + ) do + eq_name = Funx.Eq.contramap(& &1.name) + eq_age = Funx.Eq.contramap(& &1.age) + eq_all = Funx.Eq.compose_all(eq_name, eq_age) + + person1 = %{name: name1, age: age1} + person2 = %{name: name2, age: age2} + + # Both name and age must match + if name1 == name2 and age1 == age2 do + assert eq_all.eq?.(person1, person2) + else + refute eq_all.eq?.(person1, person2) + end + end + end + + property "compose_any/2 combines equality checks with OR logic" do + check all( + name1 <- string(:alphanumeric, min_length: 1), + name2 <- string(:alphanumeric, min_length: 1), + age1 <- integer(1..100), + age2 <- integer(1..100) + ) do + eq_name = Funx.Eq.contramap(& &1.name) + eq_age = Funx.Eq.contramap(& &1.age) + eq_any = Funx.Eq.compose_any(eq_name, eq_age) + + person1 = %{name: name1, age: age1} + person2 = %{name: name2, age: age2} + + # Either name or age must match + if name1 == name2 or age1 == age2 do + assert eq_any.eq?.(person1, person2) + else + refute eq_any.eq?.(person1, person2) + end + end + end end describe "property: to_predicate behavior" do diff --git a/test/ord_test.exs b/test/ord_test.exs index f95b0fad..5864c250 100644 --- a/test/ord_test.exs +++ b/test/ord_test.exs @@ -28,6 +28,13 @@ defmodule Funx.OrdTest do defp ord_concat_default, do: concat([Funx.Ord.Protocol]) defp ord_empty, do: concat([]) + # Compose fixtures (new API) + defp ord_compose_2, do: compose(ord_name(), ord_age()) + defp ord_compose_list, do: compose([ord_name(), ord_age()]) + defp ord_compose_age, do: compose([ord_age(), ord_ticket(), Funx.Ord.Protocol]) + defp ord_compose_default, do: compose([Funx.Ord.Protocol]) + defp ord_compose_empty, do: compose([]) + # ============================================================================ # Basic Operations Tests # ============================================================================ @@ -439,6 +446,57 @@ defmodule Funx.OrdTest do end end + describe "Ord Monoid - compose" do + test "compose/2 combines two orderings" do + alice = %Person{name: "Alice", age: 30, ticket: :b} + bob = %Person{name: "Bob", age: 25, ticket: :a} + + assert compare(alice, bob, ord_compose_2()) == :lt + assert compare(bob, alice, ord_compose_2()) == :gt + assert compare(alice, alice, ord_compose_2()) == :eq + end + + test "compose/1 with list combines orderings lexicographically" do + alice = %Person{name: "Alice", age: 30, ticket: :b} + bob = %Person{name: "Bob", age: 25, ticket: :a} + bob_b = %Person{name: "Bob", age: 30, ticket: :a} + bob_c = %Person{name: "Bob", age: 30, ticket: :b} + + assert compare(alice, bob, ord_compose_list()) == :lt + assert compare(bob, alice, ord_compose_list()) == :gt + assert compare(alice, alice, ord_compose_list()) == :eq + + assert compare(alice, bob, ord_compose_age()) == :gt + assert compare(bob, alice, ord_compose_age()) == :lt + assert compare(alice, alice, ord_compose_age()) == :eq + assert compare(alice, bob_b, ord_compose_age()) == :gt + assert compare(alice, bob_c, ord_compose_age()) == :lt + + assert ord_compose_list().lt?.(alice, bob) + assert ord_compose_list().le?.(alice, alice) + assert ord_compose_list().gt?.(bob, alice) + assert ord_compose_list().ge?.(bob, alice) + end + + test "compose/1 with empty list makes everything equal" do + alice = %Person{name: "Alice", age: 30} + bob = %Person{name: "Bob", age: 25} + + assert compare(alice, bob, ord_compose_empty()) == :eq + assert compare(bob, alice, ord_compose_empty()) == :eq + assert compare(alice, alice, ord_compose_empty()) == :eq + end + + test "compose/1 with default ord (name)" do + alice = %Person{name: "Alice", age: 30} + bob = %Person{name: "Bob", age: 25} + + assert compare(alice, bob, ord_compose_default()) == :lt + assert compare(bob, alice, ord_compose_default()) == :gt + assert compare(alice, alice, ord_compose_default()) == :eq + end + end + # ============================================================================ # Property-Based Tests # ============================================================================ @@ -803,5 +861,70 @@ defmodule Funx.OrdTest do assert compare(x, y, left) == compare(x, y, right) end end + + property "compose/1 with empty list is identity (all equal)" do + check all( + a <- integer(), + b <- integer() + ) do + empty_ord = compose([]) + + # Empty compose makes everything equal + assert compare(a, b, empty_ord) == :eq + end + end + + property "compose/1 combines orderings lexicographically" do + check all( + name1 <- string(:alphanumeric, min_length: 1), + name2 <- string(:alphanumeric, min_length: 1), + age1 <- integer(1..100), + age2 <- integer(1..100) + ) do + ord_name = contramap(& &1.name) + ord_age = contramap(& &1.age) + combined = compose([ord_name, ord_age]) + + person1 = %{name: name1, age: age1} + person2 = %{name: name2, age: age2} + + cond do + # Names differ - ordering determined by name + name1 < name2 -> + assert combined.lt?.(person1, person2) + + name1 > name2 -> + assert combined.gt?.(person1, person2) + + # Names equal - ordering determined by age + name1 == name2 and age1 < age2 -> + assert combined.lt?.(person1, person2) + + name1 == name2 and age1 > age2 -> + assert combined.gt?.(person1, person2) + + # Both equal + name1 == name2 and age1 == age2 -> + assert compare(person1, person2, combined) == :eq + end + end + end + + property "compose/2 is associative" do + check all( + x <- integer(), + y <- integer() + ) do + ord1 = contramap(& &1) + ord2 = contramap(& &1) + ord3 = contramap(& &1) + + # (ord1 + ord2) + ord3 == ord1 + (ord2 + ord3) + left = compose(compose(ord1, ord2), ord3) + right = compose(ord1, compose(ord2, ord3)) + + assert compare(x, y, left) == compare(x, y, right) + end + end end end From 9f75a0e6562724807a797b0e04e1b4c7ca41e825 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Fri, 23 Jan 2026 12:28:13 -0800 Subject: [PATCH 2/2] Add compose --- lib/eq.ex | 16 +--- lib/eq/dsl/block.ex | 4 +- lib/eq/dsl/executor.ex | 14 ++-- lib/ord.ex | 8 +- lib/ord/dsl/executor.ex | 6 +- livebooks/eq/eq.livemd | 32 ++++---- livebooks/ord/ord.livemd | 12 +-- test/eq_test.exs | 157 ++++----------------------------------- test/monoid/max_test.exs | 6 +- test/ord_test.exs | 124 +++---------------------------- 10 files changed, 65 insertions(+), 314 deletions(-) diff --git a/lib/eq.ex b/lib/eq.ex index f00be59a..2031c928 100644 --- a/lib/eq.ex +++ b/lib/eq.ex @@ -461,27 +461,19 @@ defmodule Funx.Eq do @deprecated "Use compose_all/2 instead" @spec append_all(eq_t(), eq_t()) :: eq_t() - def append_all(a, b) do - m_append(%Monoid.Eq.All{}, a, b) - end + def append_all(a, b), do: compose_all(a, b) @deprecated "Use compose_any/2 instead" @spec append_any(eq_t(), eq_t()) :: eq_t() - def append_any(a, b) do - m_append(%Monoid.Eq.Any{}, a, b) - end + def append_any(a, b), do: compose_any(a, b) @deprecated "Use compose_all/1 instead" @spec concat_all([eq_t()]) :: eq_t() - def concat_all(eq_list) when is_list(eq_list) do - m_concat(%Monoid.Eq.All{}, eq_list) - end + def concat_all(eq_list) when is_list(eq_list), do: compose_all(eq_list) @deprecated "Use compose_any/1 instead" @spec concat_any([eq_t()]) :: eq_t() - def concat_any(eq_list) when is_list(eq_list) do - m_concat(%Monoid.Eq.Any{}, eq_list) - end + def concat_any(eq_list) when is_list(eq_list), do: compose_any(eq_list) @doc """ Converts an `Eq` comparator into a single-argument predicate function for use in `Enum` functions. diff --git a/lib/eq/dsl/block.ex b/lib/eq/dsl/block.ex index 1f200e0a..c7682abb 100644 --- a/lib/eq/dsl/block.ex +++ b/lib/eq/dsl/block.ex @@ -5,8 +5,8 @@ defmodule Funx.Eq.Dsl.Block do # ## Purpose # # Blocks group multiple equality checks with a composition strategy: - # - `:all` → All children must pass (AND logic) via concat_all - # - `:any` → At least one child must pass (OR logic) via concat_any + # - `:all` → All children must pass (AND logic) via compose_all + # - `:any` → At least one child must pass (OR logic) via compose_any # # ## Structure # diff --git a/lib/eq/dsl/executor.ex b/lib/eq/dsl/executor.ex index 68504ff4..e2878b06 100644 --- a/lib/eq/dsl/executor.ex +++ b/lib/eq/dsl/executor.ex @@ -24,10 +24,10 @@ defmodule Funx.Eq.Dsl.Executor do # # The executor recursively walks the node tree: # - Step nodes → Generate contramap/to_eq_map calls - # - Block nodes → Generate concat_all/concat_any calls + # - Block nodes → Generate compose_all/compose_any calls # - Negate flag → Swap eq?/not_eq? functions # - # Top-level nodes are implicitly combined with concat_all (AND logic). + # Top-level nodes are implicitly combined with compose_all (AND logic). alias Funx.Eq alias Funx.Eq.Dsl.{Block, Step} @@ -43,10 +43,10 @@ defmodule Funx.Eq.Dsl.Executor do Each node is converted to: - Step (on) → `contramap(projection, eq)` - Step (not_on) → `contramap(projection, negate(eq))` - - Block (all) → `concat_all([children...])` - - Block (any) → `concat_any([children...])` + - Block (all) → `compose_all([children...])` + - Block (any) → `compose_any([children...])` - Top-level nodes are combined with `concat_all` (implicit all strategy). + Top-level nodes are combined with `compose_all` (implicit all strategy). """ @spec execute_nodes(list(Step.t() | Block.t())) :: Macro.t() def execute_nodes([]) do @@ -60,7 +60,7 @@ defmodule Funx.Eq.Dsl.Executor do eq_asts = Enum.map(nodes, &node_to_ast/1) quote do - Eq.concat_all([unquote_splicing(eq_asts)]) + Eq.compose_all([unquote_splicing(eq_asts)]) end end @@ -68,7 +68,7 @@ defmodule Funx.Eq.Dsl.Executor do eq_asts = Enum.map(nodes, &node_to_ast/1) quote do - Eq.concat_any([unquote_splicing(eq_asts)]) + Eq.compose_any([unquote_splicing(eq_asts)]) end end diff --git a/lib/ord.ex b/lib/ord.ex index 6b87409c..d4ad36ac 100644 --- a/lib/ord.ex +++ b/lib/ord.ex @@ -433,15 +433,11 @@ defmodule Funx.Ord do @deprecated "Use compose/2 instead" @spec append(ord_t(), ord_t()) :: ord_t() - def append(a, b) do - m_append(%Funx.Monoid.Ord{}, a, b) - end + def append(a, b), do: compose(a, b) @deprecated "Use compose/1 instead" @spec concat([ord_t()]) :: ord_t() - def concat(ord_list) when is_list(ord_list) do - m_concat(%Funx.Monoid.Ord{}, ord_list) - end + def concat(ord_list) when is_list(ord_list), do: compose(ord_list) def to_ord_map(%{lt?: lt_fun, le?: le_fun, gt?: gt_fun, ge?: ge_fun} = ord_map) when is_function(lt_fun, 2) and diff --git a/lib/ord/dsl/executor.ex b/lib/ord/dsl/executor.ex index 2f132d70..baea260f 100644 --- a/lib/ord/dsl/executor.ex +++ b/lib/ord/dsl/executor.ex @@ -32,7 +32,7 @@ defmodule Funx.Ord.Dsl.Executor do - `:asc` → `contramap(projection, ord)` - `:desc` → `reverse(contramap(projection, ord))` - Multiple steps are combined with `concat([...])` (monoid append). + Multiple steps are combined with `compose([...])` (monoid append). If two values are equal on all specified fields, they compare as equal. Users can add an explicit tiebreaker if needed (e.g., `asc &Function.identity/1`). @@ -50,7 +50,7 @@ defmodule Funx.Ord.Dsl.Executor do ord_asts = Enum.map(steps, &step_to_ord_ast/1) quote do - Ord.concat([unquote_splicing(ord_asts)]) + Ord.compose([unquote_splicing(ord_asts)]) end end @@ -166,7 +166,7 @@ defmodule Funx.Ord.Dsl.Executor do - ord do ... end - Ord.contramap(...) - Ord.reverse(...) - - Ord.concat([...]) + - Ord.compose([...]) """ end end diff --git a/livebooks/eq/eq.livemd b/livebooks/eq/eq.livemd index 4edf975a..13d90a91 100644 --- a/livebooks/eq/eq.livemd +++ b/livebooks/eq/eq.livemd @@ -128,9 +128,9 @@ eq_by?(& &1.age, %{age: 30}, %{age: 25}) ## Monoid Operations -### append_all/2 +### compose_all/2 -Combines two equality comparators using the `Eq.All` monoid. +Composes two equality comparators using the `Eq.All` monoid. This function merges two equality comparisons, requiring **both** to return `true` for the final result to be considered equal. This enforces a **strict** equality rule, @@ -139,20 +139,20 @@ where all comparators must agree. ```elixir eq1 = contramap(& &1.name) eq2 = contramap(& &1.age) -combined = append_all(eq1, eq2) +combined = compose_all(eq1, eq2) eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 30}, combined) ``` ```elixir eq1 = contramap(& &1.name) eq2 = contramap(& &1.age) -combined = append_all(eq1, eq2) +combined = compose_all(eq1, eq2) eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 25}, combined) ``` -### append_any/2 +### compose_any/2 -Combines two equality comparators using the `Eq.Any` monoid. +Composes two equality comparators using the `Eq.Any` monoid. This function merges two equality comparisons, where **at least one** must return `true` for the final result to be considered equal. @@ -160,20 +160,20 @@ must return `true` for the final result to be considered equal. ```elixir eq1 = contramap(& &1.name) eq2 = contramap(& &1.age) -combined = append_any(eq1, eq2) +combined = compose_any(eq1, eq2) eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 25}, combined) ``` ```elixir eq1 = contramap(& &1.name) eq2 = contramap(& &1.age) -combined = append_any(eq1, eq2) +combined = compose_any(eq1, eq2) eq?(%{name: "Alice", age: 30}, %{name: "Bob", age: 25}, combined) ``` -### concat_all/1 +### compose_all/1 -Concatenates a list of equality comparators using the `Eq.All` monoid. +Composes a list of equality comparators using the `Eq.All` monoid. The resulting comparator requires **all** comparators in the list to agree that two values are equal. @@ -181,20 +181,20 @@ that two values are equal. ```elixir eq1 = contramap(& &1.name) eq2 = contramap(& &1.age) -combined = concat_all([eq1, eq2]) +combined = compose_all([eq1, eq2]) eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 30}, combined) ``` ```elixir eq1 = contramap(& &1.name) eq2 = contramap(& &1.age) -combined = concat_all([eq1, eq2]) +combined = compose_all([eq1, eq2]) eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 25}, combined) ``` -### concat_any/1 +### compose_any/1 -Concatenates a list of equality comparators using the `Eq.Any` monoid. +Composes a list of equality comparators using the `Eq.Any` monoid. The resulting comparator allows **any** comparator in the list to determine equality, making it more permissive. @@ -202,14 +202,14 @@ equality, making it more permissive. ```elixir eq1 = contramap(& &1.name) eq2 = contramap(& &1.age) -combined = concat_any([eq1, eq2]) +combined = compose_any([eq1, eq2]) eq?(%{name: "Alice", age: 30}, %{name: "Alice", age: 25}, combined) ``` ```elixir eq1 = contramap(& &1.name) eq2 = contramap(& &1.age) -combined = concat_any([eq1, eq2]) +combined = compose_any([eq1, eq2]) eq?(%{name: "Alice", age: 30}, %{name: "Bob", age: 25}, combined) ``` diff --git a/livebooks/ord/ord.livemd b/livebooks/ord/ord.livemd index a3a0ebba..b1fd6d7a 100644 --- a/livebooks/ord/ord.livemd +++ b/livebooks/ord/ord.livemd @@ -210,9 +210,9 @@ eq = to_eq(Funx.Ord.Protocol) eq.eq?.(5, 5) ``` -### append/2 +### compose/2 -Appends two `Ord` instances, combining their comparison logic. +Composes two `Ord` instances, combining their comparison logic. If the first `Ord` comparator determines an order, that result is used. If not, the second comparator is used as a fallback. @@ -220,13 +220,13 @@ If not, the second comparator is used as a fallback. ```elixir ord1 = contramap(& &1.age, Funx.Ord.Protocol) ord2 = contramap(& &1.name, Funx.Ord.Protocol) -combined = append(ord1, ord2) +combined = compose(ord1, ord2) combined.lt?.(%{age: 30, name: "Alice"}, %{age: 30, name: "Bob"}) ``` -### concat/1 +### compose/1 -Concatenates a list of `Ord` instances into a single composite comparator. +Composes a list of `Ord` instances into a single composite comparator. This function reduces a list of `Ord` comparators into a single `Ord`, applying them in sequence until an order is determined. @@ -236,6 +236,6 @@ ord_list = [ contramap(& &1.age, Funx.Ord.Protocol), contramap(& &1.name, Funx.Ord.Protocol) ] -combined = concat(ord_list) +combined = compose(ord_list) combined.gt?.(%{age: 25, name: "Charlie"}, %{age: 25, name: "Bob"}) ``` diff --git a/test/eq_test.exs b/test/eq_test.exs index 9a7b776a..399fb49e 100644 --- a/test/eq_test.exs +++ b/test/eq_test.exs @@ -398,16 +398,7 @@ defmodule Funx.EqTest do defp eq_name, do: Funx.Eq.contramap(& &1.name) defp eq_age, do: Funx.Eq.contramap(& &1.age) - defp eq_all, do: Funx.Eq.append_all(eq_name(), eq_age()) - defp eq_any, do: Funx.Eq.append_any(eq_name(), eq_age()) - defp eq_concat_all, do: Funx.Eq.concat_all([eq_name(), eq_age()]) - defp eq_concat_any, do: Funx.Eq.concat_any([eq_name(), eq_age()]) - - defp eq_concat_all_default, do: Funx.Eq.concat_all([Funx.Eq.Protocol]) - defp eq_concat_any_default, do: Funx.Eq.concat_any([Funx.Eq.Protocol]) - - # Compose fixtures (new API) defp eq_compose_all_2, do: Funx.Eq.compose_all(eq_name(), eq_age()) defp eq_compose_any_2, do: Funx.Eq.compose_any(eq_name(), eq_age()) @@ -417,91 +408,37 @@ defmodule Funx.EqTest do defp eq_compose_all_default, do: Funx.Eq.compose_all([Funx.Eq.Protocol]) defp eq_compose_any_default, do: Funx.Eq.compose_any([Funx.Eq.Protocol]) - describe "Eq Monoid - append" do - test "append with equal persons" do + describe "Eq Monoid - deprecated append/concat" do + test "append_all/2 delegates to compose_all/2" do alice1 = %Person{name: "Alice", age: 30} alice2 = %Person{name: "Alice", age: 30} - assert Funx.Eq.eq?(alice1, alice2, eq_name()) - assert Funx.Eq.eq?(alice1, alice2, eq_age()) - assert Funx.Eq.eq?(alice1, alice2, eq_all()) - assert Funx.Eq.eq?(alice1, alice2, eq_any()) - - refute Funx.Eq.not_eq?(alice1, alice2, eq_name()) - refute Funx.Eq.not_eq?(alice1, alice2, eq_age()) - refute Funx.Eq.not_eq?(alice1, alice2, eq_all()) - refute Funx.Eq.not_eq?(alice1, alice2, eq_any()) + combined = Funx.Eq.append_all(eq_name(), eq_age()) + assert Funx.Eq.eq?(alice1, alice2, combined) end - test "append with not equal persons" do + test "append_any/2 delegates to compose_any/2" do alice1 = %Person{name: "Alice", age: 30} alice2 = %Person{name: "Alice", age: 29} - assert Funx.Eq.eq?(alice1, alice2, eq_name()) - refute Funx.Eq.eq?(alice1, alice2, eq_age()) - refute Funx.Eq.eq?(alice1, alice2, eq_all()) - assert Funx.Eq.eq?(alice1, alice2, eq_any()) - - refute Funx.Eq.not_eq?(alice1, alice2, eq_name()) - assert Funx.Eq.not_eq?(alice1, alice2, eq_age()) - assert Funx.Eq.not_eq?(alice1, alice2, eq_all()) - refute Funx.Eq.not_eq?(alice1, alice2, eq_any()) + combined = Funx.Eq.append_any(eq_name(), eq_age()) + assert Funx.Eq.eq?(alice1, alice2, combined) end - end - describe "Eq Monoid - concat" do - test "concat with equal persons" do + test "concat_all/1 delegates to compose_all/1" do alice1 = %Person{name: "Alice", age: 30} alice2 = %Person{name: "Alice", age: 30} - assert Funx.Eq.eq?(alice1, alice2, eq_name()) - assert Funx.Eq.eq?(alice1, alice2, eq_age()) - assert Funx.Eq.eq?(alice1, alice2, eq_concat_all()) - assert Funx.Eq.eq?(alice1, alice2, eq_concat_any()) - - refute Funx.Eq.not_eq?(alice1, alice2, eq_name()) - refute Funx.Eq.not_eq?(alice1, alice2, eq_age()) - refute Funx.Eq.not_eq?(alice1, alice2, eq_concat_all()) - refute Funx.Eq.not_eq?(alice1, alice2, eq_concat_any()) - end - - test "concat with not equal persons" do - alice1 = %Person{name: "Alice", age: 30} - alice2 = %Person{name: "Alice", age: 29} - - assert Funx.Eq.eq?(alice1, alice2, eq_name()) - refute Funx.Eq.eq?(alice1, alice2, eq_age()) - refute Funx.Eq.eq?(alice1, alice2, eq_concat_all()) - assert Funx.Eq.eq?(alice1, alice2, eq_concat_any()) - - refute Funx.Eq.not_eq?(alice1, alice2, eq_name()) - assert Funx.Eq.not_eq?(alice1, alice2, eq_age()) - assert Funx.Eq.not_eq?(alice1, alice2, eq_concat_all()) - refute Funx.Eq.not_eq?(alice1, alice2, eq_any()) - end - - test "concat all with default (name)" do - alice1 = %Person{name: "Alice", age: 30} - alice2 = %Person{name: "Alice", age: 29} - bob = %Person{name: "Bob", age: 30} - - assert Funx.Eq.eq?(alice1, alice2, eq_concat_all_default()) - refute Funx.Eq.eq?(alice1, bob, eq_concat_all_default()) - - refute Funx.Eq.not_eq?(alice1, alice2, eq_concat_all_default()) - assert Funx.Eq.not_eq?(alice1, bob, eq_concat_all_default()) + combined = Funx.Eq.concat_all([eq_name(), eq_age()]) + assert Funx.Eq.eq?(alice1, alice2, combined) end - test "concat any with default (name)" do + test "concat_any/1 delegates to compose_any/1" do alice1 = %Person{name: "Alice", age: 30} alice2 = %Person{name: "Alice", age: 29} - bob = %Person{name: "Bob", age: 30} - assert Funx.Eq.eq?(alice1, alice2, eq_concat_any_default()) - refute Funx.Eq.eq?(alice1, bob, eq_concat_any_default()) - - refute Funx.Eq.not_eq?(alice1, alice2, eq_concat_any_default()) - assert Funx.Eq.not_eq?(alice1, bob, eq_concat_any_default()) + combined = Funx.Eq.concat_any([eq_name(), eq_age()]) + assert Funx.Eq.eq?(alice1, alice2, combined) end end @@ -855,74 +792,6 @@ defmodule Funx.EqTest do end describe "property: monoid laws" do - property "concat_all: empty list behaves as identity" do - check all(value <- integer()) do - # Empty concat_all should compare everything as equal (identity) - empty_eq = Funx.Eq.concat_all([]) - - # With empty list, everything equals everything (vacuous truth) - assert empty_eq.eq?.(value, value) - assert empty_eq.eq?.(value, value + 1) - end - end - - property "concat_any: empty list behaves as identity" do - check all(value <- integer()) do - # Empty concat_any should compare nothing as equal - empty_eq = Funx.Eq.concat_any([]) - - # With empty list, nothing equals anything (even itself) - refute empty_eq.eq?.(value, value) - refute empty_eq.eq?.(value, value + 1) - end - end - - property "append_all combines equality checks with AND logic" do - check all( - name1 <- string(:alphanumeric, min_length: 1), - name2 <- string(:alphanumeric, min_length: 1), - age1 <- integer(1..100), - age2 <- integer(1..100) - ) do - eq_name = Funx.Eq.contramap(& &1.name) - eq_age = Funx.Eq.contramap(& &1.age) - eq_all = Funx.Eq.append_all(eq_name, eq_age) - - person1 = %{name: name1, age: age1} - person2 = %{name: name2, age: age2} - - # Both name and age must match - if name1 == name2 and age1 == age2 do - assert eq_all.eq?.(person1, person2) - else - refute eq_all.eq?.(person1, person2) - end - end - end - - property "append_any combines equality checks with OR logic" do - check all( - name1 <- string(:alphanumeric, min_length: 1), - name2 <- string(:alphanumeric, min_length: 1), - age1 <- integer(1..100), - age2 <- integer(1..100) - ) do - eq_name = Funx.Eq.contramap(& &1.name) - eq_age = Funx.Eq.contramap(& &1.age) - eq_any = Funx.Eq.append_any(eq_name, eq_age) - - person1 = %{name: name1, age: age1} - person2 = %{name: name2, age: age2} - - # Either name or age must match - if name1 == name2 or age1 == age2 do - assert eq_any.eq?.(person1, person2) - else - refute eq_any.eq?.(person1, person2) - end - end - end - property "compose_all/1: empty list behaves as identity" do check all(value <- integer()) do # Empty compose_all should compare everything as equal (identity) diff --git a/test/monoid/max_test.exs b/test/monoid/max_test.exs index 618de7c3..fa891b2b 100644 --- a/test/monoid/max_test.exs +++ b/test/monoid/max_test.exs @@ -15,7 +15,7 @@ defmodule Funx.Monoid.MaxTest do m_concat( %Monoid.Max{ value: Maybe.nothing(), - ord: concat([ord_age(), ord_ticket(), Funx.Ord.Protocol]) + ord: compose([ord_age(), ord_ticket(), Funx.Ord.Protocol]) }, people ) @@ -25,7 +25,7 @@ defmodule Funx.Monoid.MaxTest do m_concat( %Monoid.Max{ value: Maybe.nothing(), - ord: concat([Funx.Ord.Protocol, ord_age()]) + ord: compose([Funx.Ord.Protocol, ord_age()]) }, people ) @@ -35,7 +35,7 @@ defmodule Funx.Monoid.MaxTest do m_concat( %Monoid.Max{ value: Maybe.nothing(), - ord: concat([ord_ticket(), ord_age()]) + ord: compose([ord_ticket(), ord_age()]) }, people ) diff --git a/test/ord_test.exs b/test/ord_test.exs index 5864c250..809b3f7b 100644 --- a/test/ord_test.exs +++ b/test/ord_test.exs @@ -22,13 +22,7 @@ defmodule Funx.OrdTest do defp ord_name, do: contramap(& &1.name) defp ord_age, do: contramap(& &1.age) defp ord_ticket, do: contramap(& &1.ticket) - defp ord_append, do: append(ord_name(), ord_age()) - defp ord_concat, do: concat([ord_name(), ord_age()]) - defp ord_concat_age, do: concat([ord_age(), ord_ticket(), Funx.Ord.Protocol]) - defp ord_concat_default, do: concat([Funx.Ord.Protocol]) - defp ord_empty, do: concat([]) - # Compose fixtures (new API) defp ord_compose_2, do: compose(ord_name(), ord_age()) defp ord_compose_list, do: compose([ord_name(), ord_age()]) defp ord_compose_age, do: compose([ord_age(), ord_ticket(), Funx.Ord.Protocol]) @@ -393,56 +387,21 @@ defmodule Funx.OrdTest do # Monoid Operations Tests # ============================================================================ - describe "Ord Monoid - append and concat" do - test "with ordered persons" do - alice = %Person{name: "Alice", age: 30, ticket: :b} - bob = %Person{name: "Bob", age: 25, ticket: :a} - bob_b = %Person{name: "Bob", age: 30, ticket: :a} - bob_c = %Person{name: "Bob", age: 30, ticket: :b} - - assert compare(alice, bob, ord_name()) == :lt - assert compare(bob, alice, ord_name()) == :gt - assert compare(alice, alice, ord_name()) == :eq - - assert compare(alice, bob, ord_age()) == :gt - assert compare(bob, alice, ord_age()) == :lt - assert compare(alice, alice, ord_age()) == :eq - - assert compare(alice, bob, ord_ticket()) == :gt - assert compare(bob, alice, ord_ticket()) == :lt - assert compare(alice, alice, ord_ticket()) == :eq - - assert compare(alice, bob, ord_append()) == :lt - assert compare(bob, alice, ord_append()) == :gt - assert compare(alice, alice, ord_append()) == :eq - - assert compare(alice, bob, ord_concat()) == :lt - assert compare(bob, alice, ord_concat()) == :gt - assert compare(alice, alice, ord_concat()) == :eq - - assert compare(alice, bob, ord_concat_age()) == :gt - assert compare(bob, alice, ord_concat_age()) == :lt - assert compare(alice, alice, ord_concat_age()) == :eq - assert compare(alice, bob_b, ord_concat_age()) == :gt - assert compare(alice, bob_c, ord_concat_age()) == :lt - - assert compare(alice, bob, ord_empty()) == :eq - assert compare(bob, alice, ord_empty()) == :eq - assert compare(alice, alice, ord_empty()) == :eq + describe "Ord Monoid - deprecated append/concat" do + test "append/2 delegates to compose/2" do + alice = %Person{name: "Alice", age: 30} + bob = %Person{name: "Bob", age: 25} - assert ord_concat().lt?.(alice, bob) - assert ord_concat().le?.(alice, alice) - assert ord_concat().gt?.(bob, alice) - assert ord_concat().ge?.(bob, alice) + combined = append(ord_name(), ord_age()) + assert compare(alice, bob, combined) == :lt end - test "with default ord persons (name)" do + test "concat/1 delegates to compose/1" do alice = %Person{name: "Alice", age: 30} bob = %Person{name: "Bob", age: 25} - assert compare(alice, bob, ord_concat_default()) == :lt - assert compare(bob, alice, ord_concat_default()) == :gt - assert compare(alice, alice, ord_concat_default()) == :eq + combined = concat([ord_name(), ord_age()]) + assert compare(alice, bob, combined) == :lt end end @@ -797,71 +756,6 @@ defmodule Funx.OrdTest do end describe "property: monoid laws" do - property "concat with empty list is identity (all equal)" do - check all( - a <- integer(), - b <- integer() - ) do - empty_ord = concat([]) - - # Empty concat makes everything equal - assert compare(a, b, empty_ord) == :eq - end - end - - property "concat combines orderings lexicographically" do - check all( - name1 <- string(:alphanumeric, min_length: 1), - name2 <- string(:alphanumeric, min_length: 1), - age1 <- integer(1..100), - age2 <- integer(1..100) - ) do - ord_name = contramap(& &1.name) - ord_age = contramap(& &1.age) - combined = concat([ord_name, ord_age]) - - person1 = %{name: name1, age: age1} - person2 = %{name: name2, age: age2} - - cond do - # Names differ - ordering determined by name - name1 < name2 -> - assert combined.lt?.(person1, person2) - - name1 > name2 -> - assert combined.gt?.(person1, person2) - - # Names equal - ordering determined by age - name1 == name2 and age1 < age2 -> - assert combined.lt?.(person1, person2) - - name1 == name2 and age1 > age2 -> - assert combined.gt?.(person1, person2) - - # Both equal - name1 == name2 and age1 == age2 -> - assert compare(person1, person2, combined) == :eq - end - end - end - - property "append is associative" do - check all( - x <- integer(), - y <- integer() - ) do - ord1 = contramap(& &1) - ord2 = contramap(& &1) - ord3 = contramap(& &1) - - # (ord1 + ord2) + ord3 == ord1 + (ord2 + ord3) - left = append(append(ord1, ord2), ord3) - right = append(ord1, append(ord2, ord3)) - - assert compare(x, y, left) == compare(x, y, right) - end - end - property "compose/1 with empty list is identity (all equal)" do check all( a <- integer(),